Skip to content

Commit b8eb139

Browse files
committed
feat: add screen reader announcements for better accessibility
Implements comprehensive screen reader support with live region announcements for Claude's responses and tool execution. Includes message content cleaning to prevent duplicated announcements for screen readers.
1 parent 156013b commit b8eb139

File tree

4 files changed

+236
-1
lines changed

4 files changed

+236
-1
lines changed

src/components/ClaudeCodeSession.tsx

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { SplitPane } from "@/components/ui/split-pane";
2828
import { WebviewPreview } from "./WebviewPreview";
2929
import type { ClaudeStreamMessage } from "./AgentExecution";
3030
import { useVirtualizer } from "@tanstack/react-virtual";
31-
import { useTrackEvent, useComponentMetrics, useWorkflowTracking } from "@/hooks";
31+
import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useScreenReaderAnnouncements } from "@/hooks";
3232
import { SessionPersistenceService } from "@/services/sessionPersistence";
3333

3434
interface ClaudeCodeSessionProps {
@@ -140,6 +140,15 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
140140
// const aiTracking = useAIInteractionTracking('sonnet'); // Default model
141141
const workflowTracking = useWorkflowTracking('claude_session');
142142

143+
// Screen reader announcements
144+
const {
145+
announceClaudeStarted,
146+
announceClaudeFinished,
147+
announceAssistantMessage,
148+
announceToolExecution,
149+
announceToolCompleted
150+
} = useScreenReaderAnnouncements();
151+
143152
// Call onProjectPathChange when component mounts with initial path
144153
useEffect(() => {
145154
if (onProjectPathChange && projectPath) {
@@ -480,6 +489,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
480489
setError(null);
481490
hasActiveSessionRef.current = true;
482491

492+
// Announce that Claude is starting to process
493+
announceClaudeStarted();
494+
483495
// For resuming sessions, ensure we have the session ID
484496
if (effectiveSession && !claudeSessionId) {
485497
setClaudeSessionId(effectiveSession.id);
@@ -570,6 +582,60 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
570582
}
571583
});
572584

585+
// Helper to find tool name by tool use ID from previous messages
586+
function findToolNameById(toolUseId: string): string | null {
587+
// Search backwards through messages for the tool use
588+
for (let i = messages.length - 1; i >= 0; i--) {
589+
const msg = messages[i];
590+
if (msg.type === 'assistant' && msg.message?.content) {
591+
const toolUse = msg.message.content.find((c: any) =>
592+
c.type === 'tool_use' && c.id === toolUseId
593+
);
594+
if (toolUse?.name) {
595+
return toolUse.name;
596+
}
597+
}
598+
}
599+
return null;
600+
}
601+
602+
// Helper to announce incoming messages to screen readers
603+
function announceIncomingMessage(message: ClaudeStreamMessage) {
604+
if (message.type === 'assistant' && message.message?.content) {
605+
// Announce tool execution
606+
const toolUses = message.message.content.filter((c: any) => c.type === 'tool_use');
607+
toolUses.forEach((toolUse: any) => {
608+
const toolName = toolUse.name || 'unknown tool';
609+
const description = toolUse.input?.description ||
610+
toolUse.input?.command ||
611+
toolUse.input?.file_path ||
612+
toolUse.input?.pattern ||
613+
toolUse.input?.prompt?.substring(0, 50);
614+
announceToolExecution(toolName, description);
615+
});
616+
617+
// Announce text content
618+
const textContent = message.message.content
619+
.filter((c: any) => c.type === 'text')
620+
.map((c: any) => typeof c.text === 'string' ? c.text : (c.text?.text || ''))
621+
.join(' ')
622+
.trim();
623+
624+
if (textContent) {
625+
announceAssistantMessage(textContent);
626+
}
627+
} else if (message.type === 'system') {
628+
// Announce system messages if they have meaningful content
629+
if (message.subtype === 'init') {
630+
// Don't announce init messages as they're just setup
631+
return;
632+
} else if (message.result || message.error) {
633+
const content = message.result || message.error || 'System message received';
634+
announceAssistantMessage(content);
635+
}
636+
}
637+
}
638+
573639
// Helper to process any JSONL stream message string
574640
function handleStreamMessage(payload: string) {
575641
try {
@@ -581,6 +647,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
581647

582648
const message = JSON.parse(payload) as ClaudeStreamMessage;
583649

650+
// Announce incoming messages to screen readers
651+
announceIncomingMessage(message);
652+
584653
// Track enhanced tool execution
585654
if (message.type === 'assistant' && message.message?.content) {
586655
const toolUses = message.message.content.filter((c: any) => c.type === 'tool_use');
@@ -609,6 +678,14 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
609678
const toolResults = message.message.content.filter((c: any) => c.type === 'tool_result');
610679
toolResults.forEach((result: any) => {
611680
const isError = result.is_error || false;
681+
682+
// Announce tool completion
683+
if (result.tool_use_id) {
684+
// Try to find the tool name from previous messages
685+
const toolName = findToolNameById(result.tool_use_id) || 'Tool';
686+
// announceToolCompleted(toolName, !isError); // Disabled to prevent interrupting other announcements
687+
}
688+
612689
// Note: We don't have execution time here, but we can track success/failure
613690
if (isError) {
614691
sessionMetrics.current.toolsFailed += 1;
@@ -660,6 +737,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
660737
hasActiveSessionRef.current = false;
661738
isListeningRef.current = false; // Reset listening state
662739

740+
// Announce that Claude has finished
741+
announceClaudeFinished();
742+
663743
// Track enhanced session stopped metrics when session completes
664744
if (effectiveSession && claudeSessionId) {
665745
const sessionStartTimeValue = messages.length > 0 ? messages[0].timestamp || Date.now() : Date.now();

src/components/StreamMessage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
118118
<div className="flex items-start gap-3">
119119
<Bot className="h-5 w-5 text-primary mt-0.5" />
120120
<div className="flex-1 space-y-2 min-w-0">
121+
<h2 className="sr-only">Assistant Response</h2>
121122
{msg.content && Array.isArray(msg.content) && msg.content.map((content: any, idx: number) => {
122123
// Text content - render as markdown
123124
if (content.type === "text") {
@@ -331,6 +332,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
331332
<div className="flex items-start gap-3">
332333
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
333334
<div className="flex-1 space-y-2 min-w-0">
335+
<h2 className="sr-only">User Prompt</h2>
334336
{/* Handle content that is a simple string (e.g. from user commands) */}
335337
{(typeof msg.content === 'string' || (msg.content && !Array.isArray(msg.content))) && (
336338
(() => {

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ export {
2424
useAsyncPerformanceTracker
2525
} from './usePerformanceMonitor';
2626
export { TAB_SCREEN_NAMES } from './useAnalytics';
27+
export { useScreenReaderAnnouncements } from './useScreenReaderAnnouncements';
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useCallback, useRef } from 'react';
2+
3+
export interface ScreenReaderAnnouncementOptions {
4+
priority?: 'polite' | 'assertive';
5+
delay?: number;
6+
}
7+
8+
/**
9+
* Custom hook to manage screen reader announcements for incoming messages
10+
* Uses ARIA live regions to announce content to screen readers
11+
*/
12+
export const useScreenReaderAnnouncements = () => {
13+
const announcementTimeoutRef = useRef<NodeJS.Timeout | null>(null);
14+
const lastAnnouncementRef = useRef<string>('');
15+
16+
/**
17+
* Announces a message to screen readers via ARIA live regions
18+
* @param message - The message content to announce
19+
* @param options - Configuration options for the announcement
20+
*/
21+
const announceMessage = useCallback((message: string, options: ScreenReaderAnnouncementOptions = {}) => {
22+
const { priority = 'polite', delay = 100 } = options;
23+
24+
// Clear any pending announcements
25+
if (announcementTimeoutRef.current) {
26+
clearTimeout(announcementTimeoutRef.current);
27+
}
28+
29+
// Don't announce the same message twice in a row
30+
if (message === lastAnnouncementRef.current) {
31+
return;
32+
}
33+
34+
lastAnnouncementRef.current = message;
35+
36+
// Small delay to ensure DOM updates are complete
37+
announcementTimeoutRef.current = setTimeout(() => {
38+
// Find or create the live region
39+
let liveRegion = document.getElementById('claude-announcements');
40+
41+
if (!liveRegion) {
42+
liveRegion = document.createElement('div');
43+
liveRegion.id = 'claude-announcements';
44+
liveRegion.setAttribute('aria-live', priority);
45+
liveRegion.setAttribute('aria-atomic', 'true');
46+
liveRegion.className = 'sr-only';
47+
liveRegion.style.cssText = `
48+
position: absolute;
49+
width: 1px;
50+
height: 1px;
51+
padding: 0;
52+
margin: -1px;
53+
overflow: hidden;
54+
clip: rect(0, 0, 0, 0);
55+
white-space: nowrap;
56+
border: 0;
57+
`;
58+
document.body.appendChild(liveRegion);
59+
}
60+
61+
// Update the live region priority if needed
62+
if (liveRegion.getAttribute('aria-live') !== priority) {
63+
liveRegion.setAttribute('aria-live', priority);
64+
}
65+
66+
// Clear and set the new message
67+
liveRegion.textContent = '';
68+
// Force a small delay to ensure screen readers detect the change
69+
setTimeout(() => {
70+
liveRegion!.textContent = message;
71+
}, 50);
72+
}, delay);
73+
}, []);
74+
75+
/**
76+
* Announces when Claude starts responding
77+
*/
78+
const announceClaudeStarted = useCallback(() => {
79+
announceMessage('Claude says', { priority: 'polite' });
80+
}, [announceMessage]);
81+
82+
/**
83+
* Announces when Claude finishes responding
84+
*/
85+
const announceClaudeFinished = useCallback(() => {
86+
announceMessage('Finished', { priority: 'polite' });
87+
}, [announceMessage]);
88+
89+
/**
90+
* Announces new assistant message content
91+
*/
92+
const announceAssistantMessage = useCallback((content: string) => {
93+
// Clean up the content for screen reader announcement
94+
const cleanContent = cleanMessageForAnnouncement(content);
95+
if (cleanContent) {
96+
announceMessage(`Claude says: ${cleanContent}`, { priority: 'polite' });
97+
}
98+
}, [announceMessage]);
99+
100+
/**
101+
* Announces tool execution
102+
*/
103+
const announceToolExecution = useCallback((toolName: string, description?: string) => {
104+
const message = description
105+
? `Using ${toolName}: ${description}`
106+
: `Using ${toolName}`;
107+
announceMessage(message, { priority: 'polite' });
108+
}, [announceMessage]);
109+
110+
/**
111+
* Announces tool completion
112+
*/
113+
const announceToolCompleted = useCallback((toolName: string, success: boolean) => {
114+
const status = success ? 'done' : 'failed';
115+
announceMessage(`${toolName} ${status}`, { priority: 'polite' });
116+
}, [announceMessage]);
117+
118+
return {
119+
announceMessage,
120+
announceClaudeStarted,
121+
announceClaudeFinished,
122+
announceAssistantMessage,
123+
announceToolExecution,
124+
announceToolCompleted,
125+
};
126+
};
127+
128+
/**
129+
* Cleans message content for screen reader announcement
130+
* Removes markdown formatting and truncates if too long
131+
*/
132+
function cleanMessageForAnnouncement(content: string): string {
133+
if (!content) return '';
134+
135+
// Remove markdown formatting
136+
let cleaned = content
137+
.replace(/```[\s\S]*?```/g, '[code block]') // Replace code blocks
138+
.replace(/`([^`]+)`/g, '$1') // Remove inline code backticks
139+
.replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold formatting
140+
.replace(/\*([^*]+)\*/g, '$1') // Remove italic formatting
141+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Replace links with text
142+
.replace(/#{1,6}\s*/g, '') // Remove heading markers
143+
.replace(/\n+/g, ' ') // Replace newlines with spaces
144+
.trim();
145+
146+
// Truncate if too long (screen readers work better with shorter announcements)
147+
if (cleaned.length > 200) {
148+
cleaned = cleaned.substring(0, 197) + '...';
149+
}
150+
151+
return cleaned;
152+
}

0 commit comments

Comments
 (0)