From 510f67933c6626572baba3c4a1cfa079ec9cfab8 Mon Sep 17 00:00:00 2001 From: Victor Tsaran Date: Sat, 30 Aug 2025 17:44:52 -0700 Subject: [PATCH 1/3] Adds accessibility labels and states This adds ARIA labels, states and properties to the Claude Code Session and related flows. The goal is to make sure that screen reader users have a decent experience when interacting with Claude Code inside a session. More work is needed around focus management and element order, but this will hopefully will be added in subsequent commits. --- src/components/Agents.tsx | 4 +- src/components/CCAgents.tsx | 2 + src/components/ClaudeCodeSession.tsx | 54 +++++++++++++------ src/components/CustomTitlebar.tsx | 20 ++++++- src/components/FilePicker.tsx | 5 +- src/components/FloatingPromptInput.tsx | 19 +++++++ src/components/SessionList.tsx | 24 +++++++++ src/components/Settings.tsx | 15 +++++- src/components/StorageTab.tsx | 6 ++- src/components/TabManager.tsx | 15 +++++- src/components/TimelineNavigator.tsx | 14 +++-- src/components/Topbar.tsx | 7 ++- .../claude-code-session/MessageList.tsx | 3 ++ .../claude-code-session/PromptQueue.tsx | 20 ++++--- .../claude-code-session/SessionHeader.tsx | 12 +++-- src/components/ui/pagination.tsx | 12 +++-- 16 files changed, 181 insertions(+), 51 deletions(-) diff --git a/src/components/Agents.tsx b/src/components/Agents.tsx index 795071786..c0f041f5b 100644 --- a/src/components/Agents.tsx +++ b/src/components/Agents.tsx @@ -354,8 +354,8 @@ export const Agents: React.FC = () => { - diff --git a/src/components/CCAgents.tsx b/src/components/CCAgents.tsx index f72b154ee..8cda1a8d5 100644 --- a/src/components/CCAgents.tsx +++ b/src/components/CCAgents.tsx @@ -469,6 +469,7 @@ export const CCAgents: React.FC = ({ onBack, className }) => { variant="outline" onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} + aria-label={`Go to previous page (page ${currentPage - 1} of ${totalPages})`} > Previous @@ -480,6 +481,7 @@ export const CCAgents: React.FC = ({ onBack, className }) => { variant="outline" onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} + aria-label={`Go to next page (page ${currentPage + 1} of ${totalPages})`} > Next diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index f0f164f21..f8c61ac44 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -1377,10 +1377,13 @@ export const ClaudeCodeSession: React.FC = ({ animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} className="fixed bottom-24 left-1/2 -translate-x-1/2 z-30 w-full max-w-3xl px-4" + role="region" + aria-label="Prompt queue" + aria-live="polite" >
-
+
Queued Prompts ({queuedPrompts.length})
@@ -1388,21 +1391,28 @@ export const ClaudeCodeSession: React.FC = ({ whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} > -
- {!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => ( - + {!queuedPromptsCollapsed && ( +
    + {queuedPrompts.map((queuedPrompt, index) => ( +
    #{index + 1} @@ -1421,12 +1431,15 @@ export const ClaudeCodeSession: React.FC = ({ size="icon" className="h-6 w-6 flex-shrink-0" onClick={() => setQueuedPrompts(prev => prev.filter(p => p.id !== queuedPrompt.id))} + aria-label={`Remove queued prompt: ${queuedPrompt.prompt.slice(0, 50)}${queuedPrompt.prompt.length > 50 ? '...' : ''}`} > - - ))} + + ))} +
+ )}
)} @@ -1475,7 +1488,8 @@ export const ClaudeCodeSession: React.FC = ({ }} className="px-3 py-2 hover:bg-accent rounded-none" > - + @@ -1508,7 +1522,8 @@ export const ClaudeCodeSession: React.FC = ({ }} className="px-3 py-2 hover:bg-accent rounded-none" > - + @@ -1540,6 +1555,9 @@ export const ClaudeCodeSession: React.FC = ({ size="icon" onClick={() => setShowTimeline(!showTimeline)} className="h-9 w-9 text-muted-foreground hover:text-foreground" + aria-label="Session timeline" + aria-haspopup="dialog" + aria-expanded={showTimeline} > @@ -1558,6 +1576,8 @@ export const ClaudeCodeSession: React.FC = ({ variant="ghost" size="icon" className="h-9 w-9 text-muted-foreground hover:text-foreground" + aria-label="Copy Conversation" + aria-haspopup="menu" > @@ -1600,6 +1620,8 @@ export const ClaudeCodeSession: React.FC = ({ size="icon" onClick={() => setShowSettings(!showSettings)} className="h-8 w-8 text-muted-foreground hover:text-foreground" + aria-label="Checkpoint Settings" + aria-haspopup="dialog" > diff --git a/src/components/CustomTitlebar.tsx b/src/components/CustomTitlebar.tsx index 3342959b0..ac3e3dfd6 100644 --- a/src/components/CustomTitlebar.tsx +++ b/src/components/CustomTitlebar.tsx @@ -91,6 +91,7 @@ export const CustomTitlebar: React.FC = ({ }} className="group relative w-3 h-3 rounded-full bg-red-500 hover:bg-red-600 transition-all duration-200 flex items-center justify-center tauri-no-drag" title="Close" + aria-label="Close window" > {isHovered && ( @@ -105,6 +106,7 @@ export const CustomTitlebar: React.FC = ({ }} className="group relative w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-600 transition-all duration-200 flex items-center justify-center tauri-no-drag" title="Minimize" + aria-label="Minimize window" > {isHovered && ( @@ -119,6 +121,7 @@ export const CustomTitlebar: React.FC = ({ }} className="group relative w-3 h-3 rounded-full bg-green-500 hover:bg-green-600 transition-all duration-200 flex items-center justify-center tauri-no-drag" title="Maximize" + aria-label="Maximize window" > {isHovered && ( @@ -146,6 +149,7 @@ export const CustomTitlebar: React.FC = ({ whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors tauri-no-drag" + aria-label="Agents manager" > @@ -159,6 +163,7 @@ export const CustomTitlebar: React.FC = ({ whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors tauri-no-drag" + aria-label="Usage dashboard" > @@ -178,6 +183,7 @@ export const CustomTitlebar: React.FC = ({ whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors tauri-no-drag" + aria-label="Settings" > @@ -192,13 +198,19 @@ export const CustomTitlebar: React.FC = ({ whileTap={{ scale: 0.97 }} transition={{ duration: 0.15 }} className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors flex items-center gap-1" + aria-label="More options" + aria-expanded={isDropdownOpen} > {isDropdownOpen && ( -
+
{onClaudeClick && ( @@ -922,6 +923,9 @@ const FloatingPromptInputInner = ( size="sm" onClick={() => setModelPickerOpen(!modelPickerOpen)} className="gap-2" + aria-label={`Select model, currently ${selectedModelData.name}`} + aria-expanded={modelPickerOpen} + aria-haspopup="listbox" > {selectedModelData.icon} @@ -943,6 +947,9 @@ const FloatingPromptInputInner = ( "hover:bg-accent", selectedModel === model.id && "bg-accent" )} + role="option" + aria-selected={selectedModel === model.id} + aria-label={`Select ${model.name} model`} >
@@ -977,6 +984,9 @@ const FloatingPromptInputInner = ( size="sm" onClick={() => setThinkingModePickerOpen(!thinkingModePickerOpen)} className="gap-2" + aria-label={`Select thinking mode: ${THINKING_MODES.find(m => m.id === selectedThinkingMode)?.name} - ${THINKING_MODES.find(m => m.id === selectedThinkingMode)?.description}`} + aria-expanded={!thinkingModePickerOpen} + aria-haspopup="listbox" > m.id === selectedThinkingMode)?.color}> {THINKING_MODES.find(m => m.id === selectedThinkingMode)?.icon} @@ -1041,6 +1051,7 @@ const FloatingPromptInputInner = ( disabled={!prompt.trim() || disabled} size="default" className="min-w-[60px]" + aria-label={isLoading ? "Cancel current request" : "Send message"} > {isLoading ? (
@@ -1095,6 +1106,8 @@ const FloatingPromptInputInner = ( size="sm" disabled={disabled} className="h-9 px-2 hover:bg-accent/50 gap-1" + aria-label={`Select model: ${selectedModelData.name} - ${selectedModelData.description}`} + aria-haspopup="menu" > {selectedModelData.icon} @@ -1126,6 +1139,7 @@ const FloatingPromptInputInner = ( "hover:bg-accent", selectedModel === model.id && "bg-accent" )} + aria-label={`Select ${model.name} model: ${model.description}`} >
@@ -1161,6 +1175,8 @@ const FloatingPromptInputInner = ( size="sm" disabled={disabled} className="h-9 px-2 hover:bg-accent/50 gap-1" + aria-label={`Select thinking mode: ${THINKING_MODES.find(m => m.id === selectedThinkingMode)?.name} - ${THINKING_MODES.find(m => m.id === selectedThinkingMode)?.description}`} + aria-haspopup="menu" > m.id === selectedThinkingMode)?.color}> {THINKING_MODES.find(m => m.id === selectedThinkingMode)?.icon} @@ -1192,6 +1208,7 @@ const FloatingPromptInputInner = ( "hover:bg-accent", selectedThinkingMode === mode.id && "bg-accent" )} + aria-label={`Select ${mode.name} thinking mode: ${mode.description}`} > {mode.icon} @@ -1257,6 +1274,7 @@ const FloatingPromptInputInner = ( onClick={() => setIsExpanded(true)} disabled={disabled} className="h-8 w-8 hover:bg-accent/50 transition-colors" + aria-label="Expand prompt editor" > @@ -1277,6 +1295,7 @@ const FloatingPromptInputInner = ( "h-8 w-8 transition-all", prompt.trim() && !isLoading && "shadow-sm" )} + aria-label={isLoading ? "Stop generation" : "Send message"} > {isLoading ? ( diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 9575c015c..f94dd4150 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -105,6 +105,20 @@ export const SessionList: React.FC = ({ "p-3 hover:bg-accent/50 transition-all duration-200 cursor-pointer group h-full", session.todo_data && "bg-primary/5" )} + role="button" + tabIndex={0} + aria-label={`Open session from ${session.message_timestamp + ? new Date(session.message_timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }) + : new Date(session.created_at * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }) + }${session.first_message ? `. First message: ${truncateText(getFirstLine(session.first_message), 60)}` : '. No messages yet'}${session.todo_data ? '. Contains todo items.' : ''}`} onClick={() => { // Emit a special event for Claude Code session navigation const event = new CustomEvent('claude-session-selected', { @@ -113,6 +127,16 @@ export const SessionList: React.FC = ({ window.dispatchEvent(event); onSessionClick?.(session); }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const event = new CustomEvent('claude-session-selected', { + detail: { session, projectPath } + }); + window.dispatchEvent(event); + onSessionClick?.(session); + } + }} >
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 06d338a0c..b66bdbbf7 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -387,8 +387,9 @@ export const Settings: React.FC = ({ {/* Content */} {loading ? ( -
+
+ Loading settings...
) : (
@@ -633,7 +634,7 @@ export const Settings: React.FC = ({
-

+

How long to retain chat transcripts locally (default: 30 days)

@@ -648,6 +649,7 @@ export const Settings: React.FC = ({ updateSetting("cleanupPeriodDays", value); }} className="w-24" + aria-describedby="cleanup-description" />
@@ -792,6 +794,7 @@ export const Settings: React.FC = ({ size="sm" onClick={() => addPermissionRule("allow")} className="gap-2 hover:border-green-500/50 hover:text-green-500" + aria-label="Add new allow rule" > Add Rule @@ -816,12 +819,14 @@ export const Settings: React.FC = ({ value={rule.value} onChange={(e) => updatePermissionRule("allow", rule.id, e.target.value)} className="flex-1" + aria-label="Allow rule pattern" /> @@ -840,6 +845,7 @@ export const Settings: React.FC = ({ size="sm" onClick={() => addPermissionRule("deny")} className="gap-2 hover:border-red-500/50 hover:text-red-500" + aria-label="Add new deny rule" > Add Rule @@ -864,12 +870,14 @@ export const Settings: React.FC = ({ value={rule.value} onChange={(e) => updatePermissionRule("deny", rule.id, e.target.value)} className="flex-1" + aria-label="Deny rule pattern" /> @@ -935,6 +943,7 @@ export const Settings: React.FC = ({ value={envVar.key} onChange={(e) => updateEnvVar(envVar.id, "key", e.target.value)} className="flex-1 font-mono text-sm" + aria-label="Environment variable key" /> = = ({ value={envVar.value} onChange={(e) => updateEnvVar(envVar.id, "value", e.target.value)} className="flex-1 font-mono text-sm" + aria-label="Environment variable value" /> diff --git a/src/components/StorageTab.tsx b/src/components/StorageTab.tsx index aa4071cb3..be887ca03 100644 --- a/src/components/StorageTab.tsx +++ b/src/components/StorageTab.tsx @@ -529,8 +529,9 @@ export const StorageTab: React.FC = () => { onClick={() => loadTableData(currentPage - 1)} disabled={currentPage === 1} className="h-7 text-xs" + aria-label={`Go to previous page (page ${currentPage - 1} of ${tableData?.total_pages || 1})`} > - +
diff --git a/src/components/TabManager.tsx b/src/components/TabManager.tsx index 5f63badba..31285a6e3 100644 --- a/src/components/TabManager.tsx +++ b/src/components/TabManager.tsx @@ -75,6 +75,10 @@ const TabItem: React.FC = ({ tab, isActive, onClose, onClick, isDr isDragging && "bg-card border-primary/50 shadow-sm z-50", "min-w-[120px] max-w-[220px] h-8 px-3" )} + role="tab" + aria-selected={isActive} + aria-label={`${tab.title} ${tab.type} tab`} + tabIndex={isActive ? 0 : -1} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onClick={() => onClick(tab.id)} @@ -103,6 +107,7 @@ const TabItem: React.FC = ({ tab, isActive, onClose, onClick, isDr )}
@@ -120,9 +125,10 @@ const TabItem: React.FC = ({ tab, isActive, onClose, onClick, isDr (isHovered || isActive) ? "opacity-100" : "opacity-0" )} title={`Close ${tab.title}`} + aria-label={`Close ${tab.title} tab`} tabIndex={-1} > - +
@@ -395,6 +405,7 @@ export const TabManager: React.FC = ({ className }) => { "bg-background/80 backdrop-blur-sm shadow-sm border border-border/50" )} title="Scroll tabs right" + aria-label="Scroll tabs to the right" > diff --git a/src/components/TimelineNavigator.tsx b/src/components/TimelineNavigator.tsx index 2cc0ce154..2fd149ded 100644 --- a/src/components/TimelineNavigator.tsx +++ b/src/components/TimelineNavigator.tsx @@ -281,11 +281,12 @@ export const TimelineNavigator: React.FC = ({ size="icon" className="h-6 w-6 -ml-1" onClick={() => toggleNodeExpansion(node.checkpoint.id)} + aria-label={isExpanded ? "Collapse checkpoint" : "Expand checkpoint"} > {isExpanded ? ( - + ) : ( - + )} )} @@ -348,8 +349,9 @@ export const TimelineNavigator: React.FC = ({ e.stopPropagation(); handleRestoreCheckpoint(node.checkpoint); }} + aria-label="Restore to this checkpoint" > - + Restore to this checkpoint @@ -367,8 +369,9 @@ export const TimelineNavigator: React.FC = ({ e.stopPropagation(); handleFork(node.checkpoint); }} + aria-label="Fork from this checkpoint" > - + Fork from this checkpoint @@ -386,8 +389,9 @@ export const TimelineNavigator: React.FC = ({ e.stopPropagation(); handleCompare(node.checkpoint); }} + aria-label="Compare with another checkpoint" > - + Compare with another checkpoint diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index eeb5bc743..caea5da96 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -85,7 +85,7 @@ export const Topbar: React.FC = ({ const StatusIndicator = () => { if (checking) { return ( -
+
Checking...
@@ -100,8 +100,11 @@ export const Topbar: React.FC = ({ size="sm" className="h-auto py-1 px-2 hover:bg-accent" onClick={onSettingsClick} + aria-label={versionStatus.is_installed + ? `Claude Code is installed, version ${versionStatus.version || 'unknown'}. Click to open settings.` + : "Claude Code not found. Click to configure installation."} > -
+
= React.memo(({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="sticky bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-background to-transparent" + role="status" + aria-live="polite" + aria-label="Claude is processing your request" >
diff --git a/src/components/claude-code-session/PromptQueue.tsx b/src/components/claude-code-session/PromptQueue.tsx index b2b2f546e..cdff4326f 100644 --- a/src/components/claude-code-session/PromptQueue.tsx +++ b/src/components/claude-code-session/PromptQueue.tsx @@ -30,20 +30,23 @@ export const PromptQueue: React.FC = React.memo(({ animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className={cn("border-t bg-muted/20", className)} + role="region" + aria-label="Prompt queue" + aria-live="polite" >
- Queued Prompts + Queued Prompts {queuedPrompts.length}
-
+
    {queuedPrompts.map((queuedPrompt, index) => ( - = React.memo(({ >
    {queuedPrompt.model === "opus" ? ( - + ) : ( - + )}
    @@ -71,13 +74,14 @@ export const PromptQueue: React.FC = React.memo(({ size="icon" className="h-6 w-6 flex-shrink-0" onClick={() => onRemove(queuedPrompt.id)} + aria-label={`Remove queued prompt: ${queuedPrompt.prompt.slice(0, 50)}${queuedPrompt.prompt.length > 50 ? '...' : ''}`} > - +
    + ))}
    -
+
); diff --git a/src/components/claude-code-session/SessionHeader.tsx b/src/components/claude-code-session/SessionHeader.tsx index b48a24823..968679460 100644 --- a/src/components/claude-code-session/SessionHeader.tsx +++ b/src/components/claude-code-session/SessionHeader.tsx @@ -64,6 +64,7 @@ export const SessionHeader: React.FC = React.memo(({ size="icon" onClick={onBack} className="h-8 w-8" + aria-label="Go back to session list" > @@ -107,8 +108,8 @@ export const SessionHeader: React.FC = React.memo(({ open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} trigger={ - } content={ @@ -143,14 +144,15 @@ export const SessionHeader: React.FC = React.memo(({ "h-8 w-8 transition-colors", showTimeline && "bg-accent text-accent-foreground" )} + aria-label={showTimeline ? "Hide timeline" : "Show timeline"} > - +