Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions apps/web/src/components/ChatView.dictation.browser.test.tsx
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");
});
9 changes: 8 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2978,7 +2978,14 @@ export default function ChatView(props: ChatViewProps) {
});
};
const onWorktreeSetupWorkLocallyStable = useStableCallback(onWorktreeSetupWorkLocally);
const onComposerSendStable = useStableCallback(onSend);
const onComposerSendStable = useStableCallback(
(
event?: { preventDefault: () => void },
mode?: Parameters<typeof onSend>[1],
intent?: Parameters<typeof onSend>[2],
dictation?: Promise<string | null>,
) => onSend(event, mode, intent, undefined, dictation),
);
const sendPreviewAnnotation = (
annotation: PreviewAnnotationPayload,
image: ComposerImageAttachment | null,
Expand Down
74 changes: 66 additions & 8 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ export interface ChatComposerProps {
e?: { preventDefault: () => void },
dispatchMode?: ComposerDispatchMode,
submissionIntent?: ComposerSubmissionIntent,
dictation?: Promise<string | null>,
) => void;
onResume: () => void;
onInterrupt: () => void;
Expand Down Expand Up @@ -1809,8 +1810,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
environmentId,
projectId: activeProjectId,
projectName,
ownerKey: questionAttachmentTarget ?? composerDraftTargetKey,
ownerKey:
questionAttachmentTarget ??
(isEditingQueuedMessage ? composerDraftTargetKey : composerTargetKey(routeThreadRef)),
disabled: dictationDisabled,
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 {
Expand Down Expand Up @@ -2102,7 +2130,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<
Expand All @@ -2113,11 +2141,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
dispatchMode?: ComposerDispatchMode,
submissionIntent?: ComposerSubmissionIntent,
) => {
if (voiceInputActive) {
event?.preventDefault();
submitAfterDictation(() => submitComposerRef.current(dispatchMode, submissionIntent));
return false;
}
if (submissionBlocked || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
event?.preventDefault();
return false;
Expand All @@ -2143,6 +2166,41 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
});
return false;
}
if (voiceInputActive) {
event?.preventDefault();
if (activePendingProgress) {
submitAfterDictation(() => submitComposerRef.current(dispatchMode, submissionIntent));
return false;
}
const transcript = new Promise<string | null>((resolve) => {
submitAfterDictation(
(text) => {
resolve(text);
if (
composerDraftTargetKeyRef.current === composerDraftTargetKey &&
composerFormRef.current?.isConnected
) {
onTypingGuardSend();
if (shouldBlurMobileComposerOnSubmit()) blurMobileComposerAfterSend();
}
},
() => resolve(null),
);
});
onSend(
undefined,
dispatchMode ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveComposerDispatchMode now runs at press time using phase === "running". Before this change, the Send-button path passed undefined and 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 on useComposerSend.ts).

resolveComposerDispatchMode({
running: phase === "running" || phase === "connecting",
alternateModifier: false,
activeTurnDefault: followUpBehavior,
canSteer,
}),
submissionIntent,
transcript,
);
return false;
}
// An empty draft has nothing to send; Enter does what the Resume thread button does.
if (showResumeAction && !activePendingProgress) {
event?.preventDefault();
Expand Down Expand Up @@ -2179,7 +2237,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
return true;
};
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]);
Expand Down
Loading
Loading