Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2024-05-25 - Accessibility for textual icon buttons
**Learning:** Textual icons (like `>`) used in icon-only buttons can be read confusingly by screen readers (e.g. "greater than"). It is necessary to hide them with `aria-hidden="true"` while providing a descriptive `aria-label` on the parent `<button>`, particularly localized strings like "Envoyer le message" in this application's French context.
**Action:** Always wrap literal text icons in `<span aria-hidden="true">` when the parent button has an `aria-label` to prevent redundant or confusing screen reader announcements.
65 changes: 50 additions & 15 deletions src/components/discussion/composer/ComposerInput.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { SwarmWorkCommand } from '../../../lib/forge-agent-protocol';
import { SWARM_WORK_COMMAND_LABELS } from '../../../lib/forge-agent-protocol';
import type { SwarmWorkCommand } from "../../../lib/forge-agent-protocol";
import { SWARM_WORK_COMMAND_LABELS } from "../../../lib/forge-agent-protocol";

interface Props {
message: string;
Expand All @@ -10,19 +10,44 @@ interface Props {
agentId: string;
send: () => Promise<void>;
applySwarmCommand: (cmd: SwarmWorkCommand) => void;
swarmCommandMode: 'direct' | 'leader';
setSwarmCommandMode: (mode: 'direct' | 'leader' | ((prev: 'direct' | 'leader') => 'direct' | 'leader')) => void;
swarmCommandMode: "direct" | "leader";
setSwarmCommandMode: (
mode:
| "direct"
| "leader"
| ((prev: "direct" | "leader") => "direct" | "leader"),
) => void;
}

export default function ComposerInput({
message, setMessage, sending, historyLoading, sessionUnavailable, agentId,
send, applySwarmCommand, swarmCommandMode, setSwarmCommandMode
message,
setMessage,
sending,
historyLoading,
sessionUnavailable,
agentId,
send,
applySwarmCommand,
swarmCommandMode,
setSwarmCommandMode,
}: Props) {
return (
<div class="sticky bottom-0 z-20 shrink-0 border-t border-gray-200 bg-white/95 p-3 backdrop-blur sm:p-4" style="padding-bottom: max(0.75rem, env(safe-area-inset-bottom));">
<div
class="sticky bottom-0 z-20 shrink-0 border-t border-gray-200 bg-white/95 p-3 backdrop-blur sm:p-4"
style="padding-bottom: max(0.75rem, env(safe-area-inset-bottom));"
>
<div class="mx-auto mb-2 flex max-w-3xl flex-wrap items-center gap-2">
<span class="text-[10px] font-semibold uppercase tracking-wide text-gray-500">Commandes</span>
{(['start_work', 'pause_work', 'resume_work', 'stop_work'] as SwarmWorkCommand[]).map((cmd) => (
<span class="text-[10px] font-semibold uppercase tracking-wide text-gray-500">
Commandes
</span>
{(
[
"start_work",
"pause_work",
"resume_work",
"stop_work",
] as SwarmWorkCommand[]
).map((cmd) => (
<button
key={cmd}
type="button"
Expand All @@ -35,8 +60,10 @@ export default function ComposerInput({
))}
<button
type="button"
class={`ml-auto min-h-[36px] rounded-full border px-3 py-1 text-[11px] font-medium ${swarmCommandMode === 'leader' ? 'bg-[#E9F3EB]' : 'bg-white'}`}
onClick={() => setSwarmCommandMode(m => m === 'leader' ? 'direct' : 'leader')}
class={`ml-auto min-h-[36px] rounded-full border px-3 py-1 text-[11px] font-medium ${swarmCommandMode === "leader" ? "bg-[#E9F3EB]" : "bg-white"}`}
onClick={() =>
setSwarmCommandMode((m) => (m === "leader" ? "direct" : "leader"))
}
disabled={sending || historyLoading}
>
mode {swarmCommandMode}
Expand All @@ -50,7 +77,7 @@ export default function ComposerInput({
onInput={(e) => setMessage((e.target as HTMLTextAreaElement).value)}
disabled={sending || historyLoading || sessionUnavailable}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && (e.ctrlKey || e.metaKey)) {
if (e.key === "Enter" && !e.shiftKey && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void send();
}
Expand All @@ -59,10 +86,18 @@ export default function ComposerInput({
<button
type="button"
onClick={() => void send()}
disabled={sending || historyLoading || sessionUnavailable || !message.trim() || !agentId}
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#175B37] text-white disabled:opacity-40"
disabled={
sending ||
historyLoading ||
sessionUnavailable ||
!message.trim() ||
!agentId
}
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#175B37] text-white disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#175B37] focus-visible:ring-offset-1"
aria-label="Envoyer le message"
title="Envoyer le message"
>
&gt;
<span aria-hidden="true">&gt;</span>
</button>
</div>
</div>
Expand Down
78 changes: 55 additions & 23 deletions src/components/discussion/composer/DiscussionHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AgentTeamProfile } from '../../../lib/agent-profile';
import type { Project, RequestItem } from './types';
import TeamAvatar from '../../agents/TeamAvatar';
import { truncateText } from './types';
import type { AgentTeamProfile } from "../../../lib/agent-profile";
import type { Project, RequestItem } from "./types";
import TeamAvatar from "../../agents/TeamAvatar";
import { truncateText } from "./types";

interface Props {
selectedTeamProfile?: AgentTeamProfile;
Expand All @@ -15,28 +15,40 @@ interface Props {
onEmptyMemberClick?: () => void;
onOpenProfile?: () => void;
policyBadge?: {
mode: 'off' | 'warn' | 'enforce';
state: 'idle' | 'compliant' | 'non_compliant';
mode: "off" | "warn" | "enforce";
state: "idle" | "compliant" | "non_compliant";
};
}

export default function DiscussionHeader({
selectedTeamProfile, selectedAgentId, selectedProject, selectedRequest,
setHeaderMenuOpen, headerMenuOpen, copyToClipboard, onEmptyMemberClick, onOpenProfile, policyBadge
selectedTeamProfile,
selectedAgentId,
selectedProject,
selectedRequest,
setHeaderMenuOpen,
headerMenuOpen,
copyToClipboard,
onEmptyMemberClick,
onOpenProfile,
policyBadge,
}: Props) {
const badgeClass =
policyBadge?.state === 'compliant'
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: policyBadge?.state === 'non_compliant'
? 'border-rose-200 bg-rose-50 text-rose-700'
: 'border-amber-200 bg-amber-50 text-amber-700';
policyBadge?.state === "compliant"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: policyBadge?.state === "non_compliant"
? "border-rose-200 bg-rose-50 text-rose-700"
: "border-amber-200 bg-amber-50 text-amber-700";

return (
<header class="relative flex shrink-0 items-start justify-between gap-3 border-b border-gray-200 bg-white px-4 py-3 sm:px-5">
<div class="flex min-w-0 flex-1 gap-3">
{selectedTeamProfile ? (
<div class="hidden shrink-0 sm:block">
<TeamAvatar profile={selectedTeamProfile} size="md" class="rounded-2xl shadow-inner ring-1 ring-gray-100" />
<TeamAvatar
profile={selectedTeamProfile}
size="md"
class="rounded-2xl shadow-inner ring-1 ring-gray-100"
/>
</div>
) : (
<div class="hidden h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gray-100 text-sm font-bold text-gray-400 shadow-inner sm:flex">
Expand Down Expand Up @@ -64,7 +76,7 @@ export default function DiscussionHeader({
<>
<div class="flex flex-wrap items-center gap-2">
<h2 class="truncate text-base font-semibold text-gray-900 sm:text-lg">
{selectedTeamProfile?.displayName ?? 'Sélectionnez un membre'}
{selectedTeamProfile?.displayName ?? "Sélectionnez un membre"}
</h2>
{selectedTeamProfile ? (
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
Expand All @@ -75,7 +87,7 @@ export default function DiscussionHeader({
<p class="mt-0.5 truncate text-xs text-gray-500 sm:text-sm">
{selectedTeamProfile
? `Agent ZimaOS · ${selectedTeamProfile.presenceLabel} · modèle ${selectedTeamProfile.modelShort}`
: 'Choisissez un membre dans la liste.'}
: "Choisissez un membre dans la liste."}
</p>
</>
)}
Expand All @@ -88,15 +100,23 @@ export default function DiscussionHeader({
) : null}
{selectedRequest ? (
<span class="rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[11px] text-gray-600">
#{selectedRequest.id} · {truncateText(selectedRequest.title, 28)}
#{selectedRequest.id} ·{" "}
{truncateText(selectedRequest.title, 28)}
</span>
) : null}
</div>
)}
{policyBadge && policyBadge.mode !== 'off' ? (
{policyBadge && policyBadge.mode !== "off" ? (
<div class="mt-2">
<span class={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${badgeClass}`}>
Policy strict: {policyBadge.mode} · {policyBadge.state === 'compliant' ? 'conforme' : policyBadge.state === 'non_compliant' ? 'non conforme' : 'en attente'}
<span
class={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${badgeClass}`}
>
Policy strict: {policyBadge.mode} ·{" "}
{policyBadge.state === "compliant"
? "conforme"
: policyBadge.state === "non_compliant"
? "non conforme"
: "en attente"}
</span>
</div>
) : null}
Expand All @@ -113,18 +133,30 @@ export default function DiscussionHeader({
</button>
<button
type="button"
class="flex h-11 w-11 items-center justify-center rounded-full border border-gray-200 text-gray-500 transition hover:bg-gray-50 hover:text-gray-800"
class="flex h-11 w-11 items-center justify-center rounded-full border border-gray-200 text-gray-500 transition hover:bg-gray-50 hover:text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-1"
onClick={() => setHeaderMenuOpen((v: boolean) => !v)}
aria-label="Ouvrir le menu des options"
title="Ouvrir le menu des options"
aria-haspopup="true"
aria-expanded={headerMenuOpen}
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<svg
class="h-5 w-5"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M12 8a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
{headerMenuOpen && (
<div class="absolute right-0 top-10 z-20 mt-1 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg">
<button
class="block w-full px-3 py-2 text-left text-sm hover:bg-gray-50"
onClick={() => { setHeaderMenuOpen(false); if (selectedAgentId) void copyToClipboard(selectedAgentId); }}
onClick={() => {
setHeaderMenuOpen(false);
if (selectedAgentId) void copyToClipboard(selectedAgentId);
}}
disabled={!selectedAgentId}
>
Copier la clé de session
Comment on lines 153 to 162
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the trigger button now has aria-haspopup="true" and aria-expanded, the popup container should have role="menu" and its interactive children should have role="menuitem" to conform to the ARIA Menu Button pattern. Additionally, adding an onKeyDown handler to close the menu when the Escape key is pressed significantly improves keyboard accessibility.

          <div
            class="absolute right-0 top-10 z-20 mt-1 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg"
            role="menu"
            onKeyDown={(e) => {
              if (e.key === "Escape") {
                setHeaderMenuOpen(false);
              }
            }}
          >
            <button
              role="menuitem"
              class="block w-full px-3 py-2 text-left text-sm hover:bg-gray-50"
              onClick={() => {
                setHeaderMenuOpen(false);
                if (selectedAgentId) void copyToClipboard(selectedAgentId);
              }}
              disabled={!selectedAgentId}
            >
              Copier la clé de session

Expand Down