Skip to content

Commit cc5c9c8

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 a8840bb commit cc5c9c8

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 {
@@ -139,6 +139,15 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
139139
// const aiTracking = useAIInteractionTracking('sonnet'); // Default model
140140
const workflowTracking = useWorkflowTracking('claude_session');
141141

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

466+
// Announce that Claude is starting to process
467+
announceClaudeStarted();
468+
457469
// For resuming sessions, ensure we have the session ID
458470
if (effectiveSession && !claudeSessionId) {
459471
setClaudeSessionId(effectiveSession.id);
@@ -544,6 +556,60 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
544556
}
545557
});
546558

559+
// Helper to find tool name by tool use ID from previous messages
560+
function findToolNameById(toolUseId: string): string | null {
561+
// Search backwards through messages for the tool use
562+
for (let i = messages.length - 1; i >= 0; i--) {
563+
const msg = messages[i];
564+
if (msg.type === 'assistant' && msg.message?.content) {
565+
const toolUse = msg.message.content.find((c: any) =>
566+
c.type === 'tool_use' && c.id === toolUseId
567+
);
568+
if (toolUse?.name) {
569+
return toolUse.name;
570+
}
571+
}
572+
}
573+
return null;
574+
}
575+
576+
// Helper to announce incoming messages to screen readers
577+
function announceIncomingMessage(message: ClaudeStreamMessage) {
578+
if (message.type === 'assistant' && message.message?.content) {
579+
// Announce tool execution
580+
const toolUses = message.message.content.filter((c: any) => c.type === 'tool_use');
581+
toolUses.forEach((toolUse: any) => {
582+
const toolName = toolUse.name || 'unknown tool';
583+
const description = toolUse.input?.description ||
584+
toolUse.input?.command ||
585+
toolUse.input?.file_path ||
586+
toolUse.input?.pattern ||
587+
toolUse.input?.prompt?.substring(0, 50);
588+
announceToolExecution(toolName, description);
589+
});
590+
591+
// Announce text content
592+
const textContent = message.message.content
593+
.filter((c: any) => c.type === 'text')
594+
.map((c: any) => typeof c.text === 'string' ? c.text : (c.text?.text || ''))
595+
.join(' ')
596+
.trim();
597+
598+
if (textContent) {
599+
announceAssistantMessage(textContent);
600+
}
601+
} else if (message.type === 'system') {
602+
// Announce system messages if they have meaningful content
603+
if (message.subtype === 'init') {
604+
// Don't announce init messages as they're just setup
605+
return;
606+
} else if (message.result || message.error) {
607+
const content = message.result || message.error || 'System message received';
608+
announceAssistantMessage(content);
609+
}
610+
}
611+
}
612+
547613
// Helper to process any JSONL stream message string
548614
function handleStreamMessage(payload: string) {
549615
try {
@@ -555,6 +621,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
555621

556622
const message = JSON.parse(payload) as ClaudeStreamMessage;
557623

624+
// Announce incoming messages to screen readers
625+
announceIncomingMessage(message);
626+
558627
// Track enhanced tool execution
559628
if (message.type === 'assistant' && message.message?.content) {
560629
const toolUses = message.message.content.filter((c: any) => c.type === 'tool_use');
@@ -583,6 +652,14 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
583652
const toolResults = message.message.content.filter((c: any) => c.type === 'tool_result');
584653
toolResults.forEach((result: any) => {
585654
const isError = result.is_error || false;
655+
656+
// Announce tool completion
657+
if (result.tool_use_id) {
658+
// Try to find the tool name from previous messages
659+
const toolName = findToolNameById(result.tool_use_id) || 'Tool';
660+
// announceToolCompleted(toolName, !isError); // Disabled to prevent interrupting other announcements
661+
}
662+
586663
// Note: We don't have execution time here, but we can track success/failure
587664
if (isError) {
588665
sessionMetrics.current.toolsFailed += 1;
@@ -634,6 +711,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
634711
hasActiveSessionRef.current = false;
635712
isListeningRef.current = false; // Reset listening state
636713

714+
// Announce that Claude has finished
715+
announceClaudeFinished();
716+
637717
// Track enhanced session stopped metrics when session completes
638718
if (effectiveSession && claudeSessionId) {
639719
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)