From e0fccbd6a9605a3dcdcf29ffb028bb5caa192cd6 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 13:32:56 -0400 Subject: [PATCH 1/8] fix(voice): retain active transcript routes --- .../useVoiceConversationController.test.ts | 69 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 7 +- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index f0735ae53..7cab4133d 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -17,6 +17,11 @@ const voiceApiMocks = vi.hoisted(() => ({ const microphonePermissionMocks = vi.hoisted(() => ({ getStatus: vi.fn<() => Promise<"authorized" | "denied">>(), })); +const voiceStoreMocks = vi.hoisted(() => ({ + subscriber: undefined as + | ((event: Record) => void | Promise) + | undefined, +})); vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ label: tauriWindowMocks.label }), @@ -39,6 +44,18 @@ vi.mock("../api/microphonePermission", () => ({ getMicrophonePermissionStatus: microphonePermissionMocks.getStatus, })); +vi.mock("../stores/voiceConversationStore", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../stores/voiceConversationStore") + >()), + subscribeToVoiceConversationEvents: ( + subscriber: (event: Record) => void | Promise, + ) => { + voiceStoreMocks.subscriber = subscriber; + return () => undefined; + }, +})); + import { canBindVoiceSendRoute, canReplaceActiveVoiceConversation, @@ -217,6 +234,58 @@ describe("voice transcript delivery coordination", () => { microphonePermissionMocks.getStatus.mockResolvedValue("authorized"); useChatStore.setState({ messagesBySession: {}, sessionStateById: {} }); }); + + it("delivers a queued transcript after its chat becomes temporarily ineligible", async () => { + const onSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); + const { rerender } = renderHook( + ({ disabled }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + disabled, + }), + { initialProps: { disabled: false } }, + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + rerender({ disabled: true }); + await act(async () => { + await voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-1", + text: "keep this route", + revision: 1, + deliveryAttempts: 0, + }); + }); + + expect(onSend).toHaveBeenCalledWith( + "keep this route", + undefined, + undefined, + expect.objectContaining({ displayText: "keep this route" }), + ); + }); it("serializes deliveries for the same session and re-evaluates in order", async () => { const enqueue = createVoiceTranscriptDeliveryQueue(); const events: string[] = []; diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 658223c6f..6c29aa342 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -33,9 +33,8 @@ interface VoiceSendRoute { send: ChatInputSendHandler; } -// The backend conversation is process-wide, but voice input is intentionally -// foreground-chat scoped. The route remains available only while at least one -// view for its bound session is mounted. +// The backend conversation is process-wide, but voice input remains bound to +// the chat that started the active lifecycle until that lifecycle terminates. let activeSendRoute: VoiceSendRoute | null = null; let deliveryInitialized = false; const operationInFlightBySession = new Set(); @@ -677,8 +676,6 @@ export function useVoiceConversationController({ }); if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; - } else if (!routeIsValid && activeSendRoute?.sessionId === sessionId) { - activeSendRoute = null; } if (routeMount.drainPending) { const routeSessionId = activeSendRoute?.sessionId; From 3179d8414e4cb50b81db62dc50b97613f5856be6 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 13:46:06 -0400 Subject: [PATCH 2/8] fix(voice): release invalid transcript routes --- .../useVoiceConversationController.test.ts | 46 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 5 ++ 2 files changed, 51 insertions(+) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 7cab4133d..54dd57d60 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -286,6 +286,52 @@ describe("voice transcript delivery coordination", () => { expect.objectContaining({ displayText: "keep this route" }), ); }); + + it("releases a retained transcript route when the chat becomes read-only", async () => { + const onSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); + const { rerender } = renderHook( + ({ readOnly }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + readOnly, + }), + { initialProps: { readOnly: false } }, + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + rerender({ readOnly: true }); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-read-only", + text: "do not deliver", + revision: 1, + deliveryAttempts: 0, + }), + ).rejects.toThrow("bound chat is unavailable"); + expect(onSend).not.toHaveBeenCalled(); + }); it("serializes deliveries for the same session and re-evaluates in order", async () => { const enqueue = createVoiceTranscriptDeliveryQueue(); const events: string[] = []; diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 6c29aa342..f41cb49f1 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -676,6 +676,11 @@ export function useVoiceConversationController({ }); if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; + } else if ( + (!enabled || !isGooseSession || readOnly) && + activeSendRoute?.sessionId === sessionId + ) { + activeSendRoute = null; } if (routeMount.drainPending) { const routeSessionId = activeSendRoute?.sessionId; From 5da617e0d9d79a1f36c258ed5bdda6ccb4ce0ca8 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 14:03:43 -0400 Subject: [PATCH 3/8] fix(voice): release routes on admission blocks --- src/features/chat/ui/ChatView.tsx | 1 + .../useVoiceConversationController.test.ts | 47 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 21 ++++++--- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 4ac10a0bb..a7d643ea2 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -264,6 +264,7 @@ export function ChatView({ }); }, readOnly: Boolean(readOnlyStatus), + routeBlocked: admissionBlocked, disabled: admissionBlocked || controller.projectMetadataPending || diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 54dd57d60..02188f2b0 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -332,6 +332,53 @@ describe("voice transcript delivery coordination", () => { ).rejects.toThrow("bound chat is unavailable"); expect(onSend).not.toHaveBeenCalled(); }); + + it("releases a retained transcript route when admission is blocked", async () => { + const onSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); + const { rerender } = renderHook( + ({ routeBlocked }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked, + }), + { initialProps: { routeBlocked: false } }, + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + rerender({ routeBlocked: true }); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-admission-blocked", + text: "do not deliver", + revision: 1, + deliveryAttempts: 0, + }), + ).rejects.toThrow("bound chat is unavailable"); + expect(onSend).not.toHaveBeenCalled(); + }); + it("serializes deliveries for the same session and re-evaluates in order", async () => { const enqueue = createVoiceTranscriptDeliveryQueue(); const events: string[] = []; diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index f41cb49f1..9a3eefab8 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -593,6 +593,7 @@ export interface UseVoiceConversationControllerOptions { onPocketSetupRequired: () => void; readOnly?: boolean; disabled?: boolean; + routeBlocked?: boolean; } export function useVoiceConversationController({ @@ -606,6 +607,7 @@ export function useVoiceConversationController({ onPocketSetupRequired, readOnly = false, disabled = false, + routeBlocked = false, }: UseVoiceConversationControllerOptions): ChatInputVoiceConversation { const { t } = useTranslation("chat"); const siriVoiceRef = useRef(siriVoice); @@ -661,12 +663,14 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); - const routeIsValid = canBindVoiceSendRoute({ - enabled, - isGooseSession, - readOnly, - disabled, - }); + const routeIsValid = + !routeBlocked && + canBindVoiceSendRoute({ + enabled, + isGooseSession, + readOnly, + disabled, + }); const activeVoiceSessionId = status.sessionId; const routeMount = resolveVoiceRouteMount({ routeIsValid, @@ -677,7 +681,7 @@ export function useVoiceConversationController({ if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; } else if ( - (!enabled || !isGooseSession || readOnly) && + (!enabled || !isGooseSession || readOnly || routeBlocked) && activeSendRoute?.sessionId === sessionId ) { activeSendRoute = null; @@ -699,6 +703,7 @@ export function useVoiceConversationController({ isGooseSession, onSend, readOnly, + routeBlocked, sessionId, status.sessionId, ]); @@ -707,6 +712,7 @@ export function useVoiceConversationController({ if ( status.lifecycle !== "running" || status.sessionId !== sessionId || + routeBlocked || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -734,6 +740,7 @@ export function useVoiceConversationController({ enabled, isGooseSession, readOnly, + routeBlocked, sessionId, status.lifecycle, status.sessionId, From 28346f4bcf07c8c8f8a3a0ca5785417d7d0248c3 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 14:21:17 -0400 Subject: [PATCH 4/8] fix(voice): discard speech blocked by admission --- .../useVoiceConversationController.test.ts | 27 ++++++++++++++++--- .../hooks/useVoiceConversationController.ts | 23 ++++++++++++---- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 02188f2b0..21394344c 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -333,7 +333,7 @@ describe("voice transcript delivery coordination", () => { expect(onSend).not.toHaveBeenCalled(); }); - it("releases a retained transcript route when admission is blocked", async () => { + it("discards transcripts for a lifecycle after admission is blocked", async () => { const onSend = vi.fn().mockResolvedValue(true); useVoiceConversationStore.setState({ status: { @@ -349,7 +349,7 @@ describe("voice transcript delivery coordination", () => { hydrated: true, init: vi.fn().mockResolvedValue(undefined), }); - const { rerender } = renderHook( + const { rerender, unmount } = renderHook( ({ routeBlocked }) => useVoiceConversationController({ sessionId: "session-1", @@ -375,8 +375,29 @@ describe("voice transcript delivery coordination", () => { revision: 1, deliveryAttempts: 0, }), - ).rejects.toThrow("bound chat is unavailable"); + ).resolves.toBeUndefined(); expect(onSend).not.toHaveBeenCalled(); + + rerender({ routeBlocked: false }); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-after-admission-block", + text: "still do not deliver", + revision: 1, + deliveryAttempts: 0, + }), + ).resolves.toBeUndefined(); + expect(onSend).not.toHaveBeenCalled(); + + act(() => { + useVoiceConversationStore.setState((state) => ({ + status: { ...state.status, lifecycle: "stopped", sessionId: null }, + })); + }); + unmount(); }); it("serializes deliveries for the same session and re-evaluates in order", async () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 9a3eefab8..a254f2afa 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -36,6 +36,7 @@ interface VoiceSendRoute { // The backend conversation is process-wide, but voice input remains bound to // the chat that started the active lifecycle until that lifecycle terminates. let activeSendRoute: VoiceSendRoute | null = null; +const blockedSendRouteSessions = new Set(); let deliveryInitialized = false; const operationInFlightBySession = new Set(); let replacementOperationInFlight = false; @@ -446,6 +447,11 @@ function ensureVoiceEventDeliveryInitialized() { const deliveryRevision = event.revision; const shouldNotifyFailure = event.deliveryAttempts === 0; return enqueueVoiceTranscriptDelivery(event.sessionId, async () => { + // Admission blocks invalidate speech captured for the active lifecycle. + // Resolve successfully so the native queue acknowledges it instead of + // replaying it if the block later clears. + if (blockedSendRouteSessions.has(event.sessionId)) return; + const route = activeSendRoute; if (!route || route.sessionId !== event.sessionId) { const message = @@ -663,8 +669,14 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); + if (status.sessionId !== sessionId) { + blockedSendRouteSessions.delete(sessionId); + } else if (routeBlocked) { + blockedSendRouteSessions.add(sessionId); + } + const routeDiscardedForLifecycle = blockedSendRouteSessions.has(sessionId); const routeIsValid = - !routeBlocked && + !routeDiscardedForLifecycle && canBindVoiceSendRoute({ enabled, isGooseSession, @@ -686,8 +698,10 @@ export function useVoiceConversationController({ ) { activeSendRoute = null; } - if (routeMount.drainPending) { - const routeSessionId = activeSendRoute?.sessionId; + if (routeMount.drainPending || routeDiscardedForLifecycle) { + const routeSessionId = routeDiscardedForLifecycle + ? sessionId + : activeSendRoute?.sessionId; if (!routeSessionId) return; void drainPendingTranscripts( routeSessionId, @@ -712,7 +726,7 @@ export function useVoiceConversationController({ if ( status.lifecycle !== "running" || status.sessionId !== sessionId || - routeBlocked || + blockedSendRouteSessions.has(sessionId) || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -740,7 +754,6 @@ export function useVoiceConversationController({ enabled, isGooseSession, readOnly, - routeBlocked, sessionId, status.lifecycle, status.sessionId, From 21954234ff7f58b7013f8d02574fd9dd4a9cdbf3 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 14:50:14 -0400 Subject: [PATCH 5/8] fix(voice): defer transcripts during admission --- .../useVoiceConversationController.test.ts | 22 ++++++------ .../hooks/useVoiceConversationController.ts | 34 +++++++++++-------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 21394344c..fa084a9e4 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -333,7 +333,7 @@ describe("voice transcript delivery coordination", () => { expect(onSend).not.toHaveBeenCalled(); }); - it("discards transcripts for a lifecycle after admission is blocked", async () => { + it("defers transcripts until admission is unblocked", async () => { const onSend = vi.fn().mockResolvedValue(true); useVoiceConversationStore.setState({ status: { @@ -349,7 +349,7 @@ describe("voice transcript delivery coordination", () => { hydrated: true, init: vi.fn().mockResolvedValue(undefined), }); - const { rerender, unmount } = renderHook( + const { rerender } = renderHook( ({ routeBlocked }) => useVoiceConversationController({ sessionId: "session-1", @@ -375,7 +375,7 @@ describe("voice transcript delivery coordination", () => { revision: 1, deliveryAttempts: 0, }), - ).resolves.toBeUndefined(); + ).rejects.toThrow("waiting for its bound chat"); expect(onSend).not.toHaveBeenCalled(); rerender({ routeBlocked: false }); @@ -385,19 +385,17 @@ describe("voice transcript delivery coordination", () => { sessionId: "session-1", lifecycleId: "lifecycle-1", id: "utterance-after-admission-block", - text: "still do not deliver", + text: "deliver after unblock", revision: 1, deliveryAttempts: 0, }), ).resolves.toBeUndefined(); - expect(onSend).not.toHaveBeenCalled(); - - act(() => { - useVoiceConversationStore.setState((state) => ({ - status: { ...state.status, lifecycle: "stopped", sessionId: null }, - })); - }); - unmount(); + expect(onSend).toHaveBeenCalledWith( + "deliver after unblock", + undefined, + undefined, + expect.objectContaining({ displayText: "deliver after unblock" }), + ); }); it("serializes deliveries for the same session and re-evaluates in order", async () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index a254f2afa..4c778f804 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -447,10 +447,11 @@ function ensureVoiceEventDeliveryInitialized() { const deliveryRevision = event.revision; const shouldNotifyFailure = event.deliveryAttempts === 0; return enqueueVoiceTranscriptDelivery(event.sessionId, async () => { - // Admission blocks invalidate speech captured for the active lifecycle. - // Resolve successfully so the native queue acknowledges it instead of - // replaying it if the block later clears. - if (blockedSendRouteSessions.has(event.sessionId)) return; + if (blockedSendRouteSessions.has(event.sessionId)) { + throw new Error( + "Voice transcript is waiting for its bound chat to become available.", + ); + } const route = activeSendRoute; if (!route || route.sessionId !== event.sessionId) { @@ -489,6 +490,11 @@ function ensureVoiceEventDeliveryInitialized() { const opportunity = await waitForVoiceDeliveryOpportunity( event.sessionId, ); + if (blockedSendRouteSessions.has(event.sessionId)) { + throw new Error( + "Voice transcript is waiting for its bound chat to become available.", + ); + } const currentRoute = activeSendRoute; if (!currentRoute || currentRoute.sessionId !== event.sessionId) { throw new Error( @@ -669,14 +675,13 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); - if (status.sessionId !== sessionId) { - blockedSendRouteSessions.delete(sessionId); - } else if (routeBlocked) { + if (routeBlocked) { blockedSendRouteSessions.add(sessionId); + } else { + blockedSendRouteSessions.delete(sessionId); } - const routeDiscardedForLifecycle = blockedSendRouteSessions.has(sessionId); const routeIsValid = - !routeDiscardedForLifecycle && + !routeBlocked && canBindVoiceSendRoute({ enabled, isGooseSession, @@ -693,15 +698,13 @@ export function useVoiceConversationController({ if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; } else if ( - (!enabled || !isGooseSession || readOnly || routeBlocked) && + (!enabled || !isGooseSession || readOnly) && activeSendRoute?.sessionId === sessionId ) { activeSendRoute = null; } - if (routeMount.drainPending || routeDiscardedForLifecycle) { - const routeSessionId = routeDiscardedForLifecycle - ? sessionId - : activeSendRoute?.sessionId; + if (routeMount.drainPending) { + const routeSessionId = activeSendRoute?.sessionId; if (!routeSessionId) return; void drainPendingTranscripts( routeSessionId, @@ -726,7 +729,7 @@ export function useVoiceConversationController({ if ( status.lifecycle !== "running" || status.sessionId !== sessionId || - blockedSendRouteSessions.has(sessionId) || + routeBlocked || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -754,6 +757,7 @@ export function useVoiceConversationController({ enabled, isGooseSession, readOnly, + routeBlocked, sessionId, status.lifecycle, status.sessionId, From 771776f9b95d05371dad190811fd240545bf56b8 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 28 Aug 2026 15:17:33 -0400 Subject: [PATCH 6/8] fix(voice): defer blocked transcripts without rejection --- .../hooks/useVoiceConversationController.ts | 5 ++-- .../stores/voiceConversationStore.test.ts | 27 +++++++++++++++++++ .../stores/voiceConversationStore.ts | 27 ++++++++++++++----- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 4c778f804..cf94cb821 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -12,6 +12,7 @@ import { steerPromptInSession } from "@/features/chat/lib/steerCore"; import { subscribeToVoiceConversationEvents, useVoiceConversationStore, + VoiceTranscriptDeferredError, } from "../stores/voiceConversationStore"; import { captureNativeAssistantSpeechHistory, @@ -448,7 +449,7 @@ function ensureVoiceEventDeliveryInitialized() { const shouldNotifyFailure = event.deliveryAttempts === 0; return enqueueVoiceTranscriptDelivery(event.sessionId, async () => { if (blockedSendRouteSessions.has(event.sessionId)) { - throw new Error( + throw new VoiceTranscriptDeferredError( "Voice transcript is waiting for its bound chat to become available.", ); } @@ -491,7 +492,7 @@ function ensureVoiceEventDeliveryInitialized() { event.sessionId, ); if (blockedSendRouteSessions.has(event.sessionId)) { - throw new Error( + throw new VoiceTranscriptDeferredError( "Voice transcript is waiting for its bound chat to become available.", ); } diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index 78631600e..f1bb947c8 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.test.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.test.ts @@ -198,6 +198,33 @@ describe("voice conversation store lifecycle ordering", () => { unsubscribe(); }); + it("defers a blocked transcript without spending its rejection budget", async () => { + const transcript = { + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "deferred-utterance", + text: "Wait for admission", + revision: 1, + deliveryAttempts: 2, + }; + mocks.drain.mockResolvedValueOnce([transcript]); + const module = await import("./voiceConversationStore"); + const unsubscribe = module.subscribeToVoiceConversationEvents(() => + Promise.reject(new module.VoiceTranscriptDeferredError("blocked")), + ); + await module.useVoiceConversationStore.getState().init(); + + await expect( + module.useVoiceConversationStore + .getState() + .drainPendingTranscripts("session-1"), + ).resolves.toBeUndefined(); + + expect(mocks.reject).not.toHaveBeenCalled(); + expect(mocks.acknowledge).not.toHaveBeenCalled(); + unsubscribe(); + }); + it("restores prior causal state after terminal transcript rejection", async () => { mocks.reject.mockResolvedValueOnce({ attempts: 3, terminal: true }); const module = await import("./voiceConversationStore"); diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index 2fe6ad7a6..163b7ce8a 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -87,7 +87,11 @@ const voiceStartsInFlight = new Map>(); const eventSubscribers = new Set< (event: VoiceConversationEvent) => void | Promise >(); -const transcriptDeliveries = new Map>(); +type TranscriptDeliveryOutcome = "accepted" | "deferred" | "rejected"; +const transcriptDeliveries = new Map< + string, + Promise +>(); const deliveredTranscripts = new Set(); const deliveredTranscriptOrder: string[] = []; const MAX_DELIVERED_TRANSCRIPT_KEYS = 256; @@ -112,6 +116,8 @@ export function subscribeToVoiceConversationEvents( return () => eventSubscribers.delete(subscriber); } +export class VoiceTranscriptDeferredError extends Error {} + export async function blockVoiceConversationStarts( sessionId: string, ): Promise<() => Promise> { @@ -161,12 +167,12 @@ function rememberDeliveredTranscript(key: string) { async function deliverTranscriptOnce( transcript: PendingVoiceTranscript, -): Promise { +): Promise { const key = transcriptKey(transcript); if (deliveredTranscripts.has(key)) { await acknowledgeVoiceConversationTranscript(transcript); priorFinalizedTranscriptKeys.delete(key); - return true; + return "accepted"; } const existing = transcriptDeliveries.get(key); @@ -175,17 +181,22 @@ async function deliverTranscriptOnce( const event = { type: "user" as const, ...transcript }; const finalizedKey = finalizedTranscriptKey(transcript); const subscribers = [...eventSubscribers]; - if (subscribers.length === 0) return false; + if (subscribers.length === 0) return "rejected"; const delivery = (async () => { const results = await Promise.allSettled( subscribers.map((subscriber) => subscriber(event)), ); const accepted = results.some((result) => result.status === "fulfilled"); + const deferred = results.some( + (result) => + result.status === "rejected" && + result.reason instanceof VoiceTranscriptDeferredError, + ); if (accepted) { rememberDeliveredTranscript(key); await acknowledgeVoiceConversationTranscript(transcript); priorFinalizedTranscriptKeys.delete(key); - } else { + } else if (!deferred) { const rejection = await rejectVoiceConversationTranscript(transcript); if (rejection.terminal) { const priorKey = priorFinalizedTranscriptKeys.get(key) ?? null; @@ -205,7 +216,7 @@ async function deliverTranscriptOnce( ); } } - return accepted; + return accepted ? "accepted" : deferred ? "deferred" : "rejected"; })().finally(() => transcriptDeliveries.delete(key)); transcriptDeliveries.set(key, delivery); @@ -978,7 +989,9 @@ export const useVoiceConversationStore = create( if (!alreadyDelivered || current === null || current === key) { observeFinalizedTranscript(transcript); } - if (!(await deliverTranscriptOnce(transcript))) { + const outcome = await deliverTranscriptOnce(transcript); + if (outcome === "deferred") return; + if (outcome === "rejected") { throw new Error("Voice transcript delivery was rejected."); } } From 92aaa1c9709d4cf3232d6bccedb381c7270e111e Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 06:27:26 -0400 Subject: [PATCH 7/8] fix(voice): preserve deferred transcript state --- src/features/chat/ui/ChatView.tsx | 22 +++-- .../ui/__tests__/ChatView.mcpApp.test.tsx | 5 ++ .../useVoiceConversationController.test.ts | 86 ++++++++++++++++++- .../hooks/useVoiceConversationController.ts | 34 +++++--- 4 files changed, 124 insertions(+), 23 deletions(-) diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index a7d643ea2..df9396615 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -244,6 +244,18 @@ export function ChatView({ voiceInput.backend, voiceOutput.backend, ); + const voiceAdmissionPermanentlyBlocked = + composerBinding.target.kind === "existingSession" && + Boolean(composerBinding.target.admission.blockingReason); + const voiceDeliveryTemporarilyBlocked = + !voiceAdmissionPermanentlyBlocked && + ((composerBinding.target.kind === "existingSession" && + composerBinding.target.admission.securityConfirmationPending) || + controller.projectMetadataPending || + controller.isCompactingContext || + controller.isLoadingHistory || + !controller.workspaceContextReady || + controller.queue.queuedMessage !== null); const voiceConversation = useVoiceConversationController({ sessionId, // Voice delivery only needs to wait for admission. Holding its per-session @@ -264,14 +276,8 @@ export function ChatView({ }); }, readOnly: Boolean(readOnlyStatus), - routeBlocked: admissionBlocked, - disabled: - admissionBlocked || - controller.projectMetadataPending || - controller.isCompactingContext || - controller.isLoadingHistory || - !controller.workspaceContextReady || - controller.queue.queuedMessage !== null, + routeBlocked: voiceDeliveryTemporarilyBlocked, + disabled: admissionBlocked || voiceDeliveryTemporarilyBlocked, }); const isAgentBuilderOpen = agentBuilderOpenForLayout; const patchSession = useChatSessionStore((s) => s.patchSession); diff --git a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx index 5b34991a5..6e56929fb 100644 --- a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx +++ b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx @@ -454,6 +454,7 @@ describe("ChatView MCP app messaging", () => { projectMetadataPending: false, isCompactingContext: false, workspaceSetupInProgress: false, + workspaceContextReady: true, queue: { queuedMessage: null, dismiss: vi.fn() }, draftValue: "", handleDraftChange: mocks.handleDraftChange, @@ -687,8 +688,10 @@ describe("ChatView MCP app messaging", () => { const voiceOptions = mocks.voiceControllerSpy.mock.calls.at(-1)?.[0] as { onSend: (text: string) => boolean; disabled: boolean; + routeBlocked: boolean; }; expect(voiceOptions.disabled).toBe(true); + expect(voiceOptions.routeBlocked).toBe(true); expect(voiceOptions.onSend("blocked voice")).toBe(false); expect(mocks.handleSend).not.toHaveBeenCalled(); }); @@ -1389,8 +1392,10 @@ describe("ChatView MCP app messaging", () => { const voiceOptions = mocks.voiceControllerSpy.mock.calls.at(-1)?.[0] as { onSend: (text: string) => boolean; disabled: boolean; + routeBlocked: boolean; }; expect(voiceOptions.disabled).toBe(true); + expect(voiceOptions.routeBlocked).toBe(false); expect(voiceOptions.onSend("blocked voice")).toBe(false); expect(mocks.handleSend).not.toHaveBeenCalled(); }); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index fa084a9e4..12d96a5f1 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -227,7 +227,8 @@ describe("voice transcript delivery coordination", () => { nativeAssistantSpeechMocks.capture.mockClear(); nativeAssistantSpeechMocks.start.mockClear(); nativeAssistantSpeechMocks.stop.mockClear(); - nativeAssistantSpeechMocks.takeNotices.mockClear(); + nativeAssistantSpeechMocks.takeNotices.mockReset(); + nativeAssistantSpeechMocks.takeNotices.mockReturnValue(null); voiceApiMocks.confirmForegroundSession.mockReset(); voiceApiMocks.confirmForegroundSession.mockResolvedValue(1); microphonePermissionMocks.getStatus.mockReset(); @@ -304,7 +305,7 @@ describe("voice transcript delivery coordination", () => { init: vi.fn().mockResolvedValue(undefined), }); const { rerender } = renderHook( - ({ readOnly }) => + ({ readOnly, routeBlocked }) => useVoiceConversationController({ sessionId: "session-1", onSend, @@ -313,12 +314,13 @@ describe("voice transcript delivery coordination", () => { pocketReady: true, onPocketSetupRequired: vi.fn(), readOnly, + routeBlocked, }), - { initialProps: { readOnly: false } }, + { initialProps: { readOnly: false, routeBlocked: false } }, ); await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); - rerender({ readOnly: true }); + rerender({ readOnly: true, routeBlocked: true }); await expect( voiceStoreMocks.subscriber?.({ type: "user", @@ -333,6 +335,82 @@ describe("voice transcript delivery coordination", () => { expect(onSend).not.toHaveBeenCalled(); }); + it("defers mid-flight without error UI or consuming playback context", async () => { + const onSend = vi.fn().mockResolvedValue(true); + nativeAssistantSpeechMocks.takeNotices.mockReturnValue("playback context"); + useChatStore.getState().setChatState("session-1", "waiting"); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + }); + const { rerender } = renderHook( + ({ routeBlocked }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + routeBlocked, + }), + { initialProps: { routeBlocked: false } }, + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + const transcript = { + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-mid-flight", + text: "deliver after the block", + revision: 1, + deliveryAttempts: 0, + }; + const delivery = voiceStoreMocks.subscriber?.(transcript); + await waitFor(() => + expect(useVoiceConversationStore.getState().uiState).toBe( + "user-speaking", + ), + ); + + rerender({ routeBlocked: true }); + act(() => useChatStore.getState().setChatState("session-1", "idle")); + + await expect(delivery).rejects.toThrow("waiting for its bound chat"); + expect(useVoiceConversationStore.getState().uiState).toBe("listening"); + expect(nativeAssistantSpeechMocks.takeNotices).not.toHaveBeenCalled(); + expect(onSend).not.toHaveBeenCalled(); + expect( + useChatStore.getState().messagesBySession["session-1"] ?? [], + ).toEqual([]); + + rerender({ routeBlocked: false }); + await expect( + voiceStoreMocks.subscriber?.(transcript), + ).resolves.toBeUndefined(); + expect(onSend).toHaveBeenCalledWith( + "deliver after the block", + undefined, + undefined, + expect.objectContaining({ + assistantPrompt: "playback context", + displayText: "deliver after the block", + }), + ); + }); + it("defers transcripts until admission is unblocked", async () => { const onSend = vi.fn().mockResolvedValue(true); useVoiceConversationStore.setState({ diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index cf94cb821..16e85a5e2 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -479,12 +479,6 @@ function ensureVoiceEventDeliveryInitialized() { voiceConversationRevision: event.revision, }, }; - const playbackNotice = takeVoicePlaybackNotices(event.sessionId); - const displayOptions = { - ...sendOptions, - ...(playbackNotice ? { assistantPrompt: playbackNotice } : {}), - displayText: event.text, - }; try { // This runs inside the per-session queue, so a prior send can change // the opportunity to steer before the next transcript is evaluated. @@ -502,6 +496,12 @@ function ensureVoiceEventDeliveryInitialized() { "Voice transcript could not be sent because its bound chat is unavailable.", ); } + const playbackNotice = takeVoicePlaybackNotices(event.sessionId); + const displayOptions = { + ...sendOptions, + ...(playbackNotice ? { assistantPrompt: playbackNotice } : {}), + displayText: event.text, + }; store.setUiState("agent-working"); const delivered = opportunity === "steer" @@ -542,6 +542,16 @@ function ensureVoiceEventDeliveryInitialized() { resetVoiceUiWhenRunSettles(event.sessionId, deliveryRevision); } catch (deliveryError) { const current = useVoiceConversationStore.getState(); + if (deliveryError instanceof VoiceTranscriptDeferredError) { + if ( + current.status.lifecycle === "running" && + current.status.sessionId === event.sessionId && + current.status.revision >= deliveryRevision + ) { + current.setUiState("listening"); + } + throw deliveryError; + } if ( current.status.lifecycle === "running" && current.status.sessionId === event.sessionId && @@ -654,6 +664,8 @@ export function useVoiceConversationController({ (state) => state.clearRequestedStart, ); const previousPocketReady = useRef(pocketReady); + const deliveryBlocked = + routeBlocked && enabled && isGooseSession && !readOnly; useEffect(() => { if (!enabled || !isGooseSession) return; @@ -676,13 +688,13 @@ export function useVoiceConversationController({ useEffect(() => { if (enabled && isGooseSession) ensureVoiceEventDeliveryInitialized(); - if (routeBlocked) { + if (deliveryBlocked) { blockedSendRouteSessions.add(sessionId); } else { blockedSendRouteSessions.delete(sessionId); } const routeIsValid = - !routeBlocked && + !deliveryBlocked && canBindVoiceSendRoute({ enabled, isGooseSession, @@ -721,7 +733,7 @@ export function useVoiceConversationController({ isGooseSession, onSend, readOnly, - routeBlocked, + deliveryBlocked, sessionId, status.sessionId, ]); @@ -730,7 +742,7 @@ export function useVoiceConversationController({ if ( status.lifecycle !== "running" || status.sessionId !== sessionId || - routeBlocked || + deliveryBlocked || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -758,7 +770,7 @@ export function useVoiceConversationController({ enabled, isGooseSession, readOnly, - routeBlocked, + deliveryBlocked, sessionId, status.lifecycle, status.sessionId, From 0b518981f5040afc676318293802bbb5e51adb05 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 29 Aug 2026 07:01:00 -0400 Subject: [PATCH 8/8] fix(voice): clear permanently unavailable routes --- src/features/chat/ui/ChatView.tsx | 1 + .../ui/__tests__/ChatView.mcpApp.test.tsx | 4 ++ .../useVoiceConversationController.test.ts | 47 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 13 +++-- 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index df9396615..f663df1eb 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -277,6 +277,7 @@ export function ChatView({ }, readOnly: Boolean(readOnlyStatus), routeBlocked: voiceDeliveryTemporarilyBlocked, + routeUnavailable: voiceAdmissionPermanentlyBlocked, disabled: admissionBlocked || voiceDeliveryTemporarilyBlocked, }); const isAgentBuilderOpen = agentBuilderOpenForLayout; diff --git a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx index 6e56929fb..6d8701366 100644 --- a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx +++ b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx @@ -689,9 +689,11 @@ describe("ChatView MCP app messaging", () => { onSend: (text: string) => boolean; disabled: boolean; routeBlocked: boolean; + routeUnavailable: boolean; }; expect(voiceOptions.disabled).toBe(true); expect(voiceOptions.routeBlocked).toBe(true); + expect(voiceOptions.routeUnavailable).toBe(false); expect(voiceOptions.onSend("blocked voice")).toBe(false); expect(mocks.handleSend).not.toHaveBeenCalled(); }); @@ -1393,9 +1395,11 @@ describe("ChatView MCP app messaging", () => { onSend: (text: string) => boolean; disabled: boolean; routeBlocked: boolean; + routeUnavailable: boolean; }; expect(voiceOptions.disabled).toBe(true); expect(voiceOptions.routeBlocked).toBe(false); + expect(voiceOptions.routeUnavailable).toBe(true); expect(voiceOptions.onSend("blocked voice")).toBe(false); expect(mocks.handleSend).not.toHaveBeenCalled(); }); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 12d96a5f1..4b58efadd 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -335,6 +335,53 @@ describe("voice transcript delivery coordination", () => { expect(onSend).not.toHaveBeenCalled(); }); + it("releases a retained transcript route after permanent admission failure", async () => { + const onSend = vi.fn().mockResolvedValue(true); + useVoiceConversationStore.setState({ + status: { + available: true, + unavailableReason: null, + lifecycle: "running", + sessionId: "session-1", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 1, + }, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + }); + const { rerender } = renderHook( + ({ disabled, routeUnavailable }) => + useVoiceConversationController({ + sessionId: "session-1", + onSend, + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + disabled, + routeUnavailable, + }), + { initialProps: { disabled: false, routeUnavailable: false } }, + ); + + await waitFor(() => expect(voiceStoreMocks.subscriber).toBeDefined()); + rerender({ disabled: true, routeUnavailable: true }); + await expect( + voiceStoreMocks.subscriber?.({ + type: "user", + sessionId: "session-1", + lifecycleId: "lifecycle-1", + id: "utterance-admission-failed", + text: "do not deliver", + revision: 1, + deliveryAttempts: 0, + }), + ).rejects.toThrow("bound chat is unavailable"); + expect(onSend).not.toHaveBeenCalled(); + }); + it("defers mid-flight without error UI or consuming playback context", async () => { const onSend = vi.fn().mockResolvedValue(true); nativeAssistantSpeechMocks.takeNotices.mockReturnValue("playback context"); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 16e85a5e2..271cca2d0 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -617,6 +617,7 @@ export interface UseVoiceConversationControllerOptions { readOnly?: boolean; disabled?: boolean; routeBlocked?: boolean; + routeUnavailable?: boolean; } export function useVoiceConversationController({ @@ -631,6 +632,7 @@ export function useVoiceConversationController({ readOnly = false, disabled = false, routeBlocked = false, + routeUnavailable = false, }: UseVoiceConversationControllerOptions): ChatInputVoiceConversation { const { t } = useTranslation("chat"); const siriVoiceRef = useRef(siriVoice); @@ -665,7 +667,7 @@ export function useVoiceConversationController({ ); const previousPocketReady = useRef(pocketReady); const deliveryBlocked = - routeBlocked && enabled && isGooseSession && !readOnly; + routeBlocked && enabled && isGooseSession && !readOnly && !routeUnavailable; useEffect(() => { if (!enabled || !isGooseSession) return; @@ -695,6 +697,7 @@ export function useVoiceConversationController({ } const routeIsValid = !deliveryBlocked && + !routeUnavailable && canBindVoiceSendRoute({ enabled, isGooseSession, @@ -711,7 +714,7 @@ export function useVoiceConversationController({ if (routeMount.claimRoute) { activeSendRoute = { sessionId, send: onSend }; } else if ( - (!enabled || !isGooseSession || readOnly) && + (!enabled || !isGooseSession || readOnly || routeUnavailable) && activeSendRoute?.sessionId === sessionId ) { activeSendRoute = null; @@ -734,6 +737,7 @@ export function useVoiceConversationController({ onSend, readOnly, deliveryBlocked, + routeUnavailable, sessionId, status.sessionId, ]); @@ -743,6 +747,7 @@ export function useVoiceConversationController({ status.lifecycle !== "running" || status.sessionId !== sessionId || deliveryBlocked || + routeUnavailable || !canBindVoiceSendRoute({ enabled, isGooseSession, @@ -771,6 +776,7 @@ export function useVoiceConversationController({ isGooseSession, readOnly, deliveryBlocked, + routeUnavailable, sessionId, status.lifecycle, status.sessionId, @@ -1032,7 +1038,8 @@ export function useVoiceConversationController({ ]); const isActive = status.sessionId !== null && status.lifecycle !== "stopped"; - const sessionEligible = enabled && isGooseSession && !readOnly && !disabled; + const sessionEligible = + enabled && isGooseSession && !readOnly && !disabled && !routeUnavailable; const canToggle = sessionEligible && (!pocketReady || status.available); const toggle = useCallback(async () => {