Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
Audio stays on the client device even for remote environments. The composer
owns the dictation session through a hook that mirrors mobile's
useVoiceInputController and reads its send/editor gates directly; the mic
button only renders state. Cancellation stops owned work, stale drafts never
button only renders state. A new recording targets the thread the composer
is currently showing, and an owner change cancels an in-flight one rather
than re-targeting it. Cancellation stops owned work, stale drafts never
receive transcripts, and a recording in flight survives the composer becoming
disabled: a pending approval or question routes the transcript into the
prompt draft itself, and when the action cluster unmounts the recording is
Expand Down Expand Up @@ -68,6 +70,7 @@
- docs/operations/release.md
verify:
- apps/web/src/__fork_guards__/forkLocalDictation.test.ts
- apps/web/src/__fork_guards__/forkDictationThreadOwnership.test.tsx
- apps/desktop/src/fork/voice/LocalSpeechEngine.test.ts
- apps/web/src/custom/voice/BrowserVoiceRecorder.test.ts

Expand Down
260 changes: 260 additions & 0 deletions apps/web/src/__fork_guards__/forkDictationThreadOwnership.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/**
* Fork guard — see `.fork/customizations.yaml#fork-local-dictation`.
*
* ChatComposer is `memo(fn)`, and react-dom refreshes a useEffectEvent impl
* only for FunctionComponent fibers, so one declared inside it keeps its
* mount-time closure forever. These tests pin that dictation still targets the
* thread on screen; against the effect-event version of the hook every
* transcript lands in the first thread opened.
*/
import { act, memo, StrictMode, useLayoutEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

vi.mock("../custom/voice/BrowserVoiceRecorder", () => ({
BrowserVoiceRecorder: class {
uri = "blob:test-recording";
async prepareToRecordAsync() {}
record() {}
async stop() {}
release() {}
},
recordingToWav: async () => new Uint8Array([1]),
releaseRecording: () => {},
}));

type DictationHook =
typeof import("../custom/voice/useForkDictationController").useForkDictationController;

let useForkDictationController: DictationHook;
let root: Root;
let dictation: ReturnType<DictationHook>;
let drafts: Map<string, string>;
let focused: string[];
const transcribe = vi.fn(async () => "spoken words");

/**
* Each thread gets its own composer element, so a stale `getComposerElement`
* is observable: the chord only starts when the keydown target sits inside the
* composer of the thread currently rendered.
*/
class FakeNode {
constructor(readonly ownerKey: string) {}
}
let editors: Map<string, FakeNode>;
function editorFor(ownerKey: string) {
let node = editors.get(ownerKey);
if (!node) {
node = new FakeNode(ownerKey);
editors.set(ownerKey, node);
}
return node;
}

// ChatComposer uses memo: without it, the stale effect-event bug does not reproduce.
const Composer = memo(function Composer({ ownerKey }: { ownerKey: string }) {
const session = useForkDictationController({
ownerKey,
disabled: false,
getComposerElement: () => {
const editor = editorFor(ownerKey);
return { contains: (node: unknown) => node === editor } as unknown as HTMLElement;
},
focusEditor: () => {
focused.push(ownerKey);
},
readDraft: () => {
const value = drafts.get(ownerKey) ?? "";
return { value, expandedCursor: value.length };
},
commitDraft: (text) => {
drafts.set(ownerKey, text);
},
});
useLayoutEffect(() => {
dictation = session;
});
return null;
});

async function renderThread(ownerKey: string) {
Comment thread
NoahHendrickson marked this conversation as resolved.
Resolved
await act(() => {
root.render(
<StrictMode>
<Composer ownerKey={ownerKey} />
</StrictMode>,
);
});
}

function pressChord(target: unknown) {
const event = new Event("keydown", { cancelable: true });
Object.defineProperty(event, "target", { value: target });
Object.assign(event, {
key: " ",
code: "Space",
ctrlKey: true,
shiftKey: true,
metaKey: false,
altKey: false,
repeat: false,
});
window.dispatchEvent(event);
}

beforeEach(async () => {
// VoiceInputController holds its active-session token at module scope and
// releases it only when a transcription settles. A test ending while
// preparing or transcribing would leave it held and fail the next one inside
// acquireSession(). client-runtime does not export
// resetVoiceInputGlobalsForTests() from its barrel, so a fresh module graph
// per test is the file-local equivalent. Import before the stubs below: the
// graph reaches modules that sniff the real navigator at load.
vi.resetModules();
({ useForkDictationController } = await import("../custom/voice/useForkDictationController"));

// The probe renders no host nodes, but ReactDOM still needs an event target.
const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} };
const container = {
nodeType: 1,
tagName: "DIV",
namespaceURI: "http://www.w3.org/1999/xhtml",
ownerDocument: document,
addEventListener() {},
removeEventListener() {},
};
vi.stubGlobal("document", document);
vi.stubGlobal(
"window",
Object.assign(new EventTarget(), { document, HTMLIFrameElement: EventTarget }),
);
vi.stubGlobal("navigator", { permissions: { query: async () => ({ state: "granted" }) } });
// The keydown handler narrows the event target with `instanceof Node`.
vi.stubGlobal("Node", FakeNode);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("forkDesktopBridge", {
voiceInput: {
prepare: async () => {},
transcribe,
cancel: async () => {},
onDownloadProgress: () => () => {},
},
});
transcribe.mockReset().mockResolvedValue("spoken words");
drafts = new Map();
editors = new Map();
focused = [];
root = createRoot(container as unknown as HTMLElement);
});

afterEach(async () => {
Comment thread
NoahHendrickson marked this conversation as resolved.
Resolved
// Unmount runs the hook's cleanups; a throw there must not leak the stubs.
try {
await act(() => {
root.unmount();
});
} finally {
vi.unstubAllGlobals();
}
});

describe("dictation thread ownership", () => {
it("inserts into the current thread after navigating with the same composer mounted", async () => {
const sessions = new Set<unknown>();
for (const owner of ["thread-a", "thread-b", "thread-c"]) {
await renderThread(owner);
// Built once per mount, so this doubles as the harness's own invariant:
// if a tree change ever remounted the composer, each thread would get a
// fresh session and the stale-closure bug would be unreachable here.
sessions.add(dictation.subscribeLevel);
await act(async () => {
dictation.start();
});
expect(dictation.state.phase).toBe("recording");
await act(async () => {
dictation.stop();
});
expect(dictation.state.phase).toBe("idle");
expect(drafts.get(owner)).toBe("spoken words");
}
expect(sessions.size).toBe(1);
expect([...drafts.values()]).toEqual(["spoken words", "spoken words", "spoken words"]);
});

it("does not insert a late transcript after switching threads", async () => {
let finish!: (text: string) => void;
transcribe.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
await renderThread("thread-a");
await act(async () => {
dictation.start();
});
await act(async () => {
dictation.stop();
});
expect(dictation.state.phase).toBe("transcribing");
await renderThread("thread-b");
await act(async () => {
finish("old thread's speech");
});
expect(drafts.size).toBe(0);
Comment thread
NoahHendrickson marked this conversation as resolved.
Resolved
// The owner change cancels; dropping the transcript through an error path
// instead would put a banner and a Retry on a thread that never recorded.
expect(dictation.state.phase).toBe("idle");
expect(dictation.error).toBeNull();
await act(async () => {
dictation.start();
});
await act(async () => {
dictation.stop();
});
expect(drafts.get("thread-b")).toBe("spoken words");
expect(drafts.has("thread-a")).toBe(false);
});

it("runs the keyboard chord against the visible thread's composer", async () => {
await renderThread("thread-a");
await renderThread("thread-b");

// The previous thread's editor is no longer inside the composer the hook reads.
await act(async () => {
pressChord(editorFor("thread-a"));
});
expect(dictation.state.phase).toBe("idle");

await act(async () => {
pressChord(editorFor("thread-b"));
});
expect(dictation.state.phase).toBe("recording");
await act(async () => {
pressChord(editorFor("thread-b"));
});
expect(dictation.state.phase).toBe("idle");
expect(drafts.get("thread-b")).toBe("spoken words");
expect(drafts.has("thread-a")).toBe(false);
});

it("keeps retained start and cancel callbacks pointed at the visible thread", async () => {
await renderThread("thread-a");
// Stands in for anything holding the returned object across renders, such
// as memoizing the mic button or hoisting start into a command action.
const retained = dictation;
await renderThread("thread-b");

await act(async () => {
retained.start();
});
expect(dictation.state.phase).toBe("recording");
focused.length = 0;
await act(async () => {
retained.cancel();
});
expect(dictation.state.phase).toBe("idle");
expect(focused).toEqual(["thread-b"]);
expect(drafts.size).toBe(0);
});
});
7 changes: 7 additions & 0 deletions apps/web/src/__fork_guards__/forkLocalDictation.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* Fork guard — see `.fork/customizations.yaml#fork-local-dictation`.
*
* Pins the wire format the packaged helper expects, the draft-insertion
* contract, and the composer owning the session. Thread targeting across
* navigation is guarded separately in `forkDictationThreadOwnership.test.tsx`.
*/
import * as NodeFS from "node:fs";
import { describe, expect, it } from "vite-plus/test";
import { VoiceInputController } from "@t3tools/client-runtime/voice-input";
Expand Down
50 changes: 38 additions & 12 deletions apps/web/src/custom/voice/useForkDictationController.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
VoiceInputController,
voiceInputBlocksSubmission,
Expand Down Expand Up @@ -189,19 +189,41 @@ const NO_SESSION = {
};

export function useForkDictationController(input: DictationInput) {
// Handing the ref mirror below into a render-phase initializer costs this
// hook its React Compiler memoization ("Cannot access refs during render").
// Stated so the loss is deliberate: ChatComposer itself does not compile
// either, so nothing downstream keys on the returned object's identity.
"use no memo";
const [state, setState] = useState(INITIAL_STATE);
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
const [detail, setDetail] = useState<string | null>(null);
// Called only from controller callbacks and key handlers, which run on user
// events after commit and need the latest closures, not the mount-time ones.
const readInput = useEffectEvent(() => input);
// ChatComposer is `memo(fn)`, and react-dom applies useEffectEvent impls only
// for FunctionComponent fibers — memo and forwardRef hosts are skipped
// outright (`case 11: case 15: break;` in commitBeforeMutationEffects,
// react-dom 19.2.6). An effect event declared here would keep its mount-time
// closure forever and route every later recording into the first thread
// opened. Audit on a React bump: grep useEffectEvent, check whether the host
// component is memoized or a forwardRef.
//
// The mirror syncs in the layout phase rather than during render (the shape
// BranchToolbar and mobile's useVoiceInputController use) because a
// render-phase write can latch an input from a concurrent render that never
// commits. The owner-change cancel below is a layout effect for the same
// reason: layout effects run in declaration order, so the mirror and the
// cancel land in one uninterruptible commit with no window between them.
const inputRef = useRef(input);
Comment thread
NoahHendrickson marked this conversation as resolved.
Resolved
useLayoutEffect(() => {
Comment thread
NoahHendrickson marked this conversation as resolved.
Resolved
inputRef.current = input;
});
// Initialization only registers callbacks; none reads the input until an event.
// oxlint-disable-next-line react/refs
const [{ controller, subscribeLevel, settleWithoutControls }] = useState(() => {
Comment thread
NoahHendrickson marked this conversation as resolved.
Resolved
const bridge = readForkVoiceInputBridge();
return bridge
? createSession(bridge, readInput, {
? createSession(bridge, () => inputRef.current, {
state: (next) => {
setState(next);
if (next.phase === "error") readInput().focusEditor();
if (next.phase === "error") inputRef.current.focusEditor();
},
detail: setDetail,
// The one-time download reports every integer percent; the label
Expand All @@ -213,7 +235,7 @@ export function useForkDictationController(input: DictationInput) {
});

const previousOwnerRef = useRef(input.ownerKey);
useEffect(() => {
useLayoutEffect(() => {
if (previousOwnerRef.current === input.ownerKey) return;
previousOwnerRef.current = input.ownerKey;
controller?.ownerChanged();
Expand All @@ -230,21 +252,21 @@ export function useForkDictationController(input: DictationInput) {
if (event.repeat || event.defaultPrevented) return;
const { phase } = controller.currentState;
const target = event.target;
const composer = readInput().getComposerElement();
const composer = inputRef.current.getComposerElement();
const inComposer = target instanceof Node && composer?.contains(target) === true;
const unfocused = target === document.body || target === document;
if (event.key === "Escape") {
// Only while dictation is actually holding the composer; a lingering
// error banner must not eat Escape from other composer UI.
if (!BLOCKING_PHASES.has(phase) || !(inComposer || unfocused)) return;
controller.cancel();
readInput().focusEditor();
inputRef.current.focusEditor();
} else if (isDictationChord(event)) {
if (phase === "recording") {
if (!(inComposer || unfocused)) return;
void controller.stop();
} else if (phase === "idle" || phase === "error") {
if (!inComposer || readInput().disabled) return;
if (!inComposer || inputRef.current.disabled) return;
void controller.start();
} else return;
} else return;
Expand All @@ -255,13 +277,17 @@ export function useForkDictationController(input: DictationInput) {
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [controller]);

// Through the mirror, not the render scope: a caller that retains these
// (memoizing ForkDictationControl, hoisting start into a command action)
// would otherwise regress the click path to the old thread while the
// keyboard path kept working.
const start = () => {
Comment thread
NoahHendrickson marked this conversation as resolved.
Resolved
if (!input.disabled) void controller?.start();
if (!inputRef.current.disabled) void controller?.start();
};
const stop = () => void controller?.stop();
const cancel = () => {
controller?.cancel();
input.focusEditor();
inputRef.current.focusEditor();
};

return {
Expand Down
Loading