From 9c687b8ae1c89a7448f4368b6be5bf0335af8114 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 10 Nov 2026 00:08:46 +0530 Subject: [PATCH 1/7] fix(web): finish confirmed dictation sends in their original thread --- .../ChatView.dictation.browser.test.tsx | 137 +++++++++++++++ apps/web/src/components/ChatView.tsx | 9 +- apps/web/src/components/chat/ChatComposer.tsx | 45 ++++- .../src/components/chat/useComposerSend.ts | 112 ++++++++++-- .../components/chat/useMultipleModelSend.ts | 6 +- ...useEnvironmentSpeechInput.browser.test.tsx | 88 +++++++++- .../src/speech/useEnvironmentSpeechInput.ts | 162 ++++++++++++++---- apps/web/test/fakeEnvironment.ts | 2 +- 8 files changed, 497 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/components/ChatView.dictation.browser.test.tsx diff --git a/apps/web/src/components/ChatView.dictation.browser.test.tsx b/apps/web/src/components/ChatView.dictation.browser.test.tsx new file mode 100644 index 0000000000000..f595c90aa3416 --- /dev/null +++ b/apps/web/src/components/ChatView.dictation.browser.test.tsx @@ -0,0 +1,137 @@ +import { createMemoryHistory } from "@tanstack/react-router"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import type * as Context from "effect/Context"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { AppRoot } from "~/AppRoot"; +import "~/components/ChatView"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { getRouter } from "~/router"; +import { chooseAudioDestination, chooseTranscriptCleanup } from "~/speech/voiceConsent"; +import { + ENVIRONMENT_ID, + dispatchedCommands, + installFakeEnvironment, + threadWithHistory, + type FakeDriver, +} from "../../test/fakeEnvironment"; +import { act, render } from "../../test/render"; + +vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { + const [actual, { fakeDriver }, Layer] = await Promise.all([ + importOriginal<{ readonly ConnectionDriver: Context.Key }>(), + import("../../test/fakeEnvironment"), + import("effect/Layer"), + ]); + return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; +}); +vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ + ...(await original()), + trackRpcRequestSent: () => {}, +})); + +it.each(["transcription", "cleanup"])( + "sends a confirmed voice message once to its original thread after navigation during %s", + async (stage) => { + const a = threadWithHistory("dictation-a", 1); + const b = threadWithHistory("dictation-b", 1); + using _environment = installFakeEnvironment([a, b], { acceptCommands: true }); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + chooseAudioDestination({ kind: "environment", environmentId: ENVIRONMENT_ID }); + chooseTranscriptCleanup(stage === "cleanup"); + const aRef = scopeThreadRef(ENVIRONMENT_ID, a.id); + const bRef = scopeThreadRef(ENVIRONMENT_ID, b.id); + useComposerDraftStore.getState().setPrompt(aRef, "Fix"); + useComposerDraftStore.getState().setPrompt(bRef, "Do not send"); + let resolveResponse!: (response: Response) => void; + const response = new Promise((resolve) => { + resolveResponse = resolve; + }); + const fetch = globalThis.fetch; + using _fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = new URL( + input instanceof Request ? input.url : String(input), + window.location.href, + ); + if (url.pathname === "/api/voice/status") + return Response.json({ + supported: true, + state: "ready", + modelId: "test-model", + model: "Test", + size: 1, + supportsStreaming: false, + supportsTranslation: false, + acceleration: "auto", + modelUnloadTimeout: "min_15", + gpuDevices: [], + }); + if (url.pathname === "/api/voice/transcribe") + return stage === "transcription" ? response : Response.json({ text: "the login bug" }); + if (url.pathname === "/api/voice/post-process") return response; + return fetch(input, init); + }); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${a.id}`] }), + ); + await router.load(); + await using _app = await render(, { + // AppRoot supplies its own router. + router: false, + }); + const audio = new AudioContext(); + const oscillator = audio.createOscillator(); + const destination = audio.createMediaStreamDestination(); + oscillator.connect(destination); + oscillator.start(); + using _audio = { + [Symbol.dispose]: () => { + oscillator.stop(); + void audio.close(); + }, + }; + using _microphone = vi + .spyOn(navigator.mediaDevices, "getUserMedia") + .mockResolvedValue(destination.stream); + await page.getByRole("button", { name: "Start voice input", exact: true }).click(); + await expect + .element(page.getByRole("button", { name: "Cancel voice input", exact: true })) + .toBeVisible(); + await page.getByRole("button", { name: "Submit message", exact: true }).click(); + await vi.waitFor(() => + expect( + _fetch.mock.calls.some(([input]) => + String(input instanceof Request ? input.url : input).includes( + stage === "transcription" ? "/api/voice/transcribe" : "/api/voice/post-process", + ), + ), + ).toBe(true), + ); + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: b.id }, + }), + ); + await expect.element(page.getByTestId("composer-editor")).toHaveTextContent("Do not send"); + resolveResponse(Response.json({ text: "the login bug" })); + await vi.waitFor(() => + expect(dispatchedCommands().filter((command) => command.type === "message.dispatch")).toEqual( + [ + expect.objectContaining({ + threadId: a.id, + text: "Fix the login bug", + }), + ], + ), + ); + expect(useComposerDraftStore.getState().getComposerDraft(bRef)?.prompt).toBe("Do not send"); + await expect.element(page.getByTestId("composer-editor")).toHaveTextContent("Do not send"); + }, +); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6daf189f7d6df..54321f32e5508 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2948,7 +2948,14 @@ export default function ChatView(props: ChatViewProps) { }); }; const onWorktreeSetupWorkLocallyStable = useStableCallback(onWorktreeSetupWorkLocally); - const onComposerSendStable = useStableCallback(onSend); + const onComposerSendStable = useStableCallback( + ( + event?: { preventDefault: () => void }, + mode?: Parameters[1], + intent?: Parameters[2], + dictation?: Promise, + ) => onSend(event, mode, intent, undefined, dictation), + ); const sendPreviewAnnotation = ( annotation: PreviewAnnotationPayload, image: ComposerImageAttachment | null, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 65eb3896f48ce..53b50b45e9cac 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -448,6 +448,7 @@ export interface ChatComposerProps { e?: { preventDefault: () => void }, dispatchMode?: ComposerDispatchMode, submissionIntent?: ComposerSubmissionIntent, + dictation?: Promise, ) => void; onResume: () => void; onInterrupt: () => void; @@ -1794,6 +1795,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ownerKey: questionAttachmentTarget ?? composerDraftTargetKey, draftText: prompt, disabled: dictationDisabled, + captureSubmissionDraft: questionAttachmentTarget + ? undefined + : () => { + const target = routeThreadRef; + const captured = getComposerDraft(target); + return { + readDraft: () => { + if (getComposerDraft(target) !== captured) return null; + const text = captured?.prompt ?? ""; + return { text, selection: { start: text.length, end: text.length } }; + }, + commitDraft: (text) => setComposerDraftPrompt(target, text), + }; + }, readDraft: () => { const snapshot = readComposerSnapshot(); return { @@ -2087,11 +2102,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) dispatchMode?: ComposerDispatchMode, submissionIntent?: ComposerSubmissionIntent, ) => { - if (voiceInputActive) { - event?.preventDefault(); - submitAfterDictation(() => submitComposerRef.current(dispatchMode, submissionIntent)); - return; - } if ( noProviderAvailable || isSendDisabled || @@ -2121,6 +2131,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } + if (voiceInputActive) { + event?.preventDefault(); + if (activePendingProgress) { + submitAfterDictation(() => submitComposerRef.current(dispatchMode, submissionIntent)); + return; + } + const transcript = new Promise((resolve) => { + submitAfterDictation(resolve, () => resolve(null)); + }); + onSend( + undefined, + dispatchMode ?? + resolveComposerDispatchMode({ + running: phase === "running", + alternateModifier: false, + activeTurnDefault: followUpBehavior, + canSteer: !isCompacting, + }), + submissionIntent, + transcript, + ); + return; + } const submission = submitComposerDraft({ prompt: promptRef.current, submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", @@ -2149,7 +2182,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }; const submitComposerStable = useStableCallback(submitComposer); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed submission handler to the asynchronous dictation completion + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- question submissions need the answers from the render that committed their transcript useLayoutEffect(() => { submitComposerRef.current = (mode, intent) => submitComposerStable(undefined, mode, intent); }, [submitComposerStable]); diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 2b1b8d73c756b..d0bb9d5bb5dd8 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -1,3 +1,4 @@ +import { getComposerSubmissionValidationMessage } from "./composerSubmission"; import { isClearComposerCommand, parseSideChatCommand } from "@t3tools/shared/composerTrigger"; import { armFreshSession, disarmFreshSession, readFreshSessionArm } from "./freshSessionArms"; import { @@ -266,8 +267,6 @@ export function useComposerSend(input: ComposerSendInput) { activeProject, activeProjectDefaultModelSelection, activeMessageCount, - isServerThread, - isLocalDraftThread, isGitRepo, phase, runtimeMode, @@ -370,20 +369,91 @@ export function useComposerSend(input: ComposerSendInput) { annotation: PreviewAnnotationPayload; image: ComposerImageAttachment | null; }, + dictation?: Promise, ) => { e?.preventDefault(); + const stillHere = () => currentRouteThreadKeyRef.current === routeThreadKey; + const capturedHandle = input.composerRef.current; + const capturedContext = dictation ? capturedHandle?.getSendContext() : undefined; + const pendingEnvironmentAtSend = readPendingEnvironmentChange(); + const pendingAttachmentsAtSend = dictation && capturedHandle?.hasPendingAttachments(); + let dictatedPrompt = input.promptRef.current; + const composerRef = dictation + ? { + current: capturedHandle && { + ...capturedHandle, + getSendContext: () => capturedContext!, + hasPendingAttachments: () => Boolean(pendingAttachmentsAtSend), + validateProviderInput: (providerInput: string) => { + const message = getComposerSubmissionValidationMessage({ + prompt: dictatedPrompt, + providerInput, + submissionTarget: "provider-turn", + }); + if (message) + toastManager.add({ + type: "error", + title: "Voice message was not sent", + description: message, + }); + return message === null; + }, + resetCursorState: (...args: Parameters) => { + if (stillHere()) input.composerRef.current?.resetCursorState(...args); + }, + }, + } + : input.composerRef; + if (dictation) { + const text = await dictation; + if (text === null) return; + dictatedPrompt = text; + if ( + !stillHere() && + (activePendingProgress || + queuedRunEdit.isEditing || + parseSideChatCommand(text) || + isUsageLimitsCommand(text)) + ) { + toastManager.add({ + type: "info", + title: "Voice message kept in its draft", + description: "Return to the thread to finish this action.", + }); + return; + } + } + const composerDraftTarget = + dictation && activeThread + ? scopeThreadRef(environmentId, activeThread.id) + : input.composerDraftTarget; + const promoted = + dictation && draftId && useComposerDraftStore.getState().getPromotedDraftRedirect(draftId); + if ( + promoted && + (promoted.environmentId !== environmentId || promoted.threadId !== activeThread?.id) + ) { + toastManager.add({ + type: "info", + title: "Voice message was not sent", + description: "The draft's destination changed. Return to its draft to send again.", + }); + return; + } + const isLocalDraftThread = input.isLocalDraftThread && !promoted; + const isServerThread = input.isServerThread || Boolean(promoted); // What the user pressed send on; uploads below can take a while, and a redirect must not // decline approvals for a draft that changed meanwhile. - const promptAtSend = promptRef.current; + const promptAtSend = dictatedPrompt; if ( usageLimitsOffered && usageLimitsKey !== null && !directAnnotation && !draftHasNonPromptContent(composerDraftTarget) && - isUsageLimitsCommand(promptRef.current) + isUsageLimitsCommand(promptAtSend) ) { if (openUsageLimits()) { - promptRef.current = ""; + if (stillHere()) promptRef.current = ""; setComposerDraftPrompt(composerDraftTarget, ""); composerRef.current?.resetCursorState(); } @@ -407,7 +477,8 @@ export function useComposerSend(input: ComposerSendInput) { isRevertingCheckpoint || !clientSettingsHydrated || threadDetailLoading || - readPendingEnvironmentChange() !== null || + pendingEnvironmentAtSend !== null || + (stillHere() && readPendingEnvironmentChange() !== null) || sendInFlightRef.current || feedbackUploadsInFlightRef.current.has(routeThreadKey) ) { @@ -487,7 +558,7 @@ export function useComposerSend(input: ComposerSendInput) { currentRouteThreadKeyRef.current === routeThreadKey && promptRef.current === promptAtSend ) { - promptRef.current = ""; + if (stillHere()) promptRef.current = ""; setComposerDraftPrompt(composerDraftTarget, ""); composerRef.current?.resetCursorState(); } @@ -561,10 +632,10 @@ export function useComposerSend(input: ComposerSendInput) { // A direct "send annotation" writes the draft and sends in the same tick; the reference // must be in the text now, not after the next render. const promptForSend = directAnnotation - ? ensureInlineContextReferences(promptRef.current, [ + ? ensureInlineContextReferences(promptAtSend, [ previewAnnotationContextReference(directAnnotation.annotation), ]) - : promptRef.current; + : promptAtSend; const strandedThread = threadContextsOutsideEnvironment({ text: promptForSend, records: composerThreadContexts, @@ -647,7 +718,7 @@ export function useComposerSend(input: ComposerSendInput) { createdAt: new Date().toISOString(), }, clearDraft: () => { - promptRef.current = ""; + if (stillHere()) promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); }, @@ -684,7 +755,7 @@ export function useComposerSend(input: ComposerSendInput) { composerEnvironmentContexts.length === 0; if (freshSessionAvailable && composerHoldsOnlyText && isClearComposerCommand(trimmed)) { armFreshSession(routeThreadKey); - promptRef.current = ""; + if (stillHere()) promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); return; @@ -852,6 +923,7 @@ export function useComposerSend(input: ComposerSendInput) { if ( multipleModelSelections === null && + stillHere() && shouldDockDraftHeroForSubmission({ isDraftHeroState, activeThreadKey, submissionIntent }) && activeThreadKey ) { @@ -941,8 +1013,10 @@ export function useComposerSend(input: ComposerSendInput) { const attachmentIds = sendContext ? [...sendContext.images, ...sendContext.files].map((attachment) => attachment.id) : attachmentIdsAtSend; - const promptUnchanged = - promptRef.current === promptAtSend || promptRef.current === promptForSend; + const currentPrompt = dictation + ? useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt + : promptRef.current; + const promptUnchanged = currentPrompt === promptAtSend || currentPrompt === promptForSend; const attachmentsUnchanged = sameIds(attachmentIds, attachmentIdsAtSend) || (annotationImageId !== undefined && @@ -989,7 +1063,7 @@ export function useComposerSend(input: ComposerSendInput) { useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) === draftBeforeDecline ) { - promptRef.current = ""; + if (stillHere()) promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); } @@ -1038,8 +1112,8 @@ export function useComposerSend(input: ComposerSendInput) { } } if (sendQueuesBehindSetup) { - setSetupQueueSendThreadKey(activeThreadKey); - } else { + if (stillHere()) setSetupQueueSendThreadKey(activeThreadKey); + } else if (stillHere()) { beginLocalDispatch({ preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), submissionIntent: @@ -1106,7 +1180,7 @@ export function useComposerSend(input: ComposerSendInput) { } else { optimisticBlobPreviews.release(optimisticMessage); } - setOptimisticUserMessages((existing) => [...existing, optimisticMessage]); + if (stillHere()) setOptimisticUserMessages((existing) => [...existing, optimisticMessage]); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( @@ -1126,7 +1200,7 @@ export function useComposerSend(input: ComposerSendInput) { : null; // Keep new-thread flows from reusing this draft while attachments and worktree setup settle. if (sentDraftThreadRef) markPromotedDraftThreadByRef(sentDraftThreadRef); - promptRef.current = ""; + if (stillHere()) promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -1396,7 +1470,7 @@ export function useComposerSend(input: ComposerSendInput) { setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, ); - if (!sendQueuesBehindSetup) resetLocalDispatch(); + if (!sendQueuesBehindSetup && stillHere()) resetLocalDispatch(); } }; diff --git a/apps/web/src/components/chat/useMultipleModelSend.ts b/apps/web/src/components/chat/useMultipleModelSend.ts index 8ff3bf7fb1392..b1c7c265073d2 100644 --- a/apps/web/src/components/chat/useMultipleModelSend.ts +++ b/apps/web/src/components/chat/useMultipleModelSend.ts @@ -133,9 +133,11 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { text: messageText, attachments: attachmentsSnapshot, }); - promptRef.current = ""; + if (currentRouteThreadKeyRef.current === routeThreadKey) { + promptRef.current = ""; + composerRef.current?.resetCursorState(); + } clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); clearedDraft = true; const clearedDraftSnapshot = useComposerDraftStore .getState() diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx index 17c1312022940..a9e0cb6fbf06e 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx +++ b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx @@ -55,9 +55,10 @@ const mocks = vi.hoisted(() => ({ cleanupOptions: [] as SpeechPostProcessingOptions[], recordingOptions: [] as SpeechTranscriptionOptions[], projectWords: {} as Record, - transcripts: [] as Array, + transcripts: [] as Array>, committed: vi.fn(), draft: "", + drafts: {} as Record, })); vi.mock("../state/session", () => ({ @@ -362,6 +363,91 @@ it.each([ expect(mocks.committed).not.toHaveBeenCalled(); }); +function SubmissionProbe({ owner }: { owner: string }) { + const text = mocks.drafts[owner] ?? ""; + const readDraft = () => ({ + text: mocks.drafts[owner] ?? "", + selection: { start: text.length, end: text.length }, + }); + const value = useEnvironmentSpeechInput({ + environmentId: THREAD_ENV as EnvironmentId, + ownerKey: owner, + draftText: text, + readDraft, + commitDraft: (next) => { + mocks.drafts[owner] = next; + }, + captureSubmissionDraft: () => ({ + readDraft, + commitDraft: (next: string) => { + mocks.drafts[owner] = next; + }, + }), + }); + useLayoutEffect(() => { + mocks.voice = value; + }); + return null; +} + +it("finishes a confirmed dictation send after unmount", async () => { + setup({ destination: "env-a" }); + let finish!: (text: string) => void; + mocks.transcripts = [ + new Promise((resolve) => { + finish = resolve; + }), + ]; + const drafts = { "thread-a": "Fix", "thread-b": "Do not send" }; + mocks.drafts = drafts; + const submit = vi.fn(); + await using screen = await render(); + await act(() => mocks.voice.start()); + await act(() => mocks.voice.submitAfterDictation(submit)); + await vi.waitFor(() => expect(mocks.transcribeCalls).toEqual(["env-a"])); + await screen.rerender(
); + await act(async () => { + finish("the login bug"); + }); + await vi.waitFor(() => expect(submit).toHaveBeenCalledExactlyOnceWith("Fix the login bug")); + expect(drafts).toEqual({ "thread-a": "Fix the login bug", "thread-b": "Do not send" }); +}); + +it.each(["cancel", "off", "destination", "cleanup", "edit"])( + "does not send a confirmed recording after %s", + async (action) => { + setup({ destination: "env-a" }); + if (action === "cleanup") chooseTranscriptCleanup(true); + let finish!: (text: string) => void; + mocks.transcripts = [ + new Promise((resolve) => { + finish = resolve; + }), + ]; + const drafts = { "thread-a": "Fix" }; + mocks.drafts = drafts; + const submit = vi.fn(); + const cancel = vi.fn(); + await using screen = await render(); + await act(() => mocks.voice.start()); + await act(() => mocks.voice.submitAfterDictation(submit, cancel)); + await vi.waitFor(() => expect(mocks.transcribeCalls).toEqual(["env-a"])); + if (action === "cancel") await act(() => mocks.voice.cancel()); + else if (action === "edit") drafts["thread-a"] = "A later edit"; + else { + await screen.rerender(
); + if (action === "cleanup") chooseTranscriptCleanup(false); + else setDestination(action === "off" ? null : "env-b"); + } + await act(async () => { + finish("the login bug"); + }); + await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()); + expect(submit).not.toHaveBeenCalled(); + expect(drafts["thread-a"]).toBe(action === "edit" ? "A later edit" : "Fix"); + }, +); + it("retries a failed transcription on the same chosen environment", async () => { setup({ destination: "env-a" }); mocks.transcripts = [new Error("inference failed"), "second try"]; diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.ts b/apps/web/src/speech/useEnvironmentSpeechInput.ts index c569c14500be6..3f13ff9eec816 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.ts +++ b/apps/web/src/speech/useEnvironmentSpeechInput.ts @@ -84,6 +84,11 @@ function cleanupContext({ text, selection }: DraftInput): DraftInput { }; } +type SubmissionDraft = { + readonly readDraft: () => DraftInput | null; + readonly commitDraft: HookInput["commitDraft"]; +}; + type HookInput = { /** The draft's own environment. Optional transcript cleanup runs there, never on the audio destination. */ readonly environmentId: EnvironmentId; @@ -100,6 +105,8 @@ type HookInput = { readonly offerSetup?: boolean | undefined; /** False keeps the transcript off the cleanup provider even when cleanup is on; the model test uses it. */ readonly cleanup?: boolean | undefined; + /** Binds a confirmed send to a draft that can be reached after this editor leaves. */ + readonly captureSubmissionDraft?: (() => SubmissionDraft) | undefined; readonly readDraft: () => DraftInput; readonly commitDraft: ( text: string, @@ -171,6 +178,7 @@ export function useEnvironmentSpeechInput(input: HookInput) { prepared, grantId, value: INITIAL_STATE, + ownerKey: input.ownerKey, }); const [queuedStart, setQueuedStart] = useState(null); const [level, setLevel] = useState(0); @@ -181,7 +189,7 @@ export function useEnvironmentSpeechInput(input: HookInput) { disabled: input.disabled, }); if (controllerState.prepared !== prepared || controllerState.grantId !== grantId) { - setControllerState({ prepared, grantId, value: INITIAL_STATE }); + setControllerState({ prepared, grantId, value: INITIAL_STATE, ownerKey: input.ownerKey }); setSetup(INITIAL_SETUP); setQueuedStart(null); setPreview(null); @@ -195,7 +203,7 @@ export function useEnvironmentSpeechInput(input: HookInput) { const state: VoiceInputState = queuedStart === prepared && prepared ? WAITING_STATE - : controllerState.prepared === prepared + : controllerState.prepared === prepared && controllerState.ownerKey === input.ownerKey ? controllerState.value : INITIAL_STATE; @@ -205,7 +213,24 @@ export function useEnvironmentSpeechInput(input: HookInput) { ownerKey: string; submit: (text: string) => void; text?: string; + cancel?: (() => void) | undefined; + draft?: SubmissionDraft | undefined; + snapshot?: VoiceDraftSnapshot | undefined; } | null>(null); + const cancelSubmission = useStableCallback(() => { + const pending = pendingSubmissionRef.current; + pendingSubmissionRef.current = null; + pending?.cancel?.(); + }); + const lastSnapshotRef = useRef(null); + const mountedRef = useRef(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- distinguishes composer unmount from a changed destination in the controller cleanup + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); const latestRef = useRef({ input, clientSettings, cleanupPrepared, projectWords }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes committed props and settings to the asynchronous recording controller useEffect(() => { @@ -235,10 +260,16 @@ export function useEnvironmentSpeechInput(input: HookInput) { readonly cleanup: SpeechPostProcessingOptions | null; readonly cleanupGrant: string | null; readonly environmentId: EnvironmentId; + readonly target: PreparedConnection | null; } | undefined; - const readDraft = (): VoiceDraftSnapshot => { + const readDraft = (): VoiceDraftSnapshot | null => { + const pending = pendingSubmissionRef.current; + if (pending?.draft && pending.snapshot) { + const draft = pending.draft.readDraft(); + return draft && { ...pending.snapshot, text: draft.text }; + } const current = latestRef.current.input; const draft = current.readDraft(); const revision = draftRevisionRef.current; @@ -249,12 +280,13 @@ export function useEnvironmentSpeechInput(input: HookInput) { revision: revision.revision + 1, }; } - return { + lastSnapshotRef.current = { ownerKey: current.ownerKey, text: draft.text, selection: draft.selection, revision: draftRevisionRef.current.revision, }; + return lastSnapshotRef.current; }; const platform = createBrowserVoiceInputPlatform({ prepared, @@ -279,6 +311,7 @@ export function useEnvironmentSpeechInput(input: HookInput) { recordingPreferences = { cleanupGrant, environmentId: current.environmentId, + target: latestRef.current.cleanupPrepared, cleanup: cleanupEnabled ? { speechCustomWords, @@ -311,14 +344,17 @@ export function useEnvironmentSpeechInput(input: HookInput) { options, ) => { const preferences = recordingPreferences; - const { input: current, cleanupPrepared: target } = latestRef.current; + const { input: current, cleanupPrepared } = latestRef.current; + const submission = pendingSubmissionRef.current; + const target = submission?.draft ? preferences?.target : cleanupPrepared; if ( !preferences?.cleanup || !hasAudioConsent() || preferences.cleanupGrant !== readCleanupGrant() || - current.cleanup === false || - current.environmentId !== preferences.environmentId || - current.ownerKey !== options.draft.ownerKey + (!submission?.draft && + (current.cleanup === false || + current.environmentId !== preferences.environmentId || + current.ownerKey !== options.draft.ownerKey)) ) return null; if (!target) @@ -353,27 +389,65 @@ export function useEnvironmentSpeechInput(input: HookInput) { }), readDraft, commitDraft: (text, selection) => { - if (latestRef.current.input.disabled) return; - latestRef.current.input.commitDraft(text, selection); - if (pendingSubmissionRef.current) pendingSubmissionRef.current.text = text; + const pending = pendingSubmissionRef.current; + if (pending?.draft) { + if ( + !hasAudioConsent() || + (recordingPreferences?.cleanupGrant && + recordingPreferences.cleanupGrant !== readCleanupGrant()) + ) { + cancelSubmission(); + return; + } + pending.draft.commitDraft(text, selection); + } else { + if (latestRef.current.input.disabled) return; + latestRef.current.input.commitDraft(text, selection); + } + if (pending) pending.text = text; }, onStateChange: (value) => { - if (disposed) return; - if (value.phase === "error") pendingSubmissionRef.current = null; - setControllerState({ prepared, grantId, value }); + const pending = pendingSubmissionRef.current; + if (value.phase === "error") { + cancelSubmission(); + if (pending?.draft) { + toastManager.add({ + type: "error", + title: "Voice message was not sent", + description: value.error, + }); + } + } + if (value.phase === "idle" && pending?.draft) { + pendingSubmissionRef.current = null; + if (pending.text !== undefined) pending.submit(pending.text); + else pending.cancel?.(); + } + if (disposed) { + if (value.phase === "error") controller.dispose(); + return; + } + setControllerState({ + prepared, + grantId, + value, + ownerKey: lastSnapshotRef.current?.ownerKey ?? latestRef.current.input.ownerKey, + }); if (value.phase !== "recording" && value.phase !== "transcribing") setPreview(null); }, }); controllerRef.current = controller; return () => { disposed = true; - pendingSubmissionRef.current = null; startRequestRef.current += 1; setQueuedStart(null); - controller.dispose(); + if (mountedRef.current || !pendingSubmissionRef.current?.draft) { + cancelSubmission(); + controller.dispose(); + } if (controllerRef.current === controller) controllerRef.current = null; }; - }, [prepared, grantId, cloudProvider, cloudModel]); + }, [prepared, grantId, cloudProvider, cloudModel, cancelSubmission]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- probes the chosen environment's speech status once its connection is available useEffect(() => { @@ -419,35 +493,49 @@ export function useEnvironmentSpeechInput(input: HookInput) { }, [prepared, setup.downloading, setup.open, setupModelId]); const previousOwnerRef = useRef(input.ownerKey); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- abandons the recording and any queued submission when the draft's owner changes + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels unsent dictation on navigation while a captured submission keeps its original draft useEffect(() => { if (previousOwnerRef.current === input.ownerKey && !input.disabled) return; previousOwnerRef.current = input.ownerKey; - pendingSubmissionRef.current = null; startRequestRef.current += 1; + const pending = pendingSubmissionRef.current; + if (pending?.draft && (pending.ownerKey !== input.ownerKey || !input.disabled)) return; + cancelSubmission(); controllerRef.current?.ownerChanged(); - }, [input.ownerKey, input.disabled]); + }, [input.ownerKey, input.disabled, cancelSubmission]); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- delivers a queued submission only after the transcript has committed to the draft + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- editor-owned submissions wait for React to commit the inserted transcript useEffect(() => { const pending = pendingSubmissionRef.current; - if (state.phase !== "idle" || !pending) return; + if (state.phase !== "idle" || !pending || pending.draft) return; pendingSubmissionRef.current = null; if (!input.disabled && pending.ownerKey === input.ownerKey && pending.text !== undefined) pending.submit(pending.text); + else pending.cancel?.(); }, [state, input.ownerKey, input.disabled]); - const submitAfterDictation = useStableCallback((submit: (text: string) => void) => { - if (latestRef.current.input.disabled || state.phase === "preparing") return; - const controller = controllerRef.current; - if (!controller || !voiceInputBlocksSubmission(state)) { - submit(latestRef.current.input.readDraft().text); - return; - } - if (pendingSubmissionRef.current) return; - pendingSubmissionRef.current = { ownerKey: latestRef.current.input.ownerKey, submit }; - if (controller.currentState.phase === "recording") void controller.stop(); - }); + const submitAfterDictation = useStableCallback( + (submit: (text: string) => void, cancel?: () => void) => { + const current = latestRef.current.input; + if (current.disabled || state.phase === "preparing" || pendingSubmissionRef.current) { + cancel?.(); + return; + } + const controller = controllerRef.current; + if (!controller || !voiceInputBlocksSubmission(state)) { + submit(current.readDraft().text); + return; + } + pendingSubmissionRef.current = { + ownerKey: current.ownerKey, + submit, + cancel, + draft: current.captureSubmissionDraft?.(), + snapshot: lastSnapshotRef.current ?? undefined, + }; + if (controller.currentState.phase === "recording") void controller.stop(); + }, + ); const loadSetupCatalog = useStableCallback( async (connection: PreparedConnection, selectedModelId: string, request: number) => { @@ -477,6 +565,10 @@ export function useEnvironmentSpeechInput(input: HookInput) { const start = useStableCallback(async () => { if (latestRef.current.input.disabled || (setup.open && setup.downloading)) return; + if (pendingSubmissionRef.current) { + toastManager.add({ type: "info", title: "Finishing the previous voice message" }); + return; + } if (!grantId) { if (destination.offerFirstRunSetup && latestRef.current.input.offerSetup !== false) await navigate({ to: "/settings/voice", hash: "voice-transcription-destination" }); @@ -629,7 +721,9 @@ export function useEnvironmentSpeechInput(input: HookInput) { submitAfterDictation, stop: useStableCallback(() => controllerRef.current?.stop() ?? Promise.resolve()), cancel: useStableCallback(() => { - pendingSubmissionRef.current = null; + const pending = pendingSubmissionRef.current; + if (pending?.draft && pending.ownerKey !== latestRef.current.input.ownerKey) return; + cancelSubmission(); startRequestRef.current += 1; setQueuedStart(null); setSetup(INITIAL_SETUP); diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index dd3650519eab4..68b8930e131fc 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -59,7 +59,7 @@ const descriptor = { // A matching version, since a mismatch adds an update banner a typical user never sees. serverVersion: APP_VERSION, orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, - capabilities: { repositoryIdentity: true }, + capabilities: { repositoryIdentity: true, voiceTranscription: true }, }; const auth = { policy: "loopback-browser", From a549191013dfb334fb17597b51817c1206aeadcd Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 10 Nov 2026 00:33:48 +0530 Subject: [PATCH 2/7] fix(web): preserve dictation edits and attachment completion --- apps/web/src/components/chat/ChatComposer.tsx | 53 +++++++++++++------ .../src/components/chat/useComposerSend.ts | 9 ++-- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 53b50b45e9cac..dfa60e95c89f1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1795,20 +1795,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ownerKey: questionAttachmentTarget ?? composerDraftTargetKey, draftText: prompt, disabled: dictationDisabled, - captureSubmissionDraft: questionAttachmentTarget - ? undefined - : () => { - const target = routeThreadRef; - const captured = getComposerDraft(target); - return { - readDraft: () => { - if (getComposerDraft(target) !== captured) return null; - const text = captured?.prompt ?? ""; - return { text, selection: { start: text.length, end: text.length } }; - }, - commitDraft: (text) => setComposerDraftPrompt(target, text), - }; - }, + captureSubmissionDraft: + questionAttachmentTarget || isEditingQueuedMessage + ? undefined + : () => { + const target = routeThreadRef; + const captured = getComposerDraft(target); + return { + readDraft: () => { + const current = getComposerDraft(target); + if ( + (current?.prompt ?? "") !== (captured?.prompt ?? "") || + (current?.images.length ?? 0) !== (captured?.images.length ?? 0) || + current?.images.some( + (image, index) => image.id !== captured?.images[index]?.id, + ) || + (current?.files.length ?? 0) !== (captured?.files.length ?? 0) || + current?.files.some((file, index) => file.id !== captured?.files[index]?.id) + ) + return null; + const text = current?.prompt ?? ""; + return { text, selection: { start: text.length, end: text.length } }; + }, + commitDraft: (text) => setComposerDraftPrompt(target, text), + }; + }, readDraft: () => { const snapshot = readComposerSnapshot(); return { @@ -2138,7 +2149,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } const transcript = new Promise((resolve) => { - submitAfterDictation(resolve, () => resolve(null)); + submitAfterDictation( + (text) => { + resolve(text); + if ( + composerDraftTargetKeyRef.current === composerDraftTargetKey && + composerFormRef.current?.isConnected + ) { + onTypingGuardSend(); + if (shouldBlurMobileComposerOnSubmit()) blurMobileComposerAfterSend(); + } + }, + () => resolve(null), + ); }); onSend( undefined, diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index d0bb9d5bb5dd8..739aaccb5a82c 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -410,10 +410,7 @@ export function useComposerSend(input: ComposerSendInput) { dictatedPrompt = text; if ( !stillHere() && - (activePendingProgress || - queuedRunEdit.isEditing || - parseSideChatCommand(text) || - isUsageLimitsCommand(text)) + (queuedRunEdit.isEditing || parseSideChatCommand(text) || isUsageLimitsCommand(text)) ) { toastManager.add({ type: "info", @@ -1112,7 +1109,7 @@ export function useComposerSend(input: ComposerSendInput) { } } if (sendQueuesBehindSetup) { - if (stillHere()) setSetupQueueSendThreadKey(activeThreadKey); + setSetupQueueSendThreadKey(activeThreadKey); } else if (stillHere()) { beginLocalDispatch({ preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree), @@ -1299,7 +1296,7 @@ export function useComposerSend(input: ComposerSendInput) { } : undefined; const backgroundThreadRef = - submissionIntent === "background" && isLocalDraftThread + submissionIntent === "background" && isLocalDraftThread && stillHere() ? scopeThreadRef(environmentId, threadIdForSend) : null; if (backgroundThreadRef) beginBackgroundDraftSubmissionByRef(backgroundThreadRef); From 64e064a9b90066a07d31b84c5a3232733d553dc7 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 10 Nov 2026 00:47:52 +0530 Subject: [PATCH 3/7] fix(web): reattach confirmed voice submissions after remount --- .../ChatView.dictation.browser.test.tsx | 255 ++++++++++++------ apps/web/src/components/chat/ChatComposer.tsx | 4 +- ...useEnvironmentSpeechInput.browser.test.tsx | 132 +++++++-- .../src/speech/useEnvironmentSpeechInput.ts | 144 ++++++++-- apps/web/src/speech/voiceConsent.ts | 21 ++ apps/web/test/fakeEnvironment.ts | 8 +- 6 files changed, 427 insertions(+), 137 deletions(-) diff --git a/apps/web/src/components/ChatView.dictation.browser.test.tsx b/apps/web/src/components/ChatView.dictation.browser.test.tsx index f595c90aa3416..a74c91e918375 100644 --- a/apps/web/src/components/ChatView.dictation.browser.test.tsx +++ b/apps/web/src/components/ChatView.dictation.browser.test.tsx @@ -33,86 +33,128 @@ vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ trackRpcRequestSent: () => {}, })); -it.each(["transcription", "cleanup"])( - "sends a confirmed voice message once to its original thread after navigation during %s", - async (stage) => { - const a = threadWithHistory("dictation-a", 1); - const b = threadWithHistory("dictation-b", 1); - using _environment = installFakeEnvironment([a, b], { acceptCommands: true }); - __setClientSettingsForTests({ - ...DEFAULT_CLIENT_SETTINGS, - onboardingCompletedAt: "2026-01-01T00:00:00.000Z", - }); - chooseAudioDestination({ kind: "environment", environmentId: ENVIRONMENT_ID }); - chooseTranscriptCleanup(stage === "cleanup"); - const aRef = scopeThreadRef(ENVIRONMENT_ID, a.id); - const bRef = scopeThreadRef(ENVIRONMENT_ID, b.id); - useComposerDraftStore.getState().setPrompt(aRef, "Fix"); - useComposerDraftStore.getState().setPrompt(bRef, "Do not send"); - let resolveResponse!: (response: Response) => void; - const response = new Promise((resolve) => { - resolveResponse = resolve; - }); - const fetch = globalThis.fetch; - using _fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { - const url = new URL( - input instanceof Request ? input.url : String(input), - window.location.href, - ); - if (url.pathname === "/api/voice/status") - return Response.json({ - supported: true, - state: "ready", - modelId: "test-model", - model: "Test", - size: 1, - supportsStreaming: false, - supportsTranslation: false, - acceleration: "auto", - modelUnloadTimeout: "min_15", - gpuDevices: [], - }); - if (url.pathname === "/api/voice/transcribe") - return stage === "transcription" ? response : Response.json({ text: "the login bug" }); - if (url.pathname === "/api/voice/post-process") return response; - return fetch(input, init); - }); - const router = getRouter( - createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${a.id}`] }), - ); - await router.load(); - await using _app = await render(, { - // AppRoot supplies its own router. - router: false, - }); - const audio = new AudioContext(); - const oscillator = audio.createOscillator(); - const destination = audio.createMediaStreamDestination(); - oscillator.connect(destination); - oscillator.start(); - using _audio = { - [Symbol.dispose]: () => { - oscillator.stop(); - void audio.close(); - }, - }; - using _microphone = vi - .spyOn(navigator.mediaDevices, "getUserMedia") - .mockResolvedValue(destination.stream); - await page.getByRole("button", { name: "Start voice input", exact: true }).click(); - await expect - .element(page.getByRole("button", { name: "Cancel voice input", exact: true })) - .toBeVisible(); - await page.getByRole("button", { name: "Submit message", exact: true }).click(); - await vi.waitFor(() => - expect( - _fetch.mock.calls.some(([input]) => - String(input instanceof Request ? input.url : input).includes( - stage === "transcription" ? "/api/voice/transcribe" : "/api/voice/post-process", - ), +it.each([ + ["transcription", "navigation"], + ["cleanup", "navigation"], + ["transcription", "queued-different"], + ["transcription", "queued-equal"], + ["transcription", "upload"], + ["transcription", "attachment-change"], + ["transcription", "prompt-change"], +])("keeps a confirmed voice submission owned by its draft: %s / %s", async (stage, scenario) => { + const normal = threadWithHistory("dictation-a", 1); + const b = threadWithHistory("dictation-b", 1); + const template = threadWithHistory("dictation-queued", 1); + const queued = { + ...template, + runs: template.runs.map((run) => ({ + ...run, + status: "queued" as const, + startedAt: null, + completedAt: null, + })), + }; + const editing = scenario.startsWith("queued-"); + const a = editing ? queued : normal; + using _environment = installFakeEnvironment([normal, b, queued], { acceptCommands: true }); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + chooseAudioDestination({ kind: "environment", environmentId: ENVIRONMENT_ID }); + chooseTranscriptCleanup(stage === "cleanup"); + const aRef = scopeThreadRef(ENVIRONMENT_ID, a.id); + const bRef = scopeThreadRef(ENVIRONMENT_ID, b.id); + const mainPrompt = scenario === "queued-equal" ? "Queued message" : "Fix"; + useComposerDraftStore.getState().setPrompt(aRef, mainPrompt); + const file = { + type: "file" as const, + id: `dictation-file-${scenario}`, + name: "report.txt", + mimeType: "text/plain", + sizeBytes: 1, + file: null, + uploadedAttachmentId: `uploaded-${scenario}`, + uploadEnvironmentId: ENVIRONMENT_ID, + }; + const fileScenario = ["upload", "attachment-change", "prompt-change"].includes(scenario); + if (fileScenario) useComposerDraftStore.getState().addFiles(aRef, [file]); + useComposerDraftStore.getState().setPrompt(bRef, "Do not send"); + let resolveResponse!: (response: Response) => void; + const response = new Promise((resolve) => { + resolveResponse = resolve; + }); + await using _response = { + [Symbol.asyncDispose]: () => + act(async () => { + resolveResponse(Response.json({ text: "the login bug" })); + }), + }; + const fetch = globalThis.fetch; + using _fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), window.location.href); + if (url.pathname === "/api/voice/status") + return Response.json({ + supported: true, + state: "ready", + modelId: "test-model", + model: "Test", + size: 1, + supportsStreaming: false, + supportsTranslation: false, + acceleration: "auto", + modelUnloadTimeout: "min_15", + gpuDevices: [], + }); + if (url.pathname === "/api/voice/transcribe") + return stage === "transcription" ? response : Response.json({ text: "the login bug" }); + if (url.pathname === "/api/voice/post-process") return response; + return fetch(input, init); + }); + const router = getRouter(createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${a.id}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot supplies its own router. + router: false, + }); + const audio = new AudioContext(); + const oscillator = audio.createOscillator(); + const destination = audio.createMediaStreamDestination(); + oscillator.connect(destination); + oscillator.start(); + using _audio = { + [Symbol.dispose]: () => { + oscillator.stop(); + void audio.close(); + }, + }; + using _microphone = vi + .spyOn(navigator.mediaDevices, "getUserMedia") + .mockResolvedValue(destination.stream); + if (editing) { + await page.getByRole("button", { name: "Edit queued message", exact: true }).click(); + await expect.element(page.getByTestId("composer-editor")).toHaveTextContent("Queued message"); + } + await page.getByRole("button", { name: "Start voice input", exact: true }).click(); + await expect + .element(page.getByRole("button", { name: "Cancel voice input", exact: true })) + .toBeVisible(); + await page + .getByRole("button", { + name: editing ? "Update queued message" : "Submit message", + exact: true, + }) + .click(); + await vi.waitFor(() => + expect( + _fetch.mock.calls.some(([input]) => + String(input instanceof Request ? input.url : input).includes( + stage === "transcription" ? "/api/voice/transcribe" : "/api/voice/post-process", ), - ).toBe(true), - ); + ), + ).toBe(true), + ); + if (!editing) { await act(() => router.navigate({ to: "/$environmentId/$threadId", @@ -120,18 +162,53 @@ it.each(["transcription", "cleanup"])( }), ); await expect.element(page.getByTestId("composer-editor")).toHaveTextContent("Do not send"); - resolveResponse(Response.json({ text: "the login bug" })); + } + if (fileScenario) { + const before = useComposerDraftStore.getState().getComposerDraft(aRef); + await act(() => { + if (scenario === "attachment-change") { + useComposerDraftStore.getState().removeFile(aRef, file.id); + useComposerDraftStore + .getState() + .addFiles(aRef, [{ ...file, id: `${file.id}-replacement` }]); + } else if (scenario === "prompt-change") + useComposerDraftStore.getState().setPrompt(aRef, "Later text"); + else + useComposerDraftStore + .getState() + .setFileUpload(aRef, file.id, ENVIRONMENT_ID, "completed-upload"); + }); + expect(useComposerDraftStore.getState().getComposerDraft(aRef)).not.toBe(before); + } + resolveResponse(Response.json({ text: "the login bug" })); + if (scenario === "attachment-change" || scenario === "prompt-change") { + await expect + .element(page.getByText("Voice message was not sent", { exact: true })) + .toBeVisible(); + expect(dispatchedCommands().filter((command) => command.type === "message.dispatch")).toEqual( + [], + ); + expect(useComposerDraftStore.getState().getComposerDraft(aRef)?.prompt).toBe( + scenario === "prompt-change" ? "Later text" : "Fix", + ); + } else if (editing) { + await vi.waitFor(() => + expect(dispatchedCommands().filter((command) => command.type === "queued-run.edit")).toEqual([ + expect.objectContaining({ threadId: a.id, text: "Queued message the login bug" }), + ]), + ); + expect(dispatchedCommands().filter((command) => command.type === "message.dispatch")).toEqual( + [], + ); + expect(useComposerDraftStore.getState().getComposerDraft(aRef)?.prompt).toBe(mainPrompt); + } else { await vi.waitFor(() => expect(dispatchedCommands().filter((command) => command.type === "message.dispatch")).toEqual( - [ - expect.objectContaining({ - threadId: a.id, - text: "Fix the login bug", - }), - ], + [expect.objectContaining({ threadId: a.id, text: "Fix the login bug" })], ), ); - expect(useComposerDraftStore.getState().getComposerDraft(bRef)?.prompt).toBe("Do not send"); + } + expect(useComposerDraftStore.getState().getComposerDraft(bRef)?.prompt).toBe("Do not send"); + if (!editing) await expect.element(page.getByTestId("composer-editor")).toHaveTextContent("Do not send"); - }, -); +}); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index dfa60e95c89f1..8211b152d0288 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1792,7 +1792,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, projectId: activeProjectId, projectName, - ownerKey: questionAttachmentTarget ?? composerDraftTargetKey, + ownerKey: + questionAttachmentTarget ?? + (isEditingQueuedMessage ? composerDraftTargetKey : composerTargetKey(routeThreadRef)), draftText: prompt, disabled: dictationDisabled, captureSubmissionDraft: diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx index a9e0cb6fbf06e..34cdf948ee0ae 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx +++ b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx @@ -51,6 +51,7 @@ const mocks = vi.hoisted(() => ({ microphoneRequests: [] as string[], transcribeCalls: [] as string[], cleanupCalls: [] as string[], + cleanupResult: undefined as Promise | undefined, cleanupDraft: vi.fn(), cleanupOptions: [] as SpeechPostProcessingOptions[], recordingOptions: [] as SpeechTranscriptionOptions[], @@ -156,11 +157,11 @@ vi.mock("@t3tools/client-runtime/voice-input", async (importOriginal) => ({ draft: unknown, options: SpeechPostProcessingOptions, ) => - Effect.sync(() => { + Effect.promise(async () => { mocks.cleanupCalls.push(prepared.environmentId); mocks.cleanupDraft(draft); mocks.cleanupOptions.push(options); - return { text: `Cleaned ${transcript}` }; + return { text: mocks.cleanupResult ? await mocks.cleanupResult : `Cleaned ${transcript}` }; }), })); vi.mock("./browserVoiceInput", () => ({ @@ -228,6 +229,7 @@ function setup(options: { readonly destination?: string | null } = {}) { mocks.microphoneRequests = []; mocks.transcribeCalls = []; mocks.cleanupCalls = []; + mocks.cleanupResult = undefined; mocks.cleanupDraft.mockClear(); mocks.cleanupOptions = []; mocks.recordingOptions = []; @@ -363,14 +365,20 @@ it.each([ expect(mocks.committed).not.toHaveBeenCalled(); }); -function SubmissionProbe({ owner }: { owner: string }) { +function SubmissionProbe({ + owner, + environmentId = THREAD_ENV, +}: { + owner: string; + environmentId?: string; +}) { const text = mocks.drafts[owner] ?? ""; const readDraft = () => ({ text: mocks.drafts[owner] ?? "", selection: { start: text.length, end: text.length }, }); const value = useEnvironmentSpeechInput({ - environmentId: THREAD_ENV as EnvironmentId, + environmentId: environmentId as EnvironmentId, ownerKey: owner, draftText: text, readDraft, @@ -390,38 +398,90 @@ function SubmissionProbe({ owner }: { owner: string }) { return null; } -it("finishes a confirmed dictation send after unmount", async () => { - setup({ destination: "env-a" }); - let finish!: (text: string) => void; - mocks.transcripts = [ - new Promise((resolve) => { +it.each([ + ["transcribing", "finish"], + ["post-processing", "finish"], + ["transcribing", "cancel"], + ["post-processing", "cancel"], + ["post-processing", "skip"], +] as const)( + "reattaches a confirmed %s submission after remount to %s it", + async (phase, action) => { + setup({ destination: "env-a" }); + let finish!: (text: string) => void; + const result = new Promise((resolve) => { finish = resolve; - }), - ]; - const drafts = { "thread-a": "Fix", "thread-b": "Do not send" }; - mocks.drafts = drafts; - const submit = vi.fn(); - await using screen = await render(); - await act(() => mocks.voice.start()); - await act(() => mocks.voice.submitAfterDictation(submit)); - await vi.waitFor(() => expect(mocks.transcribeCalls).toEqual(["env-a"])); - await screen.rerender(
); - await act(async () => { - finish("the login bug"); - }); - await vi.waitFor(() => expect(submit).toHaveBeenCalledExactlyOnceWith("Fix the login bug")); - expect(drafts).toEqual({ "thread-a": "Fix the login bug", "thread-b": "Do not send" }); -}); + }); + if (phase === "post-processing") { + chooseTranscriptCleanup(true); + mocks.cleanupResult = result; + } else mocks.transcripts = [result]; + await using _response = { + [Symbol.asyncDispose]: () => + act(async () => { + finish("the login bug"); + }), + }; + mocks.drafts = { "thread-a": "Fix", "thread-b": "Do not send" }; + const submit = vi.fn(); + const cancel = vi.fn(); + await using screen = await render(); + await act(() => mocks.voice.start()); + await act(() => mocks.voice.submitAfterDictation(submit, cancel)); + await vi.waitFor(() => expect(mocks.voice.state.phase).toBe(phase)); + await screen.rerender(
); + await screen.rerender(); + expect(mocks.voice.state.phase).toBe("idle"); + await act(() => mocks.voice.cancel()); + expect(cancel).not.toHaveBeenCalled(); + await act(() => mocks.voice.start()); + expect(mocks.voice.state.phase).toBe("idle"); + expect(mocks.microphoneRequests).toEqual(["env-a"]); + await screen.rerender(
); + await screen.rerender(); + expect(mocks.voice.state.phase).toBe("idle"); + await act(() => mocks.voice.cancel()); + expect(cancel).not.toHaveBeenCalled(); + await screen.rerender(
); + await screen.rerender(); + expect(mocks.voice.state.phase).toBe(phase); + expect(mocks.voice.freezesEditor).toBe(true); + expect(mocks.voice.blocksSubmission).toBe(true); + const duplicate = vi.fn(); + await act(() => mocks.voice.submitAfterDictation(duplicate)); + expect(duplicate).not.toHaveBeenCalled(); + if (action === "cancel") await act(() => mocks.voice.cancel()); + if (action === "skip") await act(() => mocks.voice.skipPostProcessing()); + await act(async () => { + finish("the login bug"); + }); + await vi.waitFor(() => expect(mocks.voice.state.phase).toBe("idle")); + if (action === "cancel") { + expect(cancel).toHaveBeenCalledOnce(); + expect(submit).not.toHaveBeenCalled(); + expect(mocks.drafts["thread-a"]).toBe("Fix"); + } else { + const expected = action === "skip" ? "Fix dictated" : "Fix the login bug"; + await vi.waitFor(() => expect(submit).toHaveBeenCalledExactlyOnceWith(expected)); + expect(mocks.drafts["thread-a"]).toBe(expected); + expect(cancel).not.toHaveBeenCalled(); + } + expect(duplicate).not.toHaveBeenCalled(); + expect(mocks.drafts["thread-b"]).toBe("Do not send"); + }, +); -it.each(["cancel", "off", "destination", "cleanup", "edit"])( +it.each(["cancel", "off", "destination", "cloud-destination", "cleanup", "edit", "error"])( "does not send a confirmed recording after %s", async (action) => { setup({ destination: "env-a" }); if (action === "cleanup") chooseTranscriptCleanup(true); let finish!: (text: string) => void; + let fail!: (error: Error) => void; mocks.transcripts = [ - new Promise((resolve) => { + new Promise((resolve, reject) => { finish = resolve; + fail = reject; }), ]; const drafts = { "thread-a": "Fix" }; @@ -437,14 +497,28 @@ it.each(["cancel", "off", "destination", "cleanup", "edit"])( else { await screen.rerender(
); if (action === "cleanup") chooseTranscriptCleanup(false); - else setDestination(action === "off" ? null : "env-b"); + else if (action === "cloud-destination") { + const saved = pendingCloudAudioChoice({ + kind: "cloud", + provider: "openai", + environmentId: EnvironmentId.make("env-b"), + }); + expect(cancel).not.toHaveBeenCalled(); + saved(); + } else if (action !== "error") setDestination(action === "off" ? null : "env-b"); + if (action !== "error") expect(cancel).toHaveBeenCalledOnce(); } await act(async () => { - finish("the login bug"); + if (action === "error") fail(new Error("Transcription failed")); + else finish("the login bug"); }); await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()); expect(submit).not.toHaveBeenCalled(); expect(drafts["thread-a"]).toBe(action === "edit" ? "A later edit" : "Fix"); + await screen.rerender(
); + await screen.rerender(); + expect(mocks.voice.state.phase).toBe("idle"); + expect(mocks.voice.blocksSubmission).toBe(false); }, ); diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.ts b/apps/web/src/speech/useEnvironmentSpeechInput.ts index 3f13ff9eec816..7162a0ee3822d 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.ts +++ b/apps/web/src/speech/useEnvironmentSpeechInput.ts @@ -26,7 +26,7 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { chooseSetupSpeechModel, resolveSpeechVocabulary } from "@t3tools/shared/speech"; import { useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useStableCallback } from "~/hooks/useStableCallback"; import { toastManager } from "../components/ui/toast"; @@ -40,7 +40,12 @@ import { useEnvironmentScope, usePreparedConnection } from "../state/session"; import { createBrowserVoiceInputPlatform } from "./browserVoiceInput"; import { preferredSpeechLanguages } from "./preferredSpeechLanguages"; import { runEnvironmentSpeech } from "./runEnvironmentSpeech"; -import { bindAudioGrant, readCleanupGrant, useAudioChoice } from "./voiceConsent"; +import { + bindAudioGrant, + readCleanupGrant, + subscribeVoiceConsent, + useAudioChoice, +} from "./voiceConsent"; const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; const WAITING_STATE: VoiceInputState = { phase: "preparing", error: null, errorAction: null }; @@ -114,6 +119,49 @@ type HookInput = { ) => void; }; +type PendingSubmission = { + readonly environmentId: EnvironmentId; + readonly ownerKey: string; + readonly submit: (text: string) => void; + readonly cancel?: (() => void) | undefined; + readonly draft?: SubmissionDraft | undefined; + readonly snapshot?: VoiceDraftSnapshot | undefined; + text?: string; +}; + +type ConfirmedSubmission = { + readonly pending: PendingSubmission; + readonly controller: VoiceInputController; + readonly prepared: PreparedConnection; + readonly grantId: string; + readonly state: VoiceInputState; + readonly cancel: () => void; + unsubscribeConsent: () => void; +}; + +// A confirmed send outlives its editor; remounts need its phase and cancellation, not its controller lifecycle. +const confirmedSubmissions = new Map(); +const submissionListeners = new Set<() => void>(); +const submissionKey = (environmentId: EnvironmentId, ownerKey: string) => + JSON.stringify([environmentId, ownerKey]); +const subscribeSubmissions = (notify: () => void) => { + submissionListeners.add(notify); + return () => { + submissionListeners.delete(notify); + }; +}; +const notifySubmissions = () => { + for (const notify of submissionListeners) notify(); +}; +function clearConfirmedSubmission(pending: PendingSubmission) { + const key = submissionKey(pending.environmentId, pending.ownerKey); + const entry = confirmedSubmissions.get(key); + if (entry?.pending !== pending) return; + entry.unsubscribeConsent(); + confirmedSubmissions.delete(key); + notifySubmissions(); +} + /** * Microphone audio leaves this device only for the environment the person chose in * Voice settings. Nothing is inferred from the primary or the draft's environment, and @@ -155,6 +203,19 @@ export function useEnvironmentSpeechInput(input: HookInput) { const grantId = destination.grantId; const cloudProvider = destination.cloudProvider; const cloudModel = destination.cloudModel; + const retainedSubmission = useSyncExternalStore( + subscribeSubmissions, + () => confirmedSubmissions.get(submissionKey(input.environmentId, input.ownerKey)), + () => undefined, + ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- revokes retained speech work when the destination connection or this owner's permission changes + useEffect(() => { + for (const entry of confirmedSubmissions.values()) { + if (entry.grantId !== grantId || entry.prepared !== prepared) entry.cancel(); + } + if (input.disabled) + confirmedSubmissions.get(submissionKey(input.environmentId, input.ownerKey))?.cancel(); + }, [prepared, grantId, input.disabled, input.environmentId, input.ownerKey]); const draftCapabilities = useEnvironment(input.environmentId)?.serverConfig?.environment .capabilities; const originatingConnection = Option.getOrNull(usePreparedConnection(input.environmentId)); @@ -201,25 +262,20 @@ export function useEnvironmentSpeechInput(input: HookInput) { setSetup(INITIAL_SETUP); } const state: VoiceInputState = - queuedStart === prepared && prepared + retainedSubmission?.state ?? + (queuedStart === prepared && prepared ? WAITING_STATE : controllerState.prepared === prepared && controllerState.ownerKey === input.ownerKey ? controllerState.value - : INITIAL_STATE; + : INITIAL_STATE); const controllerRef = useRef(null); const startRequestRef = useRef(0); - const pendingSubmissionRef = useRef<{ - ownerKey: string; - submit: (text: string) => void; - text?: string; - cancel?: (() => void) | undefined; - draft?: SubmissionDraft | undefined; - snapshot?: VoiceDraftSnapshot | undefined; - } | null>(null); + const pendingSubmissionRef = useRef(null); const cancelSubmission = useStableCallback(() => { const pending = pendingSubmissionRef.current; pendingSubmissionRef.current = null; + if (pending) clearConfirmedSubmission(pending); pending?.cancel?.(); }); const lastSnapshotRef = useRef(null); @@ -419,10 +475,19 @@ export function useEnvironmentSpeechInput(input: HookInput) { } } if (value.phase === "idle" && pending?.draft) { + clearConfirmedSubmission(pending); pendingSubmissionRef.current = null; if (pending.text !== undefined) pending.submit(pending.text); else pending.cancel?.(); } + if (pending?.draft && value.phase !== "idle" && value.phase !== "error") { + const key = submissionKey(pending.environmentId, pending.ownerKey); + const entry = confirmedSubmissions.get(key); + if (entry?.pending === pending) { + confirmedSubmissions.set(key, { ...entry, state: value }); + notifySubmissions(); + } + } if (disposed) { if (value.phase === "error") controller.dispose(); return; @@ -517,7 +582,12 @@ export function useEnvironmentSpeechInput(input: HookInput) { const submitAfterDictation = useStableCallback( (submit: (text: string) => void, cancel?: () => void) => { const current = latestRef.current.input; - if (current.disabled || state.phase === "preparing" || pendingSubmissionRef.current) { + if ( + current.disabled || + state.phase === "preparing" || + pendingSubmissionRef.current || + confirmedSubmissions.has(submissionKey(current.environmentId, current.ownerKey)) + ) { cancel?.(); return; } @@ -526,13 +596,37 @@ export function useEnvironmentSpeechInput(input: HookInput) { submit(current.readDraft().text); return; } - pendingSubmissionRef.current = { + const pending: PendingSubmission = { + environmentId: current.environmentId, ownerKey: current.ownerKey, submit, cancel, draft: current.captureSubmissionDraft?.(), snapshot: lastSnapshotRef.current ?? undefined, }; + pendingSubmissionRef.current = pending; + if (pending.draft && prepared && grantId) { + const entry: ConfirmedSubmission = { + pending, + controller, + prepared, + grantId, + state: controller.currentState, + cancel: () => { + cancelSubmission(); + controller.dispose(); + }, + unsubscribeConsent: () => {}, + }; + confirmedSubmissions.set(submissionKey(current.environmentId, current.ownerKey), entry); + const hasConsent = bindAudioGrant(grantId); + const cleanupGrant = current.cleanup === false ? null : readCleanupGrant(); + entry.unsubscribeConsent = subscribeVoiceConsent(() => { + if (!hasConsent() || (cleanupGrant && cleanupGrant !== readCleanupGrant())) + entry.cancel(); + }); + notifySubmissions(); + } if (controller.currentState.phase === "recording") void controller.stop(); }, ); @@ -565,7 +659,7 @@ export function useEnvironmentSpeechInput(input: HookInput) { const start = useStableCallback(async () => { if (latestRef.current.input.disabled || (setup.open && setup.downloading)) return; - if (pendingSubmissionRef.current) { + if (confirmedSubmissions.size > 0 || pendingSubmissionRef.current) { toastManager.add({ type: "info", title: "Finishing the previous voice message" }); return; } @@ -719,8 +813,19 @@ export function useEnvironmentSpeechInput(input: HookInput) { freezesEditor: voiceInputBlocksSubmission(state), start, submitAfterDictation, - stop: useStableCallback(() => controllerRef.current?.stop() ?? Promise.resolve()), + stop: useStableCallback( + () => + ( + confirmedSubmissions.get(submissionKey(input.environmentId, input.ownerKey)) + ?.controller ?? controllerRef.current + )?.stop() ?? Promise.resolve(), + ), cancel: useStableCallback(() => { + const retained = confirmedSubmissions.get(submissionKey(input.environmentId, input.ownerKey)); + if (retained) { + retained.cancel(); + return; + } const pending = pendingSubmissionRef.current; if (pending?.draft && pending.ownerKey !== latestRef.current.input.ownerKey) return; cancelSubmission(); @@ -729,6 +834,11 @@ export function useEnvironmentSpeechInput(input: HookInput) { setSetup(INITIAL_SETUP); controllerRef.current?.cancel(); }), - skipPostProcessing: useStableCallback(() => controllerRef.current?.skipPostProcessing()), + skipPostProcessing: useStableCallback(() => + ( + confirmedSubmissions.get(submissionKey(input.environmentId, input.ownerKey))?.controller ?? + controllerRef.current + )?.skipPostProcessing(), + ), }; } diff --git a/apps/web/src/speech/voiceConsent.ts b/apps/web/src/speech/voiceConsent.ts index 7c897fbc37ea5..fb63e0f2c9558 100644 --- a/apps/web/src/speech/voiceConsent.ts +++ b/apps/web/src/speech/voiceConsent.ts @@ -110,6 +110,27 @@ export function bindAudioGrant(id: string) { } } +/** Observes revocation even while the recording's editor is unmounted. */ +export function subscribeVoiceConsent(onChange: () => void) { + let stopPending = () => {}; + const followPending = () => { + stopPending(); + const pending = pendingGrant(readChoice()); + stopPending = pending ? subscribeLocalStorageKey(savedKey(pending.id), onChange) : () => {}; + }; + followPending(); + const stopAudio = subscribeLocalStorageKey(AUDIO_KEY, () => { + followPending(); + onChange(); + }); + const stopCleanup = subscribeLocalStorageKey(CLEANUP_KEY, onChange); + return () => { + stopAudio(); + stopCleanup(); + stopPending(); + }; +} + export function useCleanupGrant() { return useLocalStorage(CLEANUP_KEY, null, CleanupGrant)[0]; } diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index 68b8930e131fc..a339d94b0c944 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -59,7 +59,11 @@ const descriptor = { // A matching version, since a mismatch adds an update banner a typical user never sees. serverVersion: APP_VERSION, orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, - capabilities: { repositoryIdentity: true, voiceTranscription: true }, + capabilities: { + repositoryIdentity: true, + voiceTranscription: true, + fileAttachments: { maxUploadBytes: 1000000 }, + }, }; const auth = { policy: "loopback-browser", @@ -341,6 +345,8 @@ const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue ); const handlers: Record unknown> = { + [WS_METHODS.assetsCreateUrl]: () => + Effect.succeed({ relativeUrl: "/fixtures/report.txt", expiresAt: 4102444800000 }), // Recorded, and accepted only when the scenario asks, since a pending command holds every // later command on its thread. Accepted commands' events never arrive. [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) => From b16777ae0068fe7d9c274df3f88f609fdb403cca Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 10 Nov 2026 01:14:56 +0530 Subject: [PATCH 4/7] fix(web): cancel dictation sends after controller completion --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- ...useEnvironmentSpeechInput.browser.test.tsx | 35 +++++++++++++++++++ .../src/speech/useEnvironmentSpeechInput.ts | 4 +++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8211b152d0288..9dae4bd8648d8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2104,7 +2104,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } - return composerSendState.hasSendableContent; + return composerSendState.hasSendableContent || voiceInputActive; }; const submitComposerRef = useRef< diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx index 34cdf948ee0ae..6a96741cebf7b 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx +++ b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx @@ -398,6 +398,41 @@ function SubmissionProbe({ return null; } +it("does not retain a send when transcription finishes before the editor rerenders", async () => { + setup({ destination: "env-a" }); + let finish!: (text: string) => void; + mocks.transcripts = [ + new Promise((resolve) => { + finish = resolve; + }), + ]; + mocks.drafts = { "thread-a": "Fix", "thread-b": "" }; + const submit = vi.fn(); + const cancel = vi.fn(); + await using screen = await render(); + await act(() => mocks.voice.start()); + let stopping!: Promise; + await act(() => { + stopping = mocks.voice.stop(); + }); + await vi.waitFor(() => expect(mocks.voice.state.phase).toBe("transcribing")); + const stale = mocks.voice; + await act(async () => { + finish("the login bug"); + await stopping; + expect(mocks.voice).toBe(stale); + stale.submitAfterDictation(submit, cancel); + }); + expect(cancel).toHaveBeenCalledOnce(); + expect(submit).not.toHaveBeenCalled(); + expect(mocks.drafts["thread-a"]).toBe("Fix the login bug"); + await screen.rerender(
); + await screen.rerender(); + await act(() => mocks.voice.start()); + expect(mocks.microphoneRequests).toEqual(["env-a", "env-a"]); + await act(() => mocks.voice.cancel()); +}); + it.each([ ["transcribing", "finish"], ["post-processing", "finish"], diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.ts b/apps/web/src/speech/useEnvironmentSpeechInput.ts index 7162a0ee3822d..8e4790862ad38 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.ts +++ b/apps/web/src/speech/useEnvironmentSpeechInput.ts @@ -596,6 +596,10 @@ export function useEnvironmentSpeechInput(input: HookInput) { submit(current.readDraft().text); return; } + if (!voiceInputBlocksSubmission(controller.currentState)) { + cancel?.(); + return; + } const pending: PendingSubmission = { environmentId: current.environmentId, ownerKey: current.ownerKey, From a7c5392cc6851cda3edfb66a6a35fb07f6f0ab80 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 10 Nov 2026 04:46:03 +0530 Subject: [PATCH 5/7] fix(web): retain session permissions during confirmed dictation Co-Authored-By: GPT-6.1 Sol --- ...useEnvironmentSpeechInput.browser.test.tsx | 66 +++++++++++++++---- .../src/speech/useEnvironmentSpeechInput.ts | 9 ++- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx index 6a96741cebf7b..3090559612b0c 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx +++ b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx @@ -4,6 +4,7 @@ import { page, userEvent } from "vite-plus/test/browser"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; import type { PreparedConnection } from "@t3tools/client-runtime/connection"; import { getEnvironmentSpeechModels } from "@t3tools/client-runtime/voice-input"; import { @@ -17,7 +18,7 @@ import { type SpeechTranscriptionOptions, } from "@t3tools/contracts"; -import { act, render, stubGlobal } from "../../test/render"; +import { act, fakeTimers, render, stubGlobal } from "../../test/render"; import { VoiceInputSetup } from "../components/chat/VoiceInputSetup"; import type { createBrowserVoiceInputPlatform } from "./browserVoiceInput"; import { useEnvironmentSpeechInput } from "./useEnvironmentSpeechInput"; @@ -28,6 +29,7 @@ import { pendingCloudAudioChoice, } from "./voiceConsent"; import { writeBrowserClientSettings } from "../clientPersistenceStorage"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; const mocks = vi.hoisted(() => ({ voice: undefined as unknown as ReturnType, @@ -36,6 +38,7 @@ const mocks = vi.hoisted(() => ({ voiceCapable: new Set(), cloudVoiceCapable: new Set(), canDictate: new Set(), + sessionAtoms: new Map>>(), prepared: new Map(), primaryEnvironmentId: "primary-env", statusRequests: [] as string[], @@ -62,18 +65,33 @@ const mocks = vi.hoisted(() => ({ drafts: {} as Record, })); -vi.mock("../state/session", () => ({ - useEnvironmentScope: (id: string) => mocks.canDictate.has(id), - usePreparedConnection: (environmentId: string | null) => { - if (!environmentId || !mocks.connected.has(environmentId)) return Option.none(); - let prepared = mocks.prepared.get(environmentId); - if (!prepared) { - prepared = { environmentId, label: environmentId }; - mocks.prepared.set(environmentId, prepared); +vi.mock("../state/session", async () => { + const { useAtomValue } = await import("@effect/atom-react"); + const { AsyncResult, Atom } = await import("effect/reactivity"); + const sessionStateAtom = (id: string) => { + let atom = mocks.sessionAtoms.get(id); + if (!atom) { + atom = Atom.make(AsyncResult.success(true)); + mocks.sessionAtoms.set(id, atom); } - return Option.some(prepared); - }, -})); + return atom; + }; + return { + environmentSession: { sessionStateAtom }, + useEnvironmentScope: (id: string) => + AsyncResult.getOrElse(useAtomValue(sessionStateAtom(id)), () => false) && + mocks.canDictate.has(id), + usePreparedConnection: (environmentId: string | null) => { + if (!environmentId || !mocks.connected.has(environmentId)) return Option.none(); + let prepared = mocks.prepared.get(environmentId); + if (!prepared) { + prepared = { environmentId, label: environmentId }; + mocks.prepared.set(environmentId, prepared); + } + return Option.some(prepared); + }, + }; +}); vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => mocks.primaryEnvironmentId, useEnvironment: (environmentId: string | null) => @@ -215,6 +233,7 @@ function setup(options: { readonly destination?: string | null } = {}) { mocks.voiceCapable = new Set(["primary-env", THREAD_ENV, "env-a", "env-b"]); mocks.cloudVoiceCapable.clear(); mocks.canDictate = new Set(mocks.voiceCapable); + mocks.sessionAtoms.clear(); mocks.prepared.clear(); mocks.statusRequests = []; mocks.statusUnavailable = false; @@ -365,7 +384,15 @@ it.each([ expect(mocks.committed).not.toHaveBeenCalled(); }); -function SubmissionProbe({ +function SubmissionProbe(props: Parameters[0]) { + return ( + + + + ); +} + +function SubmissionProbeContent({ owner, environmentId = THREAD_ENV, }: { @@ -439,10 +466,21 @@ it.each([ ["transcribing", "cancel"], ["post-processing", "cancel"], ["post-processing", "skip"], + ["transcribing", "finish after session expiry"], ] as const)( "reattaches a confirmed %s submission after remount to %s it", async (phase, action) => { setup({ destination: "env-a" }); + const expires = action === "finish after session expiry"; + if (expires) { + let reads = 0; + mocks.sessionAtoms.set( + "env-a", + Atom.make(Effect.suspend(() => (++reads === 1 ? Effect.succeed(true) : Effect.never))).pipe( + Atom.setIdleTTL(5 * 60_000), + ), + ); + } let finish!: (text: string) => void; const result = new Promise((resolve) => { finish = resolve; @@ -461,10 +499,12 @@ it.each([ const submit = vi.fn(); const cancel = vi.fn(); await using screen = await render(); + using _timers = expires ? fakeTimers() : { [Symbol.dispose]() {} }; await act(() => mocks.voice.start()); await act(() => mocks.voice.submitAfterDictation(submit, cancel)); await vi.waitFor(() => expect(mocks.voice.state.phase).toBe(phase)); await screen.rerender(
); + if (expires) await act(() => vi.advanceTimersByTimeAsync(6 * 60_000)); await screen.rerender(); expect(mocks.voice.state.phase).toBe("idle"); await act(() => mocks.voice.cancel()); diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.ts b/apps/web/src/speech/useEnvironmentSpeechInput.ts index 8e4790862ad38..8760aafef1b26 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.ts +++ b/apps/web/src/speech/useEnvironmentSpeechInput.ts @@ -36,7 +36,8 @@ import { useEnvironmentSettings, } from "../hooks/useSettings"; import { useEnvironment } from "../state/environments"; -import { useEnvironmentScope, usePreparedConnection } from "../state/session"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSession, useEnvironmentScope, usePreparedConnection } from "../state/session"; import { createBrowserVoiceInputPlatform } from "./browserVoiceInput"; import { preferredSpeechLanguages } from "./preferredSpeechLanguages"; import { runEnvironmentSpeech } from "./runEnvironmentSpeech"; @@ -136,6 +137,7 @@ type ConfirmedSubmission = { readonly grantId: string; readonly state: VoiceInputState; readonly cancel: () => void; + readonly releaseSession: () => void; unsubscribeConsent: () => void; }; @@ -158,6 +160,7 @@ function clearConfirmedSubmission(pending: PendingSubmission) { const entry = confirmedSubmissions.get(key); if (entry?.pending !== pending) return; entry.unsubscribeConsent(); + entry.releaseSession(); confirmedSubmissions.delete(key); notifySubmissions(); } @@ -616,6 +619,10 @@ export function useEnvironmentSpeechInput(input: HookInput) { prepared, grantId, state: controller.currentState, + // Remounts must not mistake an expired permission cache for revoked access. + releaseSession: appAtomRegistry.mount( + environmentSession.sessionStateAtom(prepared.environmentId), + ), cancel: () => { cancelSubmission(); controller.dispose(); From c740bec7d768670543f27407b1b27f7f50cca00d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:34:58 +0000 Subject: [PATCH 6/7] test(web): align dictation send tests with main Main inserts a transcript into a draft edited during dictation, so the hook-level edit row no longer models the composer; the ChatView prompt-change case covers that. Drop a duplicate cancel row. AI-assisted; verified with the tests and tools listed above. --- .../src/speech/useEnvironmentSpeechInput.browser.test.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx index 3c504774b1499..ca6efd41236c4 100644 --- a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx +++ b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx @@ -463,7 +463,6 @@ it("does not retain a send when transcription finishes before the editor rerende it.each([ ["transcribing", "finish"], ["post-processing", "finish"], - ["transcribing", "cancel"], ["post-processing", "cancel"], ["post-processing", "skip"], ["transcribing", "finish after session expiry"], @@ -546,7 +545,7 @@ it.each([ }, ); -it.each(["cancel", "off", "destination", "cloud-destination", "cleanup", "edit", "error"])( +it.each(["cancel", "off", "destination", "cloud-destination", "cleanup", "error"])( "does not send a confirmed recording after %s", async (action) => { setup({ destination: "env-a" }); @@ -568,7 +567,6 @@ it.each(["cancel", "off", "destination", "cloud-destination", "cleanup", "edit", await act(() => mocks.voice.submitAfterDictation(submit, cancel)); await vi.waitFor(() => expect(mocks.transcribeCalls).toEqual(["env-a"])); if (action === "cancel") await act(() => mocks.voice.cancel()); - else if (action === "edit") drafts["thread-a"] = "A later edit"; else { await screen.rerender(
); if (action === "cleanup") chooseTranscriptCleanup(false); @@ -589,7 +587,7 @@ it.each(["cancel", "off", "destination", "cloud-destination", "cleanup", "edit", }); await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()); expect(submit).not.toHaveBeenCalled(); - expect(drafts["thread-a"]).toBe(action === "edit" ? "A later edit" : "Fix"); + expect(drafts["thread-a"]).toBe("Fix"); await screen.rerender(
); await screen.rerender(); expect(mocks.voice.state.phase).toBe("idle"); From f658095e3d8bfd598de4dedb396f1797e87f62c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 23:06:54 +0000 Subject: [PATCH 7/7] fix(web): dictated queued-message edits keep their own draft Co-Authored-By: AI-assisted; verified with the tests and tools listed above. --- .../ChatView.dictation.browser.test.tsx | 21 +++++++++++++++++++ .../src/components/chat/useComposerSend.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.dictation.browser.test.tsx b/apps/web/src/components/ChatView.dictation.browser.test.tsx index a74c91e918375..8af6bcbe9143f 100644 --- a/apps/web/src/components/ChatView.dictation.browser.test.tsx +++ b/apps/web/src/components/ChatView.dictation.browser.test.tsx @@ -1,5 +1,6 @@ import { createMemoryHistory } from "@tanstack/react-router"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { MessageId, TurnItemId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; import { expect, it, vi } from "vite-plus/test"; @@ -9,6 +10,7 @@ import { AppRoot } from "~/AppRoot"; import "~/components/ChatView"; import { useComposerDraftStore } from "~/composerDraftStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { responseContextRecord } from "~/lib/composerContextRecords"; import { getRouter } from "~/router"; import { chooseAudioDestination, chooseTranscriptCleanup } from "~/speech/voiceConsent"; import { @@ -67,6 +69,25 @@ it.each([ const bRef = scopeThreadRef(ENVIRONMENT_ID, b.id); const mainPrompt = scenario === "queued-equal" ? "Queued message" : "Fix"; useComposerDraftStore.getState().setPrompt(aRef, mainPrompt); + if (editing) + useComposerDraftStore.getState().setReplyTo( + aRef, + responseContextRecord({ + citation: { + version: 1, + environmentId: ENVIRONMENT_ID, + threadId: a.id, + messageId: MessageId.make("dictation-reply"), + text: "Normal draft reply", + start: 0, + end: 18, + prefix: "", + suffix: "", + }, + itemId: TurnItemId.make("dictation-reply"), + subagents: [], + }), + ); const file = { type: "file" as const, id: `dictation-file-${scenario}`, diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 5a0dbf381db98..df27b0770c2dc 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -432,7 +432,7 @@ export function useComposerSend(input: ComposerSendInput) { } } const composerDraftTarget = - dictation && activeThread + dictation && activeThread && !queuedRunEdit.isEditing ? scopeThreadRef(environmentId, activeThread.id) : input.composerDraftTarget; const promoted =