From 982945bfe124a28b282bde702ccb9ec4127405ab Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:17:32 +0100 Subject: [PATCH 1/3] fix(mobile): retry failed dictation without recording again --- .../voice-input/voiceInputSession.test.ts | 45 ++++++++++++++++++- .../features/voice-input/voiceInputSession.ts | 1 + docs/user/composer.md | 6 ++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts index 9d30e376b54e2..493632f9891d6 100644 --- a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts +++ b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts @@ -47,6 +47,7 @@ function createSession( const releaseKeepAwake = vi.fn(); const keepAwake = vi.fn(() => releaseKeepAwake); const publishAudioLevels = vi.fn(); + const deleteRecording = vi.fn(); const recorder = { uri: "file:///voice.m4a", prepareToRecordAsync: vi.fn(async () => {}), @@ -63,18 +64,58 @@ function createSession( requestPermission: async () => ({ granted: true, canAskAgain: true }), configureRecording: async () => {}, releaseRecording: vi.fn(async () => {}), - deleteRecording: vi.fn(), + deleteRecording, sampleRecording: () => null, publishAudioLevels, keepAwake, ...overrides, }); - return { session, recorder, prepare, keepAwake, releaseKeepAwake, publishAudioLevels }; + return { + session, + recorder, + prepare, + keepAwake, + releaseKeepAwake, + publishAudioLevels, + deleteRecording, + }; } describe("global voice input", () => { beforeEach(() => resetVoiceInputGlobalsForTests()); + it("retries failed batch audio without recording again and deletes it after recovery", async () => { + const { session, recorder, prepare, deleteRecording, keepAwake } = createSession(); + const transcribe = vi + .fn() + .mockRejectedValueOnce(new Error("Upload failed")) + .mockRejectedValueOnce(new Error("Transcription failed")) + .mockResolvedValueOnce("recovered text"); + prepare.mockResolvedValue({ locale: "en-US", transcribe }); + const commit = vi.fn(); + await session.start(createTarget("first", () => "draft", commit, { start: 5, end: 5 })); + await session.controller.stop(); + expect(session.controller.currentState.phase).toBe("error"); + expect(deleteRecording).not.toHaveBeenCalled(); + expect(commit).not.toHaveBeenCalled(); + + await session.retry(); + expect(session.controller.currentState.phase).toBe("error"); + expect(deleteRecording).not.toHaveBeenCalled(); + await session.retry(); + + expect(session.controller.currentState.phase).toBe("idle"); + expect(transcribe.mock.calls.map(([uri]) => uri)).toEqual([ + "file:///voice.m4a", + "file:///voice.m4a", + "file:///voice.m4a", + ]); + expect(recorder.record).toHaveBeenCalledOnce(); + expect(keepAwake).toHaveBeenCalledOnce(); + expect(commit).toHaveBeenCalledExactlyOnceWith("draft recovered text", { start: 20, end: 20 }); + expect(deleteRecording).toHaveBeenCalledExactlyOnceWith("file:///voice.m4a"); + }); + it.each(["retry", "takeover"])( "handles failed deactivation with %s without notifying a released owner", async (action) => { diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.ts b/apps/mobile/src/features/voice-input/voiceInputSession.ts index 5bf9395daf59c..3de8678cc33d0 100644 --- a/apps/mobile/src/features/voice-input/voiceInputSession.ts +++ b/apps/mobile/src/features/voice-input/voiceInputSession.ts @@ -100,6 +100,7 @@ export class VoiceInputSession { constructor(dependencies: VoiceInputSessionDependencies) { this.dependencies = dependencies; this.controller = new VoiceInputController({ + retryFailedRecording: true, ...dependencies, onStateChange: (state) => this.handleStateChange(state), readDraft: () => this.target?.readDraft() ?? null, diff --git a/docs/user/composer.md b/docs/user/composer.md index 32de36e13b842..f3970ff83b4f4 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -240,8 +240,10 @@ microphone on iOS. Swipe a device left to forget it. Android may use another inp when the preferred microphone cannot be routed. Devices with the same name may share a saved preference, but simultaneous same-name inputs remain separately selectable. -T3 Code deletes the temporary recording from your phone after transcription or -cancellation. The transcript stays in your draft until you send the message to the agent. +If transcription on a paired environment fails, retry dictation to reuse the same +recording. Dismiss the error or choose another transcription destination to discard it. +T3 Code deletes the temporary recording from your phone after successful transcription +or cancellation. The transcript stays in your draft until you send the message to the agent. ## Queued messages From 4dab1146f327b25d92a881be43dfdc1fac8ebe9f Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:34:12 +0100 Subject: [PATCH 2/3] fix(mobile): discard dictation retries when drafts reset --- .../src/features/threads/ThreadComposer.tsx | 2 + .../voice-input/useVoiceInputController.ts | 2 + .../voice-input/voiceInputSession.test.ts | 99 +++++++++++++------ .../features/voice-input/voiceInputSession.ts | 21 +++- apps/mobile/src/state/use-composer-drafts.ts | 4 + docs/user/composer.md | 7 +- 6 files changed, 97 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e36900736e8e2..9e6b652f95dd5 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -59,6 +59,7 @@ import { getComposerDraftSnapshot, composerDraftsAtom, setComposerDraftText, + subscribeComposerDraftReset, composerContextImportsAtom, countComposerDraftAttachmentsAfterSelection, useComposerDraft, @@ -576,6 +577,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps label: props.selectedThread.title || "Untitled thread", readDraftMessage: () => getComposerDraftSnapshot(composerDraftKey).text, subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(composerDraftsAtom, onChange), + subscribeToDraftReset: (onReset) => subscribeComposerDraftReset(composerDraftKey, onReset), selection: composerMenu.selection, onChangeDraftMessage: (text) => setComposerDraftText(composerDraftKey, text), onChangeSelection: composerMenu.onSelectionChange, diff --git a/apps/mobile/src/features/voice-input/useVoiceInputController.ts b/apps/mobile/src/features/voice-input/useVoiceInputController.ts index e26672b5b4809..69a1a6601fb57 100644 --- a/apps/mobile/src/features/voice-input/useVoiceInputController.ts +++ b/apps/mobile/src/features/voice-input/useVoiceInputController.ts @@ -16,6 +16,7 @@ export function useVoiceInputController(input: { readonly label: string; readonly readDraftMessage: () => string | null; readonly subscribeToDraftChanges: (onChange: () => void) => () => void; + readonly subscribeToDraftReset?: (onReset: () => void) => () => void; readonly selection: ComposerEditorSelection; readonly disabled?: boolean; readonly onChangeDraftMessage: (value: string) => void; @@ -59,6 +60,7 @@ export function useVoiceInputController(input: { }, captured.selection, captured.subscribeToDraftChanges, + captured.subscribeToDraftReset, ), label: captured.label, }); diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts index 493632f9891d6..e583161e7368c 100644 --- a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts +++ b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { beforeEach, describe, expect, it, onTestFinished, vi } from "vite-plus/test"; import type { PreparedVoiceTranscription } from "@t3tools/client-runtime/voice-input"; import { Atom, AtomRegistry } from "effect/reactivity"; import { resetVoiceInputGlobalsForTests } from "../../../../../packages/client-runtime/src/voice-input/controller"; @@ -84,37 +84,73 @@ function createSession( describe("global voice input", () => { beforeEach(() => resetVoiceInputGlobalsForTests()); - it("retries failed batch audio without recording again and deletes it after recovery", async () => { - const { session, recorder, prepare, deleteRecording, keepAwake } = createSession(); - const transcribe = vi - .fn() - .mockRejectedValueOnce(new Error("Upload failed")) - .mockRejectedValueOnce(new Error("Transcription failed")) - .mockResolvedValueOnce("recovered text"); - prepare.mockResolvedValue({ locale: "en-US", transcribe }); - const commit = vi.fn(); - await session.start(createTarget("first", () => "draft", commit, { start: 5, end: 5 })); - await session.controller.stop(); - expect(session.controller.currentState.phase).toBe("error"); - expect(deleteRecording).not.toHaveBeenCalled(); - expect(commit).not.toHaveBeenCalled(); - - await session.retry(); - expect(session.controller.currentState.phase).toBe("error"); - expect(deleteRecording).not.toHaveBeenCalled(); - await session.retry(); + it.each(["recover", "reset", "remove"] as const)( + "handles retained batch audio on %s", + async (action) => { + const registry = AtomRegistry.make(); + onTestFinished(() => registry.dispose()); + const draft = Atom.make("draft"); + const reset = Atom.make(0); + const unsubscribeReset = vi.fn(); + const { session, recorder, prepare, deleteRecording, keepAwake } = createSession(); + const transcribe = vi + .fn() + .mockRejectedValueOnce(new Error("Upload failed")) + .mockRejectedValueOnce(new Error("Transcription failed")) + .mockResolvedValueOnce("recovered text"); + prepare.mockResolvedValue({ locale: "en-US", transcribe }); + const commit = vi.fn(); + await session.start( + createVoiceInputTarget( + "first", + () => registry.get(draft), + commit, + { start: 5, end: 5 }, + (onChange) => registry.subscribe(draft, onChange), + (onReset) => { + const stop = registry.subscribe(reset, onReset); + return () => { + stop(); + unsubscribeReset(); + }; + }, + ), + ); + await session.controller.stop(); + expect(session.controller.currentState.phase).toBe("error"); + expect(deleteRecording).not.toHaveBeenCalled(); + expect(commit).not.toHaveBeenCalled(); - expect(session.controller.currentState.phase).toBe("idle"); - expect(transcribe.mock.calls.map(([uri]) => uri)).toEqual([ - "file:///voice.m4a", - "file:///voice.m4a", - "file:///voice.m4a", - ]); - expect(recorder.record).toHaveBeenCalledOnce(); - expect(keepAwake).toHaveBeenCalledOnce(); - expect(commit).toHaveBeenCalledExactlyOnceWith("draft recovered text", { start: 20, end: 20 }); - expect(deleteRecording).toHaveBeenCalledExactlyOnceWith("file:///voice.m4a"); - }); + await session.retry(); + expect(session.controller.currentState.phase).toBe("error"); + expect(deleteRecording).not.toHaveBeenCalled(); + if (action === "recover") { + await session.retry(); + } else { + if (action === "reset") registry.set(reset, 1); + else registry.set(draft, null); + expect(session.controller.currentState.phase).toBe("idle"); + expect(deleteRecording).toHaveBeenCalledExactlyOnceWith("file:///voice.m4a"); + expect(commit).not.toHaveBeenCalled(); + await session.start(createTarget("first", () => "draft", commit, { start: 5, end: 5 })); + await session.controller.stop(); + } + expect(session.controller.currentState.phase).toBe("idle"); + expect(transcribe.mock.calls.map(([uri]) => uri)).toEqual([ + "file:///voice.m4a", + "file:///voice.m4a", + "file:///voice.m4a", + ]); + expect(recorder.record).toHaveBeenCalledTimes(action === "recover" ? 1 : 2); + expect(keepAwake).toHaveBeenCalledTimes(action === "recover" ? 1 : 2); + expect(commit).toHaveBeenCalledExactlyOnceWith("draft recovered text", { + start: 20, + end: 20, + }); + expect(deleteRecording).toHaveBeenCalledTimes(action === "recover" ? 1 : 2); + expect(unsubscribeReset).toHaveBeenCalledTimes(action === "recover" ? 3 : 2); + }, + ); it.each(["retry", "takeover"])( "handles failed deactivation with %s without notifying a released owner", @@ -335,6 +371,7 @@ describe("global voice input", () => { await session.controller.stop(); expect(commit).not.toHaveBeenCalled(); expect(session.controller.currentState.error).toContain("draft changed"); + session.dispose(); expect(unsubscribe).toHaveBeenCalledTimes(1); registry.dispose(); }); diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.ts b/apps/mobile/src/features/voice-input/voiceInputSession.ts index 3de8678cc33d0..3a926579e9a69 100644 --- a/apps/mobile/src/features/voice-input/voiceInputSession.ts +++ b/apps/mobile/src/features/voice-input/voiceInputSession.ts @@ -19,7 +19,7 @@ export type VoiceInputTarget = { readonly label?: string; readonly readDraft: () => VoiceDraftSnapshot | null; readonly commitDraft: VoiceInputControllerDependencies["commitDraft"]; - readonly subscribe: () => () => void; + readonly subscribe: (onReset: () => void) => () => void; }; export type VoiceInputSnapshot = { @@ -53,6 +53,7 @@ export function createVoiceInputTarget( commitDraft: VoiceInputTarget["commitDraft"], selection: VoiceDraftSnapshot["selection"], subscribeToChanges: (onChange: () => void) => () => void, + subscribeToReset?: (onReset: () => void) => () => void, ): VoiceInputTarget { let revision = 0; return { @@ -63,15 +64,24 @@ export function createVoiceInputTarget( return { ownerKey, text, selection, revision }; }, commitDraft, - subscribe: () => { + subscribe: (onReset) => { let previousText = readText(); - return subscribeToChanges(() => { + const unsubscribeChanges = subscribeToChanges(() => { const text = readText(); + if (text === null) { + onReset(); + return; + } if (text !== previousText) { previousText = text; revision += 1; } }); + const unsubscribeReset = subscribeToReset?.(onReset); + return () => { + unsubscribeChanges(); + unsubscribeReset?.(); + }; }, }; } @@ -141,8 +151,9 @@ export class VoiceInputSession { start(target: VoiceInputTarget): Promise { if (voiceInputBlocksSubmission(this.controller.currentState)) return Promise.resolve(); + this.unsubscribe?.(); this.target = target; - this.unsubscribe = target.subscribe(); + this.unsubscribe = target.subscribe(() => this.cancel(target.ownerKey)); return this.controller.start(); } @@ -155,7 +166,7 @@ export class VoiceInputSession { } private handleStateChange(state: VoiceInputState): void { - if (!voiceInputBlocksSubmission(state)) { + if (state.phase === "idle") { this.unsubscribe?.(); this.unsubscribe = null; } diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index b233034c5cf84..bf4315abcf8c7 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -122,6 +122,10 @@ function markComposerDraftReset(draftKey: string): void { appAtomRegistry.set(composerDraftResetAtom(draftKey), nextComposerDraftReset()); } +export function subscribeComposerDraftReset(draftKey: string, onReset: () => void): () => void { + return appAtomRegistry.subscribe(composerDraftResetAtom(draftKey), onReset); +} + /** * Raised when a draft's text is replaced outright (sent, discarded), or when the editor switches * drafts. iOS editor events read it synchronously, so an edit the editor made before applying the diff --git a/docs/user/composer.md b/docs/user/composer.md index f3970ff83b4f4..49db480d1fe62 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -241,9 +241,12 @@ when the preferred microphone cannot be routed. Devices with the same name may s a saved preference, but simultaneous same-name inputs remain separately selectable. If transcription on a paired environment fails, retry dictation to reuse the same -recording. Dismiss the error or choose another transcription destination to discard it. +recording. Sending or discarding the draft, dismissing the error, or choosing another +transcription destination discards that recording. T3 Code deletes the temporary recording from your phone after successful transcription -or cancellation. The transcript stays in your draft until you send the message to the agent. +or cancellation. On-device iPhone transcription also deletes it after a failed attempt, +so retrying requires a new recording. The transcript stays in your draft until you send +the message to the agent. ## Queued messages From 7739256d285a3ab71aee91620134fbae18735d88 Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:51:46 +0100 Subject: [PATCH 3/3] fix(mobile): discard dictation retries for fresh scheduled tasks --- .../SettingsScheduledTasksRouteScreen.tsx | 2 + .../settings/scheduled-task-editor-state.ts | 8 + .../voice-input/voiceInputSession.test.ts | 165 +++++++++++------- 3 files changed, 111 insertions(+), 64 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index ef4b696c82ec8..7a6d2c8ae1cfd 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -86,6 +86,7 @@ import { discardScheduledTaskEditor, markScheduledTaskEditorSaved, scheduledTaskEditorSessionAtom, + subscribeScheduledTaskEditorReset, } from "./scheduled-task-editor-state"; import { appAtomRegistry } from "../../state/atom-registry"; import { @@ -518,6 +519,7 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } label: editor?.draft.title.trim() || title, subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(scheduledTaskEditorSessionAtom, onChange), + subscribeToDraftReset: subscribeScheduledTaskEditorReset, selection, readDraftMessage: () => { const current = readEditor(); diff --git a/apps/mobile/src/features/settings/scheduled-task-editor-state.ts b/apps/mobile/src/features/settings/scheduled-task-editor-state.ts index 73b8b1da72777..22b4a5554f66b 100644 --- a/apps/mobile/src/features/settings/scheduled-task-editor-state.ts +++ b/apps/mobile/src/features/settings/scheduled-task-editor-state.ts @@ -22,9 +22,14 @@ type ScheduledTaskEditorSession = { export const scheduledTaskEditorSessionAtom = Atom.make( null, ).pipe(Atom.keepAlive); +const scheduledTaskEditorResetAtom = Atom.make(0).pipe(Atom.keepAlive); let nextEditorId = 0; let newTaskLinkAwaitsDiscard = false; +export function subscribeScheduledTaskEditorReset(onReset: () => void): () => void { + return appAtomRegistry.subscribe(scheduledTaskEditorResetAtom, onReset); +} + export function scheduledTaskEditorHasChanges( initial: ScheduledTaskEditor | null, current: ScheduledTaskEditor | null, @@ -68,6 +73,7 @@ export function updateScheduledTaskEditor( */ export function markScheduledTaskEditorSaved(): void { newTaskLinkAwaitsDiscard = false; + appAtomRegistry.set(scheduledTaskEditorResetAtom, ++nextEditorId); const session = appAtomRegistry.get(scheduledTaskEditorSessionAtom); if (session) { appAtomRegistry.set(scheduledTaskEditorSessionAtom, { ...session, initial: session.current }); @@ -96,6 +102,7 @@ export function takeScheduledTaskNewLink( if (session && scheduledTaskEditorHasChanges(session.initial, session.current)) { newTaskLinkAwaitsDiscard = true; } else { + appAtomRegistry.set(scheduledTaskEditorResetAtom, ++nextEditorId); appAtomRegistry.set(scheduledTaskEditorSessionAtom, null); } } @@ -106,6 +113,7 @@ export function takeScheduledTaskNewLink( export function discardScheduledTaskEditor(): void { if (newTaskLinkAwaitsDiscard) { newTaskLinkAwaitsDiscard = false; + appAtomRegistry.set(scheduledTaskEditorResetAtom, ++nextEditorId); appAtomRegistry.set(scheduledTaskEditorSessionAtom, null); return; } diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts index e583161e7368c..d0f92781c1504 100644 --- a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts +++ b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, onTestFinished, vi } from "vite-plus/test"; import type { PreparedVoiceTranscription } from "@t3tools/client-runtime/voice-input"; +import { EnvironmentId } from "@t3tools/contracts"; import { Atom, AtomRegistry } from "effect/reactivity"; import { resetVoiceInputGlobalsForTests } from "../../../../../packages/client-runtime/src/voice-input/controller"; import { @@ -26,6 +27,16 @@ import { type VoiceInputTarget, type VoiceRecordingSample, } from "./voiceInputSession"; +import { + discardScheduledTaskEditor, + markScheduledTaskEditorSaved, + scheduledTaskEditorSessionAtom, + subscribeScheduledTaskEditorReset, + takeScheduledTaskNewLink, + updateScheduledTaskEditor, +} from "../settings/scheduled-task-editor-state"; +import { createDraft } from "../settings/scheduledTaskDraft"; +import { appAtomRegistry } from "../../state/atom-registry"; function createTarget( ownerKey: string, @@ -84,73 +95,99 @@ function createSession( describe("global voice input", () => { beforeEach(() => resetVoiceInputGlobalsForTests()); - it.each(["recover", "reset", "remove"] as const)( - "handles retained batch audio on %s", - async (action) => { - const registry = AtomRegistry.make(); - onTestFinished(() => registry.dispose()); - const draft = Atom.make("draft"); - const reset = Atom.make(0); - const unsubscribeReset = vi.fn(); - const { session, recorder, prepare, deleteRecording, keepAwake } = createSession(); - const transcribe = vi - .fn() - .mockRejectedValueOnce(new Error("Upload failed")) - .mockRejectedValueOnce(new Error("Transcription failed")) - .mockResolvedValueOnce("recovered text"); - prepare.mockResolvedValue({ locale: "en-US", transcribe }); - const commit = vi.fn(); - await session.start( - createVoiceInputTarget( - "first", - () => registry.get(draft), - commit, - { start: 5, end: 5 }, - (onChange) => registry.subscribe(draft, onChange), - (onReset) => { - const stop = registry.subscribe(reset, onReset); - return () => { - stop(); - unsubscribeReset(); - }; - }, - ), - ); - await session.controller.stop(); - expect(session.controller.currentState.phase).toBe("error"); - expect(deleteRecording).not.toHaveBeenCalled(); - expect(commit).not.toHaveBeenCalled(); + it.each([ + "recover", + "reset", + "remove", + "new-task-link", + "task-saved", + "discard-new-task-link", + ] as const)("handles retained batch audio on %s", async (action) => { + const registry = AtomRegistry.make(); + onTestFinished(() => registry.dispose()); + appAtomRegistry.set(scheduledTaskEditorSessionAtom, null); + onTestFinished(() => appAtomRegistry.set(scheduledTaskEditorSessionAtom, null)); + const draft = Atom.make("draft"); + const reset = Atom.make(0); + const unsubscribeReset = vi.fn(); + const { session, recorder, prepare, deleteRecording, keepAwake } = createSession(); + const transcribe = vi + .fn() + .mockRejectedValueOnce(new Error("Upload failed")) + .mockRejectedValueOnce(new Error("Transcription failed")) + .mockResolvedValueOnce("recovered text"); + prepare.mockResolvedValue({ locale: "en-US", transcribe }); + const commit = vi.fn(); + await session.start( + createVoiceInputTarget( + "first", + () => registry.get(draft), + commit, + { start: 5, end: 5 }, + (onChange) => registry.subscribe(draft, onChange), + (onReset) => { + const stop = ["new-task-link", "task-saved", "discard-new-task-link"].includes(action) + ? subscribeScheduledTaskEditorReset(onReset) + : registry.subscribe(reset, onReset); + return () => { + stop(); + unsubscribeReset(); + }; + }, + ), + ); + await session.controller.stop(); + expect(session.controller.currentState.phase).toBe("error"); + expect(deleteRecording).not.toHaveBeenCalled(); + expect(commit).not.toHaveBeenCalled(); + await session.retry(); + expect(session.controller.currentState.phase).toBe("error"); + expect(deleteRecording).not.toHaveBeenCalled(); + if (action === "recover") { await session.retry(); - expect(session.controller.currentState.phase).toBe("error"); - expect(deleteRecording).not.toHaveBeenCalled(); - if (action === "recover") { - await session.retry(); - } else { - if (action === "reset") registry.set(reset, 1); - else registry.set(draft, null); - expect(session.controller.currentState.phase).toBe("idle"); - expect(deleteRecording).toHaveBeenCalledExactlyOnceWith("file:///voice.m4a"); - expect(commit).not.toHaveBeenCalled(); - await session.start(createTarget("first", () => "draft", commit, { start: 5, end: 5 })); - await session.controller.stop(); - } + } else { + if (action === "new-task-link") { + takeScheduledTaskNewLink({ routes: [{ name: "SettingsScheduledTaskNew" }] }); + } else if (action === "discard-new-task-link") { + const editor = { + environmentId: EnvironmentId.make("environment"), + environmentLabel: "Environment", + draft: createDraft(null, null), + }; + updateScheduledTaskEditor( + { ...editor, draft: { ...editor.draft, prompt: "typed" } }, + editor, + ); + takeScheduledTaskNewLink({ routes: [{ name: "SettingsScheduledTaskNew" }] }); + expect(session.controller.currentState.phase).toBe("error"); + expect(deleteRecording).not.toHaveBeenCalled(); + discardScheduledTaskEditor(); + } else if (action === "task-saved") { + markScheduledTaskEditorSaved(); + } else if (action === "reset") registry.set(reset, 1); + else registry.set(draft, null); expect(session.controller.currentState.phase).toBe("idle"); - expect(transcribe.mock.calls.map(([uri]) => uri)).toEqual([ - "file:///voice.m4a", - "file:///voice.m4a", - "file:///voice.m4a", - ]); - expect(recorder.record).toHaveBeenCalledTimes(action === "recover" ? 1 : 2); - expect(keepAwake).toHaveBeenCalledTimes(action === "recover" ? 1 : 2); - expect(commit).toHaveBeenCalledExactlyOnceWith("draft recovered text", { - start: 20, - end: 20, - }); - expect(deleteRecording).toHaveBeenCalledTimes(action === "recover" ? 1 : 2); - expect(unsubscribeReset).toHaveBeenCalledTimes(action === "recover" ? 3 : 2); - }, - ); + expect(deleteRecording).toHaveBeenCalledExactlyOnceWith("file:///voice.m4a"); + expect(commit).not.toHaveBeenCalled(); + await session.start(createTarget("first", () => "draft", commit, { start: 5, end: 5 })); + await session.controller.stop(); + } + expect(session.controller.currentState.phase).toBe("idle"); + expect(transcribe.mock.calls.map(([uri]) => uri)).toEqual([ + "file:///voice.m4a", + "file:///voice.m4a", + "file:///voice.m4a", + ]); + expect(recorder.record).toHaveBeenCalledTimes(action === "recover" ? 1 : 2); + expect(keepAwake).toHaveBeenCalledTimes(action === "recover" ? 1 : 2); + expect(commit).toHaveBeenCalledExactlyOnceWith("draft recovered text", { + start: 20, + end: 20, + }); + expect(deleteRecording).toHaveBeenCalledTimes(action === "recover" ? 1 : 2); + expect(unsubscribeReset).toHaveBeenCalledTimes(action === "recover" ? 3 : 2); + }); it.each(["retry", "takeover"])( "handles failed deactivation with %s without notifying a released owner",