From 8c1806ccb83936237782f68b31e41027fa230d7f Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:26:35 +0000 Subject: [PATCH 1/4] fix(mobile): screen readers hear when the open thread starts, needs you or finishes The web announces Working, Needs approval, Needs your input, Done, Failed and Stopped for the open thread, but the mobile app said nothing to VoiceOver or TalkBack. Move the pure announcement logic to client-runtime and speak the same status through AccessibilityInfo on mobile. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadRouteScreen.tsx | 17 +++ .../threads/threadStatusAnnouncement.test.ts | 115 ++++++++++++++++++ .../threads/threadStatusAnnouncement.ts | 31 +++++ .../threads/useThreadStatusAnnouncements.ts | 48 ++++++++ .../src/state/use-thread-composer-state.ts | 15 +++ apps/web/src/components/ChatView.tsx | 5 +- .../backgroundThreadAnnouncement.ts | 5 +- .../chat/ThreadActivityAnnouncer.tsx | 2 +- .../components/chat/usePromptSuggestion.ts | 2 +- packages/client-runtime/package.json | 4 + .../state}/threadActivityAnnouncement.test.ts | 2 +- .../src/state}/threadActivityAnnouncement.ts | 0 12 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/features/threads/threadStatusAnnouncement.test.ts create mode 100644 apps/mobile/src/features/threads/threadStatusAnnouncement.ts create mode 100644 apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts rename {apps/web/src/components/chat => packages/client-runtime/src/state}/threadActivityAnnouncement.test.ts (99%) rename {apps/web/src/components/chat => packages/client-runtime/src/state}/threadActivityAnnouncement.ts (100%) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 96c10b127b48e..1a772ee63f733 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -28,6 +28,7 @@ import { AuthTerminalReadScope, DEFAULT_SERVER_SETTINGS, EnvironmentId, + isProviderNativeSubagentThread, MessageId, ThreadId, type ProjectScript, @@ -122,6 +123,8 @@ import { type ThreadInspectorPresentation, } from "./thread-inspector-preview"; import { useStartSideChat } from "./useStartSideChat"; +import { threadActivitySnapshot } from "./threadStatusAnnouncement"; +import { useThreadStatusAnnouncements } from "./useThreadStatusAnnouncements"; import type { ThreadInspectorMode, ThreadInspectorTarget } from "./thread-inspector-navigation"; import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; @@ -377,6 +380,20 @@ function ThreadRouteContent( const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); + useThreadStatusAnnouncements({ + threadKey: + selectedThread === null || isProviderNativeSubagentThread(selectedThread.source) + ? null + : scopedThreadKey(selectedThread.environmentId, selectedThread.id), + ...threadActivitySnapshot({ + approvalRequestId: requests.activePendingApproval?.requestId ?? null, + userInputRequestId: requests.activePendingUserInput?.requestId ?? null, + shell: selectedThread, + working: composer.activeWorkStartedAt !== null || composer.isCompacting, + run: composer.selectedThreadAnnouncedRun, + }), + detailBehind: composer.selectedThreadDetailBehindShell, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, { label: "resume queue", diff --git a/apps/mobile/src/features/threads/threadStatusAnnouncement.test.ts b/apps/mobile/src/features/threads/threadStatusAnnouncement.test.ts new file mode 100644 index 0000000000000..3fbf7450fa44c --- /dev/null +++ b/apps/mobile/src/features/threads/threadStatusAnnouncement.test.ts @@ -0,0 +1,115 @@ +import { + nextThreadAnnouncement, + type ThreadAnnouncementMemory, +} from "@t3tools/client-runtime/state/thread-activity-announcement"; +import { RunId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { threadActivitySnapshot } from "./threadStatusAnnouncement"; + +const run = RunId.make("run-1"); + +function shellWith(pendingId: string | null) { + return { + hasPendingUserInput: pendingId !== null, + source: { pendingRuntimeRequest: pendingId === null ? null : { id: pendingId } }, + }; +} + +function announcements( + frames: ReadonlyArray[0]>, +): ReadonlyArray { + let memory: ThreadAnnouncementMemory | null = null; + return frames.map((frame) => { + const next = nextThreadAnnouncement(memory, threadActivitySnapshot(frame)); + memory = next.memory; + return next.announcement; + }); +} + +const working = { runId: run, status: "running" as const }; + +describe("threadActivitySnapshot", () => { + it("says each question once while the card and the shell catch up at different times", () => { + expect( + announcements([ + { + approvalRequestId: null, + userInputRequestId: null, + shell: shellWith(null), + working: false, + run: null, + }, + { + approvalRequestId: null, + userInputRequestId: null, + shell: shellWith(null), + working: true, + run: working, + }, + // The card shows question A before the shell has it. + { + approvalRequestId: null, + userInputRequestId: "a", + shell: shellWith(null), + working: true, + run: working, + }, + { + approvalRequestId: null, + userInputRequestId: "a", + shell: shellWith("a"), + working: true, + run: working, + }, + // Answered: the card hides A at once, the shell still holds it. + { + approvalRequestId: null, + userInputRequestId: null, + shell: shellWith("a"), + working: true, + run: working, + }, + // Question B reaches the card while the shell still holds A. + { + approvalRequestId: null, + userInputRequestId: "b", + shell: shellWith("a"), + working: true, + run: working, + }, + { + approvalRequestId: null, + userInputRequestId: "b", + shell: shellWith("b"), + working: true, + run: working, + }, + { + approvalRequestId: null, + userInputRequestId: null, + shell: shellWith(null), + working: true, + run: working, + }, + { + approvalRequestId: null, + userInputRequestId: null, + shell: shellWith(null), + working: false, + run: { runId: run, status: "completed" }, + }, + ]), + ).toEqual([ + null, + "Working", + "Needs your input", + null, + null, + "Needs your input", + null, + null, + "Done", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadStatusAnnouncement.ts b/apps/mobile/src/features/threads/threadStatusAnnouncement.ts new file mode 100644 index 0000000000000..8fe816de9ad24 --- /dev/null +++ b/apps/mobile/src/features/threads/threadStatusAnnouncement.ts @@ -0,0 +1,31 @@ +import type { ThreadActivitySnapshot } from "@t3tools/client-runtime/state/thread-activity-announcement"; +import type { RunId, OrchestrationV2RunStatus } from "@t3tools/contracts"; + +/** What the open thread's screen reader status follows, from the same sources the thread screen shows. */ +export function threadActivitySnapshot(input: { + readonly approvalRequestId: string | null; + readonly userInputRequestId: string | null; + readonly shell: { + readonly hasPendingUserInput: boolean; + readonly source: { readonly pendingRuntimeRequest: { readonly id: string } | null }; + } | null; + readonly working: boolean; + readonly run: { readonly runId: RunId; readonly status: OrchestrationV2RunStatus } | null; +}): ThreadActivitySnapshot { + const { approvalRequestId, userInputRequestId, shell } = input; + return { + status: + approvalRequestId !== null + ? "approval" + : userInputRequestId !== null || shell?.hasPendingUserInput === true + ? "input" + : input.working + ? "working" + : "idle", + // An answered question leaves the card before the shell, so the shell's id keeps it the same request. + requestId: + approvalRequestId ?? userInputRequestId ?? shell?.source.pendingRuntimeRequest?.id ?? null, + runId: input.run?.runId ?? null, + runStatus: input.run?.status ?? null, + }; +} diff --git a/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts b/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts new file mode 100644 index 0000000000000..93227a4b5137a --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts @@ -0,0 +1,48 @@ +import { useIsFocused } from "@react-navigation/native"; +import { + nextThreadAnnouncement, + type ThreadActivitySnapshot, + type ThreadAnnouncementMemory, +} from "@t3tools/client-runtime/state/thread-activity-announcement"; +import { useEffect, useRef } from "react"; +import { AccessibilityInfo } from "react-native"; + +import { useAppActive } from "../../lib/appState"; + +/** Tells VoiceOver and TalkBack when the open thread starts working, needs the user, or finishes. */ +export function useThreadStatusAnnouncements({ + threadKey, + status, + requestId, + runId, + runStatus, + detailBehind, +}: ThreadActivitySnapshot & { + /** Null when nothing should be announced, such as a provider subagent's thread. */ + readonly threadKey: string | null; + /** See isDetailBehindShell. */ + readonly detailBehind: boolean; +}) { + // A retained route stays mounted under the screen above it; it speaks only while shown, + // and starts from a silent baseline when it is shown again. + const focused = useIsFocused(); + const appActive = useAppActive(); + const activeKey = focused && appActive ? threadKey : null; + const memoryRef = useRef<{ threadKey: string; memory: ThreadAnnouncementMemory } | null>(null); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- speaks committed status changes through the platform screen reader + useEffect(() => { + if (activeKey === null) { + memoryRef.current = null; + return; + } + if (detailBehind) return; + const previous = memoryRef.current?.threadKey === activeKey ? memoryRef.current.memory : null; + const next = nextThreadAnnouncement(previous, { status, requestId, runId, runStatus }); + memoryRef.current = { threadKey: activeKey, memory: next.memory }; + // Queued, like the web's polite region, so VoiceOver doesn't cut off what it is reading. + if (next.announcement !== null) { + AccessibilityInfo.announceForAccessibilityWithOptions(next.announcement, { queue: true }); + } + }, [activeKey, status, requestId, runId, runStatus, detailBehind]); +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 8a762e88c97c6..8b88e489620ef 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -8,10 +8,15 @@ import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { threadSteersDuringCompaction } from "@t3tools/client-runtime/state/thread-workflows"; import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff"; +import { + announcedRun, + isDetailBehindShell, +} from "@t3tools/client-runtime/state/thread-activity-announcement"; import { deriveProviderSubagentStatus, deriveProviderExtensionStatuses, deriveResumableRunId, + deriveLatestThreadRun, deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, @@ -335,6 +340,14 @@ export function useThreadComposerState(input: { const selectedThreadActivityRun = selectedThreadProjection ? deriveThreadActivityRun(selectedThreadProjection.projection) : (selectedThreadShell?.latestRun ?? null); + const selectedThreadAnnouncedRun = announcedRun( + selectedThreadActivityRun, + selectedThreadProjection?.projection.runs ?? [], + ); + const selectedThreadDetailBehindShell = isDetailBehindShell( + selectedThreadProjection ? deriveLatestThreadRun(selectedThreadProjection.projection) : null, + selectedThreadShell?.latestRun ?? null, + ); const activeRunId = selectedThreadRuntime?.activeRunId && threadRuntimeIsActive(selectedThreadRuntime) @@ -881,6 +894,8 @@ export function useThreadComposerState(input: { dismissFeedback, selectedThreadFeed, selectedThreadActivityRun, + selectedThreadAnnouncedRun, + selectedThreadDetailBehindShell, selectedThreadQueueCount, selectedThreadQueuedMessages, dispatchingQueuedMessageId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 156c2f8f94327..d3f3f9125df5e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7,7 +7,10 @@ import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOver import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; import { useFocusComposerOnThreadOpen } from "./chat/useFocusComposerOnThreadOpen"; import { configuredHostedAppUrl } from "../hostedPairing"; -import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnouncement"; +import { + announcedRun, + isDetailBehindShell, +} from "@t3tools/client-runtime/state/thread-activity-announcement"; import { deriveAvailableRevertTurnCounts, deriveThreadPendingBackgroundTasks, diff --git a/apps/web/src/components/backgroundThreadAnnouncement.ts b/apps/web/src/components/backgroundThreadAnnouncement.ts index 10ca422d0f3cb..e7ad9b0c08103 100644 --- a/apps/web/src/components/backgroundThreadAnnouncement.ts +++ b/apps/web/src/components/backgroundThreadAnnouncement.ts @@ -1,7 +1,10 @@ import type { OrchestrationV2RunStatus, RunId } from "@t3tools/contracts"; import type { SidebarThreadSummary } from "../types"; -import { runOutcomeText, THREAD_STATUS_TEXT } from "./chat/threadActivityAnnouncement"; +import { + runOutcomeText, + THREAD_STATUS_TEXT, +} from "@t3tools/client-runtime/state/thread-activity-announcement"; export interface BackgroundThreadSnapshot { readonly key: string; diff --git a/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx b/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx index 0dc54f3f2df00..5d367cf851d75 100644 --- a/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx +++ b/apps/web/src/components/chat/ThreadActivityAnnouncer.tsx @@ -8,7 +8,7 @@ import { nextThreadAnnouncement, type ThreadActivitySnapshot, type ThreadAnnouncementMemory, -} from "./threadActivityAnnouncement"; +} from "@t3tools/client-runtime/state/thread-activity-announcement"; import { nextTurnDetailAnnouncement, NO_TURN_DETAIL, diff --git a/apps/web/src/components/chat/usePromptSuggestion.ts b/apps/web/src/components/chat/usePromptSuggestion.ts index b3f52b2a7dc78..595841629c023 100644 --- a/apps/web/src/components/chat/usePromptSuggestion.ts +++ b/apps/web/src/components/chat/usePromptSuggestion.ts @@ -6,7 +6,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useServerConfigs } from "../../state/entities"; import { orchestrationEnvironment } from "../../state/orchestration"; import { useEnvironmentQuery } from "../../state/query"; -import { announcedRun } from "./threadActivityAnnouncement"; +import { announcedRun } from "@t3tools/client-runtime/state/thread-activity-announcement"; // Dismissed suggestions, keyed by environment and run, shared by every mounted composer and kept // across remounts. Run ids derive from thread ids, which can repeat across environments. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 7c5b8ef1cb27b..d1e74151e53e3 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -335,6 +335,10 @@ "types": "./src/state/threads.ts", "default": "./src/state/threads.ts" }, + "./state/thread-activity-announcement": { + "types": "./src/state/threadActivityAnnouncement.ts", + "default": "./src/state/threadActivityAnnouncement.ts" + }, "./state/subagent-display": { "types": "./src/state/subagentDisplay.ts", "default": "./src/state/subagentDisplay.ts" diff --git a/apps/web/src/components/chat/threadActivityAnnouncement.test.ts b/packages/client-runtime/src/state/threadActivityAnnouncement.test.ts similarity index 99% rename from apps/web/src/components/chat/threadActivityAnnouncement.test.ts rename to packages/client-runtime/src/state/threadActivityAnnouncement.test.ts index b47895426306e..f40613b9910af 100644 --- a/apps/web/src/components/chat/threadActivityAnnouncement.test.ts +++ b/packages/client-runtime/src/state/threadActivityAnnouncement.test.ts @@ -13,7 +13,7 @@ import { nextThreadAnnouncement, type ThreadActivitySnapshot, type ThreadAnnouncementMemory, -} from "./threadActivityAnnouncement"; +} from "./threadActivityAnnouncement.ts"; const firstRun = RunId.make("run-1"); const secondRun = RunId.make("run-2"); diff --git a/apps/web/src/components/chat/threadActivityAnnouncement.ts b/packages/client-runtime/src/state/threadActivityAnnouncement.ts similarity index 100% rename from apps/web/src/components/chat/threadActivityAnnouncement.ts rename to packages/client-runtime/src/state/threadActivityAnnouncement.ts From 4bd3d26aca401edaf61ea0342a7c6a7e30f05aea Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:13:35 +0000 Subject: [PATCH 2/4] fix(mobile): an answered question stays answered while the shell lags two requests behind A shell request the card already showed is one the user answered, so it keeps the request last announced instead of reading as a new question. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadRouteScreen.tsx | 14 +-- .../threads/threadStatusAnnouncement.test.ts | 119 +++++++----------- .../threads/threadStatusAnnouncement.ts | 64 ++++++---- .../threads/useThreadStatusAnnouncements.ts | 52 ++++++-- 4 files changed, 133 insertions(+), 116 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 1a772ee63f733..a668b24ccea1f 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -123,7 +123,6 @@ import { type ThreadInspectorPresentation, } from "./thread-inspector-preview"; import { useStartSideChat } from "./useStartSideChat"; -import { threadActivitySnapshot } from "./threadStatusAnnouncement"; import { useThreadStatusAnnouncements } from "./useThreadStatusAnnouncements"; import type { ThreadInspectorMode, ThreadInspectorTarget } from "./thread-inspector-navigation"; import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; @@ -385,13 +384,12 @@ function ThreadRouteContent( selectedThread === null || isProviderNativeSubagentThread(selectedThread.source) ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id), - ...threadActivitySnapshot({ - approvalRequestId: requests.activePendingApproval?.requestId ?? null, - userInputRequestId: requests.activePendingUserInput?.requestId ?? null, - shell: selectedThread, - working: composer.activeWorkStartedAt !== null || composer.isCompacting, - run: composer.selectedThreadAnnouncedRun, - }), + approvalRequestId: requests.activePendingApproval?.requestId ?? null, + userInputRequestId: requests.activePendingUserInput?.requestId ?? null, + shellRequestId: selectedThread?.source.pendingRuntimeRequest?.id ?? null, + shellPendingUserInput: selectedThread?.hasPendingUserInput === true, + working: composer.activeWorkStartedAt !== null || composer.isCompacting, + run: composer.selectedThreadAnnouncedRun, detailBehind: composer.selectedThreadDetailBehindShell, }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); diff --git a/apps/mobile/src/features/threads/threadStatusAnnouncement.test.ts b/apps/mobile/src/features/threads/threadStatusAnnouncement.test.ts index 3fbf7450fa44c..fa8814602cec7 100644 --- a/apps/mobile/src/features/threads/threadStatusAnnouncement.test.ts +++ b/apps/mobile/src/features/threads/threadStatusAnnouncement.test.ts @@ -5,100 +5,64 @@ import { import { RunId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { threadActivitySnapshot } from "./threadStatusAnnouncement"; +import { + nextThreadActivitySnapshot, + type ThreadActivityInput, + type ThreadActivityMemory, +} from "./threadStatusAnnouncement"; const run = RunId.make("run-1"); -function shellWith(pendingId: string | null) { +/** A frame of a working turn: what the question card shows and what the shell holds. */ +function frame(card: string | null, shell: string | null): ThreadActivityInput { return { - hasPendingUserInput: pendingId !== null, - source: { pendingRuntimeRequest: pendingId === null ? null : { id: pendingId } }, + approvalRequestId: null, + userInputRequestId: card, + shellRequestId: shell, + shellPendingUserInput: shell !== null, + working: true, + run: { runId: run, status: "running" }, }; } -function announcements( - frames: ReadonlyArray[0]>, -): ReadonlyArray { +const idle: ThreadActivityInput = { ...frame(null, null), working: false, run: null }; +const done: ThreadActivityInput = { + ...frame(null, null), + working: false, + run: { runId: run, status: "completed" }, +}; + +function announcements(frames: ReadonlyArray): ReadonlyArray { + let activity: ThreadActivityMemory | null = null; let memory: ThreadAnnouncementMemory | null = null; - return frames.map((frame) => { - const next = nextThreadAnnouncement(memory, threadActivitySnapshot(frame)); + return frames.map((input) => { + activity = nextThreadActivitySnapshot(activity, input); + const next = nextThreadAnnouncement(memory, activity.snapshot); memory = next.memory; return next.announcement; }); } -const working = { runId: run, status: "running" as const }; - -describe("threadActivitySnapshot", () => { +describe("nextThreadActivitySnapshot", () => { it("says each question once while the card and the shell catch up at different times", () => { expect( announcements([ - { - approvalRequestId: null, - userInputRequestId: null, - shell: shellWith(null), - working: false, - run: null, - }, - { - approvalRequestId: null, - userInputRequestId: null, - shell: shellWith(null), - working: true, - run: working, - }, + idle, + frame(null, null), // The card shows question A before the shell has it. - { - approvalRequestId: null, - userInputRequestId: "a", - shell: shellWith(null), - working: true, - run: working, - }, - { - approvalRequestId: null, - userInputRequestId: "a", - shell: shellWith("a"), - working: true, - run: working, - }, + frame("a", null), + frame("a", "a"), // Answered: the card hides A at once, the shell still holds it. - { - approvalRequestId: null, - userInputRequestId: null, - shell: shellWith("a"), - working: true, - run: working, - }, - // Question B reaches the card while the shell still holds A. - { - approvalRequestId: null, - userInputRequestId: "b", - shell: shellWith("a"), - working: true, - run: working, - }, - { - approvalRequestId: null, - userInputRequestId: "b", - shell: shellWith("b"), - working: true, - run: working, - }, - { - approvalRequestId: null, - userInputRequestId: null, - shell: shellWith(null), - working: true, - run: working, - }, - { - approvalRequestId: null, - userInputRequestId: null, - shell: shellWith(null), - working: false, - run: { runId: run, status: "completed" }, - }, + frame(null, "a"), + // Question B reaches the card while the shell still holds A, and is answered before the shell has B. + frame("b", "a"), + frame(null, "a"), + frame(null, "b"), + frame(null, null), + // A secret request shows only in the shell. + frame(null, "secret"), + frame(null, null), + done, ]), ).toEqual([ null, @@ -109,6 +73,9 @@ describe("threadActivitySnapshot", () => { "Needs your input", null, null, + null, + "Needs your input", + null, "Done", ]); }); diff --git a/apps/mobile/src/features/threads/threadStatusAnnouncement.ts b/apps/mobile/src/features/threads/threadStatusAnnouncement.ts index 8fe816de9ad24..ddaabb08d6c05 100644 --- a/apps/mobile/src/features/threads/threadStatusAnnouncement.ts +++ b/apps/mobile/src/features/threads/threadStatusAnnouncement.ts @@ -1,31 +1,53 @@ import type { ThreadActivitySnapshot } from "@t3tools/client-runtime/state/thread-activity-announcement"; import type { RunId, OrchestrationV2RunStatus } from "@t3tools/contracts"; -/** What the open thread's screen reader status follows, from the same sources the thread screen shows. */ -export function threadActivitySnapshot(input: { +export interface ThreadActivityInput { readonly approvalRequestId: string | null; readonly userInputRequestId: string | null; - readonly shell: { - readonly hasPendingUserInput: boolean; - readonly source: { readonly pendingRuntimeRequest: { readonly id: string } | null }; - } | null; + /** The thread shell's pending request, which secret and link cards have only there. */ + readonly shellRequestId: string | null; + readonly shellPendingUserInput: boolean; readonly working: boolean; readonly run: { readonly runId: RunId; readonly status: OrchestrationV2RunStatus } | null; -}): ThreadActivitySnapshot { - const { approvalRequestId, userInputRequestId, shell } = input; +} + +export interface ThreadActivityMemory { + readonly snapshot: ThreadActivitySnapshot; + /** Requests the thread screen has shown as a card. */ + readonly cardRequestIds: ReadonlySet; +} + +/** What the open thread's screen reader status follows, from the same sources the thread screen shows. */ +export function nextThreadActivitySnapshot( + previous: ThreadActivityMemory | null, + input: ThreadActivityInput, +): ThreadActivityMemory { + const { approvalRequestId, userInputRequestId, shellRequestId } = input; + const cardRequestId = approvalRequestId ?? userInputRequestId; + const seen = previous?.cardRequestIds ?? new Set(); + // A card leaves as soon as it is answered, before the shell; a shell request the card + // already showed is that answered one, so the request stays the one last announced. + // Secret and link requests never show as a card and come from the shell alone. + const requestId = + cardRequestId ?? + (shellRequestId !== null && seen.has(shellRequestId) + ? (previous?.snapshot.requestId ?? null) + : shellRequestId); return { - status: - approvalRequestId !== null - ? "approval" - : userInputRequestId !== null || shell?.hasPendingUserInput === true - ? "input" - : input.working - ? "working" - : "idle", - // An answered question leaves the card before the shell, so the shell's id keeps it the same request. - requestId: - approvalRequestId ?? userInputRequestId ?? shell?.source.pendingRuntimeRequest?.id ?? null, - runId: input.run?.runId ?? null, - runStatus: input.run?.status ?? null, + snapshot: { + status: + approvalRequestId !== null + ? "approval" + : userInputRequestId !== null || input.shellPendingUserInput + ? "input" + : input.working + ? "working" + : "idle", + requestId, + runId: input.run?.runId ?? null, + runStatus: input.run?.status ?? null, + }, + cardRequestIds: + cardRequestId === null || seen.has(cardRequestId) ? seen : new Set([...seen, cardRequestId]), }; } diff --git a/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts b/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts index 93227a4b5137a..49662281ad731 100644 --- a/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts +++ b/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts @@ -1,23 +1,29 @@ import { useIsFocused } from "@react-navigation/native"; import { nextThreadAnnouncement, - type ThreadActivitySnapshot, type ThreadAnnouncementMemory, } from "@t3tools/client-runtime/state/thread-activity-announcement"; import { useEffect, useRef } from "react"; import { AccessibilityInfo } from "react-native"; import { useAppActive } from "../../lib/appState"; +import { + nextThreadActivitySnapshot, + type ThreadActivityInput, + type ThreadActivityMemory, +} from "./threadStatusAnnouncement"; /** Tells VoiceOver and TalkBack when the open thread starts working, needs the user, or finishes. */ export function useThreadStatusAnnouncements({ threadKey, - status, - requestId, - runId, - runStatus, detailBehind, -}: ThreadActivitySnapshot & { + approvalRequestId, + userInputRequestId, + shellRequestId, + shellPendingUserInput, + working, + run, +}: ThreadActivityInput & { /** Null when nothing should be announced, such as a provider subagent's thread. */ readonly threadKey: string | null; /** See isDetailBehindShell. */ @@ -28,7 +34,13 @@ export function useThreadStatusAnnouncements({ const focused = useIsFocused(); const appActive = useAppActive(); const activeKey = focused && appActive ? threadKey : null; - const memoryRef = useRef<{ threadKey: string; memory: ThreadAnnouncementMemory } | null>(null); + const memoryRef = useRef<{ + threadKey: string; + activity: ThreadActivityMemory; + announcement: ThreadAnnouncementMemory; + } | null>(null); + const runId = run?.runId ?? null; + const runStatus = run?.status ?? null; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- speaks committed status changes through the platform screen reader useEffect(() => { @@ -37,12 +49,30 @@ export function useThreadStatusAnnouncements({ return; } if (detailBehind) return; - const previous = memoryRef.current?.threadKey === activeKey ? memoryRef.current.memory : null; - const next = nextThreadAnnouncement(previous, { status, requestId, runId, runStatus }); - memoryRef.current = { threadKey: activeKey, memory: next.memory }; + const previous = memoryRef.current?.threadKey === activeKey ? memoryRef.current : null; + const activity = nextThreadActivitySnapshot(previous?.activity ?? null, { + approvalRequestId, + userInputRequestId, + shellRequestId, + shellPendingUserInput, + working, + run: runId === null || runStatus === null ? null : { runId, status: runStatus }, + }); + const next = nextThreadAnnouncement(previous?.announcement ?? null, activity.snapshot); + memoryRef.current = { threadKey: activeKey, activity, announcement: next.memory }; // Queued, like the web's polite region, so VoiceOver doesn't cut off what it is reading. if (next.announcement !== null) { AccessibilityInfo.announceForAccessibilityWithOptions(next.announcement, { queue: true }); } - }, [activeKey, status, requestId, runId, runStatus, detailBehind]); + }, [ + activeKey, + detailBehind, + approvalRequestId, + userInputRequestId, + shellRequestId, + shellPendingUserInput, + working, + runId, + runStatus, + ]); } From 9b4d3b757233765b83b03912371fc390a70b2023 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:37:21 +0000 Subject: [PATCH 3/4] fix(mobile): a thread preview beside its host doesn't announce over it The inspector's side preview mounts a second thread route that also counts as focused, so both spoke unlabeled statuses. The host speaks for its own thread; the fullscreen inspector still announces. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index a668b24ccea1f..981e7d933460d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -380,8 +380,11 @@ function ThreadRouteContent( const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); useThreadStatusAnnouncements({ + // A preview beside its host stays quiet; the host speaks for the thread it shows. threadKey: - selectedThread === null || isProviderNativeSubagentThread(selectedThread.source) + selectedThread === null || + isProviderNativeSubagentThread(selectedThread.source) || + (props.inspectorPresentation !== undefined && "onMaximize" in props.inspectorPresentation) ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id), approvalRequestId: requests.activePendingApproval?.requestId ?? null, From 015f3c016ec2f046fde28a0baaa5df18a93ff16e Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:50:50 +0000 Subject: [PATCH 4/4] fix(mobile): a thread preview beside its host names its thread when it announces Silencing the side preview hid approvals, questions and endings of a side chat or child thread worked there. It speaks again, followed by the thread's title, the way the web announces threads other than the open one. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 10 ++++++---- .../features/threads/useThreadStatusAnnouncements.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 981e7d933460d..6205fd1281797 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -380,13 +380,15 @@ function ThreadRouteContent( const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); useThreadStatusAnnouncements({ - // A preview beside its host stays quiet; the host speaks for the thread it shows. threadKey: - selectedThread === null || - isProviderNativeSubagentThread(selectedThread.source) || - (props.inspectorPresentation !== undefined && "onMaximize" in props.inspectorPresentation) + selectedThread === null || isProviderNativeSubagentThread(selectedThread.source) ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id), + // A preview beside its host names its thread, so the two threads' statuses can't be confused. + subject: + props.inspectorPresentation !== undefined && "onMaximize" in props.inspectorPresentation + ? (selectedThread?.title ?? null) + : null, approvalRequestId: requests.activePendingApproval?.requestId ?? null, userInputRequestId: requests.activePendingUserInput?.requestId ?? null, shellRequestId: selectedThread?.source.pendingRuntimeRequest?.id ?? null, diff --git a/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts b/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts index 49662281ad731..c3e14809b39f9 100644 --- a/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts +++ b/apps/mobile/src/features/threads/useThreadStatusAnnouncements.ts @@ -16,6 +16,7 @@ import { /** Tells VoiceOver and TalkBack when the open thread starts working, needs the user, or finishes. */ export function useThreadStatusAnnouncements({ threadKey, + subject, detailBehind, approvalRequestId, userInputRequestId, @@ -26,6 +27,8 @@ export function useThreadStatusAnnouncements({ }: ThreadActivityInput & { /** Null when nothing should be announced, such as a provider subagent's thread. */ readonly threadKey: string | null; + /** The thread's title, said after each status when another thread is on screen beside it. */ + readonly subject: string | null; /** See isDetailBehindShell. */ readonly detailBehind: boolean; }) { @@ -62,10 +65,14 @@ export function useThreadStatusAnnouncements({ memoryRef.current = { threadKey: activeKey, activity, announcement: next.memory }; // Queued, like the web's polite region, so VoiceOver doesn't cut off what it is reading. if (next.announcement !== null) { - AccessibilityInfo.announceForAccessibilityWithOptions(next.announcement, { queue: true }); + AccessibilityInfo.announceForAccessibilityWithOptions( + subject === null ? next.announcement : `${next.announcement}: ${subject}`, + { queue: true }, + ); } }, [ activeKey, + subject, detailBehind, approvalRequestId, userInputRequestId,