Repository navigation
fix(web): send confirmed dictation to its original thread #33571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
voltcrash
wants to merge
10
commits into
pingdotgg:main
Choose a base branch
from
voltcrash:t3/preserve-thread-speech-send
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+943
−92
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
9c687b8
fix(web): finish confirmed dictation sends in their original thread
voltcrash a549191
fix(web): preserve dictation edits and attachment completion
voltcrash 64e064a
fix(web): reattach confirmed voice submissions after remount
voltcrash b16777a
fix(web): cancel dictation sends after controller completion
voltcrash a7c5392
fix(web): retain session permissions during confirmed dictation
voltcrash 86745b0
Merge remote-tracking branch 'github/main' into t3/preserve-thread-sp…
maria-rcks a350f0a
Merge remote-tracking branch 'origin/main' into t3/preserve-thread-sp…
juliusmarminge c740bec
test(web): align dictation send tests with main
juliusmarminge f658095
fix(web): dictated queued-message edits keep their own draft
juliusmarminge bac2ecc
Merge remote-tracking branch 'origin/main' into t3/preserve-thread-sp…
juliusmarminge File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
235 changes: 235 additions & 0 deletions
235
apps/web/src/components/ChatView.dictation.browser.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| 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"; | ||
| import { page } from "vite-plus/test/browser"; | ||
|
|
||
| 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 { | ||
| 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<unknown, FakeDriver> }>(), | ||
| 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", "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); | ||
| 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}`, | ||
| 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<Response>((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 router={router} />, { | ||
| // 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), | ||
| ); | ||
| if (!editing) { | ||
| 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"); | ||
| } | ||
| 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(useComposerDraftStore.getState().getComposerDraft(bRef)?.prompt).toBe("Do not send"); | ||
| if (!editing) | ||
| await expect.element(page.getByTestId("composer-editor")).toHaveTextContent("Do not send"); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolveComposerDispatchModenow runs at press time usingphase === "running". Before this change, the Send-button path passedundefinedand resolved the mode after the transcript was committed. A turn that starts or finishes during transcription or cleanup now gets the wrong mode (see the note onuseComposerSend.ts).