From 1062b72fc56c40dca2dc5e199aeb580ad5d51936 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 11 Nov 2026 18:32:35 -0500 Subject: [PATCH 1/3] fix(mobile): screen readers hear a thread row's status and time Each thread row's accessibility label held only the title, provider, queued and regenerating notes, so VoiceOver and TalkBack users never heard the visible status (Approval, Input, Request, Failed, Limited, Done, Woke) or the time, and could not tell which threads were waiting on them. The label now comes from a pure helper that adds the same text the row shows beside its title: Woke, else the status label, else the time on cards, and the snooze wake label or time on slim rows. Both row variants use it. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/thread-list-v2-items.tsx | 13 ++++++- .../src/features/threads/threadListV2.test.ts | 36 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 31 ++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index d724d8056e95e..f593d7e2cc7d9 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -78,6 +78,7 @@ import { resolveThreadListV2ProviderDrivers, resolveThreadListV2SwipeActions, layoutThreadListV2RowMenuActions, + threadListV2RowAccessibilityLabel, type ThreadListV2Status, } from "./threadListV2"; import { QueuedMessageIcon } from "./queued-message-icon"; @@ -776,8 +777,18 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); const recede = shouldRecedeThreadRow({ status, selected }); const isRegeneratingTitle = thread.titleRegeneration != null; - const rowAccessibilityLabel = `${thread.title}${providerNameLabel ? `, ${providerNameLabel}` : ""}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`; const timeLabel = props.timeLabel; + const rowAccessibilityLabel = threadListV2RowAccessibilityLabel({ + title: thread.title, + variant, + woke: showsWoke, + statusLabel, + timeLabel, + snoozeWakeLabel: snoozedRow ? props.snoozeWakeLabelText : undefined, + providerNameLabel, + hasQueuedMessages: props.hasQueuedMessages === true, + regeneratingTitle: isRegeneratingTitle, + }); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const handleAcknowledgeWoke = useCallback(() => { diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c848e063e2bed..2b21cd61ffbe3 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -43,6 +43,7 @@ import { resolveThreadListV2Status, resolveThreadListV2WokeAt, threadListV2ListItemsAreEqual, + threadListV2RowAccessibilityLabel, threadHasUnseenCompletion, type ThreadListV2ListItem, } from "./threadListV2"; @@ -210,6 +211,41 @@ describe("resolveThreadListV2Status", () => { ); }); +describe("threadListV2RowAccessibilityLabel", () => { + const row = { + title: "Fix login", + variant: "card", + woke: false, + statusLabel: undefined, + timeLabel: "5m", + snoozeWakeLabel: undefined, + providerNameLabel: "Codex", + hasQueuedMessages: false, + regeneratingTitle: false, + } as const; + + it("speaks the status or time the row shows beside its title", () => { + expect(threadListV2RowAccessibilityLabel({ ...row, statusLabel: { label: "Approval" } })).toBe( + "Fix login, Approval, Codex", + ); + expect(threadListV2RowAccessibilityLabel(row)).toBe("Fix login, 5m, Codex"); + expect( + threadListV2RowAccessibilityLabel({ ...row, woke: true, statusLabel: { label: "Done" } }), + ).toBe("Fix login, Woke, Codex"); + expect(threadListV2RowAccessibilityLabel({ ...row, statusLabel: { label: null } })).toBe( + "Fix login, Codex", + ); + expect( + threadListV2RowAccessibilityLabel({ + ...row, + variant: "slim", + statusLabel: { label: "Failed" }, + snoozeWakeLabel: "in 2h", + }), + ).toBe("Fix login, in 2h, Codex"); + }); +}); + describe("resolveThreadListV2SwipeActions", () => { it("offers snooze for a presented input request and moves it onto the snoozed shelf", () => { const raw = makeRawThreadShell({ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 7854f9d7fdb85..6cf6618448306 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -296,6 +296,37 @@ export function resolveThreadListV2Status( return "ready"; } +export function threadListV2RowAccessibilityLabel(input: { + readonly title: string; + readonly variant: "card" | "slim"; + readonly woke: boolean; + readonly statusLabel: { readonly label: string | null } | undefined; + readonly timeLabel: string; + readonly snoozeWakeLabel: string | undefined; + readonly providerNameLabel: string | null; + readonly hasQueuedMessages: boolean; + readonly regeneratingTitle: boolean; +}): string { + // Mirrors the row's meta line: a card shows Woke, else its status, else the time. + const meta = + input.variant === "slim" + ? (input.snoozeWakeLabel ?? input.timeLabel) + : input.woke + ? "Woke" + : input.statusLabel + ? input.statusLabel.label + : input.timeLabel; + return [ + input.title, + meta, + input.providerNameLabel, + input.hasQueuedMessages ? "messages queued to send" : null, + input.regeneratingTitle ? "regenerating title" : null, + ] + .filter((part) => part !== null && part !== "") + .join(", "); +} + function parseTimestampMs(isoDate: string): number { const parsed = Date.parse(isoDate); return Number.isNaN(parsed) ? 0 : parsed; From e72bbf5c02684cc8d14e7429aca805f3b96e8b80 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 18:29:10 +0000 Subject: [PATCH 2/3] fix(mobile): name working and waiting thread rows for screen readers Their status shows only as an icon and a duration, so the row's label dropped the time and spoke no status at all. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/thread-list-v2-items.tsx | 4 +++- apps/mobile/src/features/threads/threadListV2.test.ts | 11 ++++------- apps/mobile/src/features/threads/threadListV2.ts | 8 +++----- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index f4fbc6f6abfdc..5a453454ba3e9 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -790,7 +790,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { title: thread.title, variant, woke: showsWoke, - statusLabel, + // Working and waiting rows show only an icon and a duration, so name them for speech. + statusLabel: + statusLabel && (statusLabel.label ?? (status === "waiting" ? "Waiting" : "Working")), timeLabel, snoozeWakeLabel: snoozedRow ? props.snoozeWakeLabelText : undefined, providerNameLabel, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 41510e2ef2a38..7324f039cd11e 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -242,21 +242,18 @@ describe("threadListV2RowAccessibilityLabel", () => { } as const; it("speaks the status or time the row shows beside its title", () => { - expect(threadListV2RowAccessibilityLabel({ ...row, statusLabel: { label: "Approval" } })).toBe( + expect(threadListV2RowAccessibilityLabel({ ...row, statusLabel: "Approval" })).toBe( "Fix login, Approval, Codex", ); expect(threadListV2RowAccessibilityLabel(row)).toBe("Fix login, 5m, Codex"); - expect( - threadListV2RowAccessibilityLabel({ ...row, woke: true, statusLabel: { label: "Done" } }), - ).toBe("Fix login, Woke, Codex"); - expect(threadListV2RowAccessibilityLabel({ ...row, statusLabel: { label: null } })).toBe( - "Fix login, Codex", + expect(threadListV2RowAccessibilityLabel({ ...row, woke: true, statusLabel: "Done" })).toBe( + "Fix login, Woke, Codex", ); expect( threadListV2RowAccessibilityLabel({ ...row, variant: "slim", - statusLabel: { label: "Failed" }, + statusLabel: "Failed", snoozeWakeLabel: "in 2h", }), ).toBe("Fix login, in 2h, Codex"); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index c130ca8e14f88..61652bd0c3599 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -303,7 +303,7 @@ export function threadListV2RowAccessibilityLabel(input: { readonly title: string; readonly variant: "card" | "slim"; readonly woke: boolean; - readonly statusLabel: { readonly label: string | null } | undefined; + readonly statusLabel: string | undefined; readonly timeLabel: string; readonly snoozeWakeLabel: string | undefined; readonly providerNameLabel: string | null; @@ -316,9 +316,7 @@ export function threadListV2RowAccessibilityLabel(input: { ? (input.snoozeWakeLabel ?? input.timeLabel) : input.woke ? "Woke" - : input.statusLabel - ? input.statusLabel.label - : input.timeLabel; + : (input.statusLabel ?? input.timeLabel); return [ input.title, meta, @@ -326,7 +324,7 @@ export function threadListV2RowAccessibilityLabel(input: { input.hasQueuedMessages ? "messages queued to send" : null, input.regeneratingTitle ? "regenerating title" : null, ] - .filter((part) => part !== null && part !== "") + .filter((part) => part !== null) .join(", "); } From 6805fd22241c82b4badf4f6ceb499d3249223777 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:36:49 +0000 Subject: [PATCH 3/3] fix(mobile): VoiceOver hears the thread row label on its native menu On iOS the row's native menu is the element VoiceOver focuses, so the status and time label on the inner pressable never reached it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/thread-list-v2-items.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 5a453454ba3e9..e0619fba25018 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1594,7 +1594,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { threadTitle={thread.title} > {(close) => ( - + {rowContent(close)} )}