diff --git a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts index cf27d2b367..22b2b8a6c5 100644 --- a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts +++ b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts @@ -1,10 +1,6 @@ import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; -import { - installDictationWsHarness, - installSyntheticMicrophone, - sendSessionMessage, -} from "../support/helpers/dictation-ws"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { openNewWorkspaceComposer, selectWorkspaceIsolation, @@ -21,39 +17,141 @@ import { } from "../support/helpers/composer"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +type WebSocketMessage = string | Buffer; + const TRANSCRIPT = "Keep this spoken prompt visible while creating the workspace"; const CREATE_FAILURE = "Synthetic workspace creation failure"; +function parseEnvelope(message: WebSocketMessage): { + type?: unknown; + message?: Record; +} | null { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + return JSON.parse(raw) as { type?: unknown; message?: Record }; + } catch { + return null; + } +} + +function sessionMessage(message: WebSocketMessage): Record | null { + const envelope = parseEnvelope(message); + return envelope?.type === "session" && envelope.message ? envelope.message : null; +} + +function sendSessionMessage( + ws: { send(message: string): void }, + message: Record, +): void { + ws.send(JSON.stringify({ type: "session", message })); +} + +function enableDictationCapability(message: WebSocketMessage): WebSocketMessage { + const envelope = parseEnvelope(message); + const payload = envelope?.message?.payload; + if ( + envelope?.message?.type !== "status" || + !payload || + typeof payload !== "object" || + (payload as { status?: unknown }).status !== "server_info" + ) { + return message; + } + + (payload as Record).capabilities = { + voice: { + dictation: { enabled: true, reason: "" }, + voice: { enabled: false, reason: "Realtime voice is disabled in this test." }, + }, + }; + return JSON.stringify(envelope); +} + +async function installSyntheticMicrophone(page: Page): Promise { + await page.addInitScript(() => { + const mediaDevices = navigator.mediaDevices; + Object.defineProperty(mediaDevices, "getUserMedia", { + configurable: true, + value: async () => { + const context = new AudioContext(); + const oscillator = context.createOscillator(); + const destination = context.createMediaStreamDestination(); + oscillator.connect(destination); + oscillator.start(); + return destination.stream; + }, + }); + }); +} + async function installDictationFailureHarness(page: Page) { + let resolveAudioChunk!: () => void; let resolveCreateRequest!: () => void; + const audioChunk = new Promise((resolve) => { + resolveAudioChunk = resolve; + }); const createRequest = new Promise((resolve) => { resolveCreateRequest = resolve; }); let failCreate: (() => void) | null = null; - const dictation = await installDictationWsHarness(page, { - transcript: TRANSCRIPT, - interceptClientMessage: (request, ws) => { - if (request.type !== "workspace.create.request" || typeof request.requestId !== "string") { - return false; - } - const requestId = request.requestId; - failCreate = () => { + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((message) => { + const request = sessionMessage(message); + const type = request?.type; + const dictationId = typeof request?.dictationId === "string" ? request.dictationId : null; + + if (type === "dictation_stream_start" && dictationId) { sendSessionMessage(ws, { - type: "workspace.create.response", - payload: { - requestId, - workspace: null, - setupTerminalId: null, - error: CREATE_FAILURE, - }, + type: "dictation_stream_ack", + payload: { dictationId, ackSeq: -1 }, }); - }; - resolveCreateRequest(); - return true; - }, + return; + } + if (type === "dictation_stream_chunk" && dictationId) { + const seq = typeof request?.seq === "number" ? request.seq : 0; + sendSessionMessage(ws, { + type: "dictation_stream_ack", + payload: { dictationId, ackSeq: seq }, + }); + resolveAudioChunk(); + return; + } + if (type === "dictation_stream_finish" && dictationId) { + sendSessionMessage(ws, { + type: "dictation_stream_finish_accepted", + payload: { dictationId, timeoutMs: 5_000 }, + }); + sendSessionMessage(ws, { + type: "dictation_stream_final", + payload: { dictationId, text: TRANSCRIPT }, + }); + return; + } + if (request && type === "workspace.create.request" && typeof request.requestId === "string") { + const requestId = request.requestId; + failCreate = () => { + sendSessionMessage(ws, { + type: "workspace.create.response", + payload: { + requestId, + workspace: null, + setupTerminalId: null, + error: CREATE_FAILURE, + }, + }); + }; + resolveCreateRequest(); + return; + } + + server.send(message); + }); + + server.onMessage((message) => ws.send(enableDictationCapability(message))); }); - const audioChunk = dictation.nextChunk(); return { waitForAudio: () => audioChunk, diff --git a/packages/app/e2e/browser/terminal-dictation.spec.ts b/packages/app/e2e/browser/terminal-dictation.spec.ts deleted file mode 100644 index aafd69e049..0000000000 --- a/packages/app/e2e/browser/terminal-dictation.spec.ts +++ /dev/null @@ -1,711 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import type { Locator, Page } from "@playwright/test"; -import { expect, test } from "../support/fixtures"; -import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; -import { - holdMicrophone, - installDictationWsHarness, - installSyntheticMicrophone, - microphoneStats, - releaseMicrophone, - type DictationCapability, - type DictationWsHarness, -} from "../support/helpers/dictation-ws"; -import { TerminalE2EHarness, type TerminalInstance } from "../support/helpers/terminal-dsl"; -import { ensureExplorerSidebar } from "../support/helpers/workspace-tabs"; - -const MAC_USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; -const LINUX_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; - -// Payloads are echoed as JSON with whitespace escaped, because the PTY capture -// below drops whitespace when it joins wrapped lines. -const ENCODE = String.raw` -const encode = (text) => - JSON.stringify(text).replace( - /\s/g, - (space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"), - ); -`; - -// A TUI with bracketed paste on: echoes each paste body as INPUT:. -const BRACKETED_RECEIVER_SCRIPT = String.raw` -${ENCODE} -let buffer = ""; -process.stdin.setRawMode(true); -process.stdin.resume(); -process.stdout.write("\x1b[?2004hDICTATION_RECEIVER_READY\r\n"); -process.stdin.on("data", (chunk) => { - buffer += chunk.toString(); - for (;;) { - const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer); - if (!paste) break; - process.stdout.write("INPUT:" + encode(paste[1]) + "\r\n"); - buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length); - } -}); -`; - -// A program without bracketed paste: echoes every stdin byte, in raw mode so -// Ctrl+D arrives as \x04, as RAW_INPUT: after a quiet period. -const RAW_RECEIVER_SCRIPT = String.raw` -${ENCODE} -let pending = ""; -let timer = null; -process.stdin.setRawMode(true); -process.stdin.resume(); -process.stdout.write("RAW_RECEIVER_READY\r\n"); -process.stdin.on("data", (chunk) => { - pending += chunk.toString(); - clearTimeout(timer); - timer = setTimeout(() => { - process.stdout.write("RAW_INPUT:" + encode(pending) + "\r\n"); - pending = ""; - }, 200); -}); -`; - -type ReceiverKind = "bracketed" | "raw"; - -function visibleTestId(scope: Page | Locator, testId: string): Locator { - return scope.getByTestId(testId).filter({ visible: true }).first(); -} - -// Click the surface, not its helper textarea: the textarea sits at the left edge, -// under a split or Explorer resize handle. -function terminalSurface(scope: Page | Locator): Locator { - return scope.getByTestId("terminal-surface").filter({ visible: true }); -} - -function terminalInput(scope: Page | Locator): Locator { - return scope - .getByTestId("terminal-surface") - .filter({ visible: true }) - .locator(".xterm-helper-textarea"); -} - -function splitChild(page: Page, terminal: TerminalInstance): Locator { - return page - .getByTestId("split-group-child") - .filter({ has: page.getByTestId(`workspace-tab-terminal_${terminal.id}`) }); -} - -function explorerSidebar(page: Page): Locator { - return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }).first(); -} - -async function dragTabIntoExplorer(page: Page, terminal: TerminalInstance): Promise { - const source = page - .getByTestId("workspace-pane-main") - .getByTestId(`workspace-tab-terminal_${terminal.id}`); - const destination = explorerSidebar(page).getByTestId("workspace-tab-files"); - const sourceBox = await source.boundingBox(); - const destinationBox = await destination.boundingBox(); - if (!sourceBox || !destinationBox) { - throw new Error("Tab drag requires visible source and destination"); - } - const start = { x: sourceBox.x + 12, y: sourceBox.y + sourceBox.height / 2 }; - await page.mouse.move(start.x, start.y); - await page.mouse.down(); - await page.mouse.move(start.x + 12, start.y + 4); - await page.mouse.move(destinationBox.x + 12, destinationBox.y + destinationBox.height / 2, { - steps: 20, - }); - await page.mouse.up(); -} - -async function expectMicrophoneStat( - page: Page, - stat: "trackStops" | "getUserMediaCalls", - count: number, -): Promise { - await expect.poll(async () => (await microphoneStats(page))[stat]).toBe(count); -} - -test.describe("Terminal dictation", () => { - test.describe.configure({ timeout: 180_000 }); - - let harness: TerminalE2EHarness; - - test.beforeAll(async () => { - harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-dictation-" }); - await writeFile( - path.join(harness.tempRepo.path, "bracketed-receiver.cjs"), - BRACKETED_RECEIVER_SCRIPT, - ); - await writeFile(path.join(harness.tempRepo.path, "raw-receiver.cjs"), RAW_RECEIVER_SCRIPT); - }); - - test.afterAll(async () => { - await harness?.cleanup(); - }); - - const terminals: TerminalInstance[] = []; - - test.afterEach(async () => { - for (const terminal of terminals.splice(0)) { - await harness.killTerminal(terminal.id); - } - }); - - async function createReceiver(kind: ReceiverKind, name: string): Promise { - const terminal = await harness.createTerminal({ - name, - command: process.execPath, - args: [kind === "bracketed" ? "bracketed-receiver.cjs" : "raw-receiver.cjs"], - }); - terminals.push(terminal); - await expectTerminalOutput( - terminal.id, - kind === "bracketed" ? "DICTATION_RECEIVER_READY" : "RAW_RECEIVER_READY", - ); - return terminal; - } - - async function setup( - page: Page, - options: { transcript?: string; capability?: DictationCapability } = {}, - ): Promise { - await installSyntheticMicrophone(page); - return await installDictationWsHarness(page, { - transcript: options.transcript ?? "first\nsecond", - capability: options.capability, - }); - } - - // Reads the PTY itself, so output from one pane cannot satisfy another's check. - async function terminalOutput(terminalId: string): Promise { - const capture = await harness.client.captureTerminal(terminalId); - return capture.lines.join("").replace(/\s/g, ""); - } - - async function expectTerminalOutput(terminalId: string, text: string): Promise { - await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text); - } - - /** Everything the receiver got, in order and joined. */ - async function received(terminalId: string, kind: ReceiverKind): Promise { - const label = kind === "bracketed" ? "INPUT" : "RAW_INPUT"; - const output = await terminalOutput(terminalId); - return [ - ...output.matchAll( - new RegExp( - `${kind === "bracketed" ? "(? JSON.parse(json) as string) - .join(""); - } - - async function expectReceived( - terminalId: string, - kind: ReceiverKind, - text: string, - ): Promise { - await expect.poll(() => received(terminalId, kind), { timeout: 15_000 }).toBe(text); - } - - /** Gives a late transcript time to land, then checks that none did. */ - async function expectNothingReceived( - page: Page, - ...receivers: Array<[TerminalInstance, ReceiverKind]> - ): Promise { - await page.waitForTimeout(1_500); - for (const [terminal, kind] of receivers) { - expect(await received(terminal.id, kind)).toBe(""); - } - } - - async function startFromToolbar( - page: Page, - ws: DictationWsHarness, - scope: Page | Locator = page, - ) { - const chunk = ws.nextChunk(); - await visibleTestId(scope, "terminal-toolbar-dictation").click(); - await chunk; - } - - // The route helper needs the desktop tab row, so open there, then go compact. - async function openCompact(page: Page, terminal: TerminalInstance) { - await harness.openTerminal(page, { terminalId: terminal.id }); - await page.setViewportSize({ width: 390, height: 844 }); - await expect(visibleTestId(page, "terminal-dictation")).toBeVisible(); - } - - // The compact switcher labels terminals by their command, so pick by position. - async function selectCompactTab(page: Page, index: number) { - await page - .getByRole("button", { name: /Switch tabs/ }) - .filter({ visible: true }) - .click(); - await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible(); - const options = page - .getByText("bracketed-receiver.cjs", { exact: true }) - .filter({ visible: true }); - await expect(options).toHaveCount(2); - await options.nth(index).click(); - await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); - } - - async function selectTerminalTab(scope: Page | Locator, terminal: TerminalInstance) { - await visibleTestId(scope, `workspace-tab-terminal_${terminal.id}`).click(); - } - - test("confirm pastes the transcript on one line with a trailing space and no Enter", async ({ - page, - }) => { - const ws = await setup(page); - const bracketed = await createReceiver("bracketed", "dictation-bracketed"); - const raw = await createReceiver("raw", "dictation-raw"); - - await harness.openTerminal(page, { terminalId: bracketed.id }); - await startFromToolbar(page, ws); - await visibleTestId(page, "terminal-dictation-confirm").click(); - await expectReceived(bracketed.id, "bracketed", "first second "); - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - - await selectTerminalTab(page, raw); - await startFromToolbar(page, ws); - await visibleTestId(page, "terminal-dictation-confirm").click(); - await expectReceived(raw.id, "raw", "first second "); - expect(await received(bracketed.id, "bracketed")).toBe("first second "); - }); - - test("the dictation pill keeps the tab row usable in a narrow split", async ({ page }) => { - const ws = await setup(page, { transcript: "narrow split words" }); - const left = await createReceiver("bracketed", "dictation-narrow-left"); - const right = await createReceiver("bracketed", "dictation-narrow-right"); - - await harness.openTerminal(page, { terminalId: left.id }); - await runWorkspaceActionFromCommandCenter(page, "Split pane right"); - await visibleTestId(page, `workspace-tab-terminal_${right.id}`).click(); - await runWorkspaceActionFromCommandCenter(page, "Move tab right"); - // Narrow enough that the pill's extra width decides whether the tab still fits. - await page.setViewportSize({ width: 1000, height: 900 }); - const pane = splitChild(page, right); - - const tab = visibleTestId(pane, `workspace-tab-terminal_${right.id}`); - const newTabButton = visibleTestId(pane, "workspace-new-tab-button"); - const inlineNewTabButton = pane - .getByTestId("workspace-tabs-scroll") - .getByTestId("workspace-new-tab-button"); - // Either the tab fits beside the inline new-tab button, or the row has switched to - // its overflow fallback: tabs scroll and the new-tab button moves to the toolbar. - // The button is never clipped by the tab scroller. - const expectTabsFit = async () => { - await expect(inlineNewTabButton).toHaveCount(1); - await expect(newTabButton).toBeInViewport({ ratio: 1 }); - await expect(tab).toBeInViewport({ ratio: 1 }); - }; - - await expectTabsFit(); - await startFromToolbar(page, ws, pane); - await expect(visibleTestId(pane, "terminal-dictation-pill")).toBeVisible(); - await expect(newTabButton).toBeInViewport({ ratio: 1 }); - await expect(tab).toBeInViewport({ ratio: 1 }); - - // The failed pill carries the error text, which leaves too little room for the tab. - ws.failNextFinish("Synthetic transcription failure"); - await visibleTestId(pane, "terminal-dictation-confirm").click(); - await expect(visibleTestId(pane, "terminal-dictation-retry")).toBeVisible(); - await expect(inlineNewTabButton).toHaveCount(0); - await expect(newTabButton).toBeInViewport({ ratio: 1 }); - - await visibleTestId(pane, "terminal-dictation-retry").click(); - await expectReceived(right.id, "bracketed", "narrow split words "); - await expect(visibleTestId(pane, "terminal-toolbar-dictation")).toBeVisible(); - await expectTabsFit(); - }); - - test.describe("on macOS", () => { - test.use({ userAgent: MAC_USER_AGENT }); - - test("Cmd+D starts and then confirms dictation in the focused terminal", async ({ page }) => { - const ws = await setup(page, { transcript: "shortcut words" }); - const terminal = await createReceiver("bracketed", "dictation-shortcut"); - - await harness.openTerminal(page, { terminalId: terminal.id }); - const chunk = ws.nextChunk(); - await terminalInput(page).press("Meta+d"); - await expect(visibleTestId(page, "terminal-dictation-confirm")).toBeVisible(); - await chunk; - await terminalInput(page).press("Meta+d"); - - await expectReceived(terminal.id, "bracketed", "shortcut words "); - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - }); - - test("Cmd+D goes to the terminal with keyboard focus, main pane or Explorer", async ({ - page, - }) => { - const ws = await setup(page, { transcript: "main pane words" }); - const explorerTerminal = await createReceiver("bracketed", "dictation-explorer"); - const mainTerminal = await createReceiver("bracketed", "dictation-main"); - - await harness.openTerminal(page, { terminalId: explorerTerminal.id }); - await ensureExplorerSidebar(page); - await dragTabIntoExplorer(page, explorerTerminal); - const explorer = explorerSidebar(page); - await expect( - explorer.getByTestId(`workspace-tab-terminal_${explorerTerminal.id}`), - ).toBeVisible(); - const main = page.getByTestId("workspace-pane-main"); - await selectTerminalTab(main, mainTerminal); - await expect(explorer.getByTestId("terminal-surface")).toBeVisible(); - - let chunk = ws.nextChunk(); - await terminalSurface(main).click(); - await terminalInput(main).press("Meta+d"); - await expect(visibleTestId(main, "terminal-dictation-confirm")).toBeVisible(); - await expect(explorer.getByTestId("terminal-dictation-confirm")).toHaveCount(0); - await chunk; - await terminalInput(main).press("Meta+d"); - await expectReceived(mainTerminal.id, "bracketed", "main pane words "); - await expectNothingReceived(page, [explorerTerminal, "bracketed"]); - - ws.setTranscript("explorer words"); - chunk = ws.nextChunk(); - await terminalSurface(explorer).click(); - await terminalInput(explorer).press("Meta+d"); - await expect(visibleTestId(explorer, "terminal-dictation-confirm")).toBeVisible(); - await expect(main.getByTestId("terminal-dictation-confirm")).toHaveCount(0); - await chunk; - await terminalInput(explorer).press("Meta+d"); - await expectReceived(explorerTerminal.id, "bracketed", "explorer words "); - expect(await received(mainTerminal.id, "bracketed")).toBe("main pane words "); - }); - - test("Cmd+D records only in the focused split pane", async ({ page }) => { - const ws = await setup(page, { transcript: "right pane words" }); - const left = await createReceiver("bracketed", "dictation-split-left"); - const right = await createReceiver("bracketed", "dictation-split-right"); - - await harness.openTerminal(page, { terminalId: left.id }); - await runWorkspaceActionFromCommandCenter(page, "Split pane right"); - await visibleTestId(page, `workspace-tab-terminal_${right.id}`).click(); - await runWorkspaceActionFromCommandCenter(page, "Move tab right"); - const leftPane = splitChild(page, left); - const rightPane = splitChild(page, right); - await expect(terminalInput(leftPane)).toHaveCount(1); - await expect(terminalInput(rightPane)).toHaveCount(1); - - const chunk = ws.nextChunk(); - await terminalSurface(rightPane).click(); - await terminalInput(rightPane).press("Meta+d"); - await expect(visibleTestId(rightPane, "terminal-dictation-confirm")).toBeVisible(); - await expect(leftPane.getByTestId("terminal-dictation-confirm")).toHaveCount(0); - await chunk; - await terminalInput(rightPane).press("Meta+d"); - - await expectReceived(right.id, "bracketed", "right pane words "); - await expectNothingReceived(page, [left, "bracketed"]); - }); - }); - - test("Ctrl+D off macOS reaches the PTY as EOF and starts nothing", async ({ page }) => { - // Chromium keeps the host's navigator.platform unless the override sets it too. - const session = await page.context().newCDPSession(page); - await session.send("Emulation.setUserAgentOverride", { - userAgent: LINUX_USER_AGENT, - platform: "Linux x86_64", - }); - const ws = await setup(page); - const terminal = await createReceiver("raw", "dictation-ctrl-d"); - - await harness.openTerminal(page, { terminalId: terminal.id }); - await terminalInput(page).press("Control+d"); - - await expectReceived(terminal.id, "raw", "\u0004"); - expect(ws.streamStarts()).toBe(0); - expect((await microphoneStats(page)).getUserMediaCalls).toBe(0); - await expect(page.getByTestId("terminal-dictation-confirm")).toHaveCount(0); - }); - - test("switching the pane to another terminal while recording stops it and delivers nothing", async ({ - page, - }) => { - const ws = await setup(page); - const first = await createReceiver("bracketed", "dictation-switch-a"); - const second = await createReceiver("bracketed", "dictation-switch-b"); - - await harness.openTerminal(page, { terminalId: first.id }); - await startFromToolbar(page, ws); - await selectTerminalTab(page, second); - - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - await expect( - page.getByTestId("terminal-dictation-cancel").filter({ visible: true }), - ).toHaveCount(0); - await expectMicrophoneStat(page, "trackStops", 1); - const chunksAfterSwitch = ws.chunks(); - const startsAfterSwitch = ws.streamStarts(); - await expectNothingReceived(page, [first, "bracketed"], [second, "bracketed"]); - expect(ws.chunks()).toBe(chunksAfterSwitch); - expect(ws.streamStarts()).toBe(startsAfterSwitch); - - await selectTerminalTab(page, first); - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - await expect( - page.getByTestId("terminal-dictation-cancel").filter({ visible: true }), - ).toHaveCount(0); - }); - - test("switching the pane to another terminal while processing drops the late transcript", async ({ - page, - }) => { - const ws = await setup(page); - const first = await createReceiver("bracketed", "dictation-processing-a"); - const second = await createReceiver("bracketed", "dictation-processing-b"); - - await harness.openTerminal(page, { terminalId: first.id }); - await startFromToolbar(page, ws); - ws.holdFinal(); - await visibleTestId(page, "terminal-dictation-confirm").click(); - await expect(visibleTestId(page, "terminal-dictation-confirm")).toHaveCount(0); - await expect(visibleTestId(page, "terminal-dictation-cancel")).toBeVisible(); - await selectTerminalTab(page, second); - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - ws.releaseFinal(); - - await expectNothingReceived(page, [first, "bracketed"], [second, "bracketed"]); - }); - - // Fails the first confirm, then retries with the final reply held, so the retry is - // still processing when the test acts on it. - async function startHeldRetry(page: Page, ws: DictationWsHarness) { - ws.failNextFinish("Synthetic transcription failure"); - await startFromToolbar(page, ws); - await visibleTestId(page, "terminal-dictation-confirm").click(); - await expect(visibleTestId(page, "terminal-dictation-retry")).toBeVisible(); - ws.holdFinal(); - await visibleTestId(page, "terminal-dictation-retry").click(); - await expect(visibleTestId(page, "terminal-dictation-retry")).toHaveCount(0); - await expect.poll(() => ws.hasHeldFinal()).toBe(true); - } - - test("cancelling a retry drops its late transcript", async ({ page }) => { - const ws = await setup(page, { transcript: "cancelled retry words" }); - const terminal = await createReceiver("bracketed", "dictation-retry-cancel"); - - await harness.openTerminal(page, { terminalId: terminal.id }); - await startHeldRetry(page, ws); - await visibleTestId(page, "terminal-dictation-cancel").click(); - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - ws.releaseFinal(); - - await expectNothingReceived(page, [terminal, "bracketed"]); - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - }); - - test("hiding a retrying terminal and returning before its reply drops the transcript", async ({ - page, - }) => { - const ws = await setup(page, { transcript: "hidden retry words" }); - const first = await createReceiver("bracketed", "dictation-retry-hide-a"); - const second = await createReceiver("bracketed", "dictation-retry-hide-b"); - - await harness.openTerminal(page, { terminalId: first.id }); - await startHeldRetry(page, ws); - await selectTerminalTab(page, second); - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - // Back on the retained pane, which is presented and still the recording owner. - await selectTerminalTab(page, first); - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - ws.releaseFinal(); - - await expectNothingReceived(page, [first, "bracketed"], [second, "bracketed"]); - }); - - test.describe("on compact web on macOS", () => { - test.use({ userAgent: MAC_USER_AGENT }); - - // Compact web turns shortcuts off except terminal dictation's Cmd+D. - test("Cmd+D starts and then confirms dictation in the focused terminal", async ({ page }) => { - const ws = await setup(page, { transcript: "compact shortcut words" }); - const terminal = await createReceiver("bracketed", "dictation-compact-shortcut"); - - await openCompact(page, terminal); - await terminalSurface(page).click(); - const chunk = ws.nextChunk(); - await terminalInput(page).press("Meta+d"); - await expect(visibleTestId(page, "terminal-dictation-overlay")).toBeVisible(); - await chunk; - await expect(terminalInput(page)).toBeFocused(); - await terminalInput(page).press("Meta+d"); - - await expectReceived(terminal.id, "bracketed", "compact shortcut words "); - await expect(page.getByTestId("terminal-dictation-overlay")).toHaveCount(0); - await expect(visibleTestId(page, "terminal-dictation")).toBeVisible(); - }); - }); - - test.describe("on compact web", () => { - test("the key-bar mic shows the overlay and confirm delivers", async ({ page }) => { - const ws = await setup(page, { transcript: "compact words" }); - const terminal = await createReceiver("bracketed", "dictation-compact-confirm"); - - await openCompact(page, terminal); - const chunk = ws.nextChunk(); - await visibleTestId(page, "terminal-dictation").click(); - const overlay = visibleTestId(page, "terminal-dictation-overlay"); - await expect(overlay).toBeVisible(); - await expect(visibleTestId(page, "terminal-dictation")).toHaveCount(0); - await expect( - overlay.getByRole("button", { name: "Insert transcription and send", exact: true }), - ).toHaveCount(0); - await chunk; - await overlay.getByRole("button", { name: "Insert transcription", exact: true }).click(); - - await expectReceived(terminal.id, "bracketed", "compact words "); - await expect(page.getByTestId("terminal-dictation-overlay")).toHaveCount(0); - await expect(visibleTestId(page, "terminal-dictation")).toBeVisible(); - }); - - test("switching tabs while recording from the key bar stops it and delivers nothing", async ({ - page, - }) => { - const ws = await setup(page); - const first = await createReceiver("bracketed", "dictation-compact-a"); - const second = await createReceiver("bracketed", "dictation-compact-b"); - - await openCompact(page, first); - const chunk = ws.nextChunk(); - await visibleTestId(page, "terminal-dictation").click(); - await expect(visibleTestId(page, "terminal-dictation-overlay")).toBeVisible(); - await chunk; - - await selectCompactTab(page, 1); - await expectMicrophoneStat(page, "trackStops", 1); - await expectNothingReceived(page, [first, "bracketed"], [second, "bracketed"]); - - await selectCompactTab(page, 0); - await expect(visibleTestId(page, "terminal-dictation")).toBeVisible(); - await expect( - page.getByTestId("terminal-dictation-overlay").filter({ visible: true }), - ).toHaveCount(0); - expect(await received(first.id, "bracketed")).toBe(""); - }); - - test("a failed dictation keeps the overlay with retry and discard", async ({ page }) => { - const ws = await setup(page, { transcript: "retried words" }); - const terminal = await createReceiver("bracketed", "dictation-compact-failure"); - - await openCompact(page, terminal); - const overlay = visibleTestId(page, "terminal-dictation-overlay"); - - ws.failNextFinish("Synthetic transcription failure"); - let chunk = ws.nextChunk(); - await visibleTestId(page, "terminal-dictation").click(); - await chunk; - await overlay.getByRole("button", { name: "Insert transcription", exact: true }).click(); - await expect(overlay).toContainText("Dictation failed: Synthetic transcription failure"); - await overlay.getByRole("button", { name: "Retry dictation", exact: true }).click(); - await expectReceived(terminal.id, "bracketed", "retried words "); - await expect(page.getByTestId("terminal-dictation-overlay")).toHaveCount(0); - await expect(visibleTestId(page, "terminal-dictation")).toBeVisible(); - - ws.failNextFinish("Synthetic transcription failure"); - chunk = ws.nextChunk(); - await visibleTestId(page, "terminal-dictation").click(); - await chunk; - await overlay.getByRole("button", { name: "Insert transcription", exact: true }).click(); - await expect(overlay).toContainText("Dictation failed: Synthetic transcription failure"); - await overlay.getByRole("button", { name: "Cancel dictation", exact: true }).click(); - await expect(page.getByTestId("terminal-dictation-overlay")).toHaveCount(0); - await expect(visibleTestId(page, "terminal-dictation")).toBeVisible(); - await page.waitForTimeout(1_500); - expect(await received(terminal.id, "bracketed")).toBe("retried words "); - }); - }); - - for (const scenario of [ - { - name: "a host without the dictation capability asks to update the host", - capability: "missing" as const, - message: "Update the host to use dictation in terminals.", - }, - { - name: "disabled dictation with an empty reason shows the fallback message", - capability: { enabled: false, reason: " " }, - message: "Dictation is not available on this host.", - }, - { - name: "disabled dictation shows the host's reason", - capability: { enabled: false, reason: "No speech model installed" }, - message: "No speech model installed", - }, - ]) { - test(scenario.name, async ({ page }) => { - const ws = await setup(page, { capability: scenario.capability }); - const terminal = await createReceiver("bracketed", "dictation-capability"); - - await harness.openTerminal(page, { terminalId: terminal.id }); - await visibleTestId(page, "terminal-toolbar-dictation").click(); - - await expect(page.getByTestId("app-toast-message")).toHaveText(scenario.message); - await expect(page.getByTestId("terminal-dictation-cancel")).toHaveCount(0); - expect((await microphoneStats(page)).getUserMediaCalls).toBe(0); - expect(ws.streamStarts()).toBe(0); - }); - } - - test("hiding a terminal while its microphone is still being acquired releases it", async ({ - page, - }) => { - const ws = await setup(page); - const first = await createReceiver("bracketed", "dictation-held-hide-a"); - const second = await createReceiver("bracketed", "dictation-held-hide-b"); - - await harness.openTerminal(page, { terminalId: first.id }); - await holdMicrophone(page); - await visibleTestId(page, "terminal-toolbar-dictation").click(); - await expectMicrophoneStat(page, "getUserMediaCalls", 1); - await expect(visibleTestId(page, "terminal-dictation-cancel")).toBeVisible(); - - await selectTerminalTab(page, second); - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - await releaseMicrophone(page); - - await expectMicrophoneStat(page, "trackStops", 1); - await expectNothingReceived(page, [first, "bracketed"], [second, "bracketed"]); - expect(ws.streamStarts()).toBe(0); - expect(ws.chunks()).toBe(0); - - await selectTerminalTab(page, first); - await expect(visibleTestId(page, "terminal-toolbar-dictation")).toBeVisible(); - await expect( - page.getByTestId("terminal-dictation-cancel").filter({ visible: true }), - ).toHaveCount(0); - await expect( - page.getByTestId("terminal-dictation-confirm").filter({ visible: true }), - ).toHaveCount(0); - }); - - test("closing a terminal while its microphone is still being acquired releases it", async ({ - page, - }) => { - const ws = await setup(page); - const first = await createReceiver("bracketed", "dictation-held-close-a"); - const second = await createReceiver("bracketed", "dictation-held-close-b"); - - await harness.openTerminal(page, { terminalId: first.id }); - await holdMicrophone(page); - await visibleTestId(page, "terminal-toolbar-dictation").click(); - await expectMicrophoneStat(page, "getUserMediaCalls", 1); - - // Closing the terminal closes its tab, so its pane unmounts with the start pending. - await harness.killTerminal(first.id); - await expect(page.getByTestId(`workspace-tab-terminal_${first.id}`)).toHaveCount(0); - await releaseMicrophone(page); - - await expectMicrophoneStat(page, "trackStops", 1); - await expectNothingReceived(page, [second, "bracketed"]); - expect(ws.streamStarts()).toBe(0); - expect(ws.chunks()).toBe(0); - }); -}); diff --git a/packages/app/e2e/browser/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts index 11d45dc21f..4b2fc06d41 100644 --- a/packages/app/e2e/browser/terminal-protocol-query.spec.ts +++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import path from "node:path"; -import type { Locator, Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { test, expect } from "../support/fixtures"; import { holdFileUploadResponse } from "../support/helpers/file-upload-gate"; import { installLocalDesktopHost } from "../support/helpers/local-desktop-host"; @@ -32,8 +32,7 @@ setTimeout(finish, 700); // A TUI stand-in: turns on bracketed paste, reads every quoted path in each // bracketed paste, and echoes typed lines, so the PTY output proves what reached -// the terminal and where the file was read from. Each paste body is also echoed -// as PASTED:, whitespace escaped so it survives the capture's line joins. +// the terminal and where the file was read from. const FILE_RECEIVER_SCRIPT = String.raw` const fs = require("node:fs"); const crypto = require("node:crypto"); @@ -85,22 +84,6 @@ function terminalSurface(page: Page) { return page.getByTestId("terminal-surface").filter({ visible: true }); } -function visibleTestId(page: Page, testId: string) { - return page.getByTestId(testId).filter({ visible: true }).first(); -} - -/** The test ID of the toolbar control rendered immediately before `control`. */ -async function controlBefore(control: Locator): Promise { - return await control.evaluate((element) => { - let group = element.parentElement; - while (group && group.querySelectorAll('[role="button"]').length < 2) { - group = group.parentElement; - } - const buttons = Array.from(group?.querySelectorAll('[role="button"]') ?? []); - return buttons[buttons.indexOf(element) - 1]?.getAttribute("data-testid") ?? null; - }); -} - function terminalInput(page: Page) { return terminalSurface(page).locator(".xterm-helper-textarea"); } @@ -297,54 +280,6 @@ test.describe("Terminal protocol queries", () => { } }); - test("upload button uploads picked files when the host is another machine", async ({ page }) => { - const terminal = await openFileReceiver(page, "file-pick-remote"); - const files = [ - { name: "picked.png", mimeType: "image/png", buffer: pngBytes(11) }, - { name: "picked.txt", mimeType: "text/plain", buffer: Buffer.from("picked notes") }, - ]; - try { - const upload = visibleTestId(page, "terminal-toolbar-upload"); - await expect(upload).toHaveCount(1); - // The terminal slot sits right before "...": upload, then the mic. - expect(await controlBefore(visibleTestId(page, "workspace-split-pane-menu"))).toBe( - "terminal-toolbar-dictation", - ); - expect(await controlBefore(visibleTestId(page, "terminal-toolbar-dictation"))).toBe( - "terminal-toolbar-upload", - ); - - const chooser = page.waitForEvent("filechooser"); - await upload.click(); - await (await chooser).setFiles(files); - - await expectTerminalOutput(terminal.id, fileRead(files[0].buffer, "upload")); - await expectTerminalOutput(terminal.id, fileRead(files[1].buffer, "upload")); - await expectSeparatePastedPaths(terminal.id); - } finally { - await harness.killTerminal(terminal.id); - } - }); - - test("shows the upload button only while the pane's active tab is a terminal", async ({ - page, - }) => { - const terminal = await openFileReceiver(page, "file-pick-slot"); - try { - await expect(visibleTestId(page, "terminal-toolbar-upload")).toHaveCount(1); - - await visibleTestId(page, "workspace-new-tab-button").click(); - await visibleTestId(page, "workspace-new-tab-menu-diff").click(); - await expect(visibleTestId(page, "working-diff-panel")).toBeVisible(); - await expect(visibleTestId(page, "terminal-toolbar-upload")).toHaveCount(0); - - await visibleTestId(page, `workspace-tab-terminal_${terminal.id}`).click(); - await expect(visibleTestId(page, "terminal-toolbar-upload")).toHaveCount(1); - } finally { - await harness.killTerminal(terminal.id); - } - }); - test("pastes dropped files' own paths when the host is this machine", async ({ page }) => { const files = [ { name: "local-shot.png", type: "image/png", bytes: pngBytes(9) }, @@ -360,7 +295,6 @@ test.describe("Terminal protocol queries", () => { await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local")); await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local")); - await expectSeparatePastedPaths(terminal.id); expect(await terminalOutput(terminal.id)).not.toContain(":upload"); await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); } finally { diff --git a/packages/app/e2e/support/helpers/dictation-ws.ts b/packages/app/e2e/support/helpers/dictation-ws.ts deleted file mode 100644 index 6dcaf5a778..0000000000 --- a/packages/app/e2e/support/helpers/dictation-ws.ts +++ /dev/null @@ -1,281 +0,0 @@ -import type { Page } from "@playwright/test"; -import { daemonWsRoutePattern } from "./daemon-port"; - -type WebSocketMessage = string | Buffer; - -interface SessionSocket { - send(message: string): void; -} - -/** What the rewritten `server_info` reports for dictation; `missing` drops the voice field. */ -export type DictationCapability = { enabled: boolean; reason: string } | "missing"; - -function parseEnvelope(message: WebSocketMessage): { - type?: unknown; - message?: Record; -} | null { - const raw = typeof message === "string" ? message : message.toString("utf8"); - try { - return JSON.parse(raw) as { type?: unknown; message?: Record }; - } catch { - return null; - } -} - -export function sessionMessage(message: WebSocketMessage): Record | null { - const envelope = parseEnvelope(message); - return envelope?.type === "session" && envelope.message ? envelope.message : null; -} - -export function sendSessionMessage(ws: SessionSocket, message: Record): void { - ws.send(JSON.stringify({ type: "session", message })); -} - -function rewriteDictationCapability( - message: WebSocketMessage, - capability: DictationCapability, -): WebSocketMessage { - const envelope = parseEnvelope(message); - const payload = envelope?.message?.payload; - if ( - envelope?.message?.type !== "status" || - !payload || - typeof payload !== "object" || - (payload as { status?: unknown }).status !== "server_info" - ) { - return message; - } - - const serverInfo = payload as Record; - if (capability === "missing") { - const capabilities = serverInfo.capabilities as Record | undefined; - if (capabilities) { - delete capabilities.voice; - } - } else { - serverInfo.capabilities = { - voice: { - dictation: capability, - voice: { enabled: false, reason: "Realtime voice is disabled in this test." }, - }, - }; - } - return JSON.stringify(envelope); -} - -interface SyntheticMicrophoneState { - getUserMediaCalls: number; - trackStops: number; - hold: boolean; - pending: Array<() => void>; -} - -/** - * Replaces getUserMedia with an oscillator stream, so the app's real web audio - * source runs end to end. Counts calls and stopped tracks, and can hold an - * acquisition until the test releases it (a permission prompt left open). - */ -export async function installSyntheticMicrophone(page: Page): Promise { - await page.addInitScript(() => { - const state: SyntheticMicrophoneState = { - getUserMediaCalls: 0, - trackStops: 0, - hold: false, - pending: [], - }; - ( - window as unknown as { __paseoSyntheticMicrophone: SyntheticMicrophoneState } - ).__paseoSyntheticMicrophone = state; - Object.defineProperty(navigator.mediaDevices, "getUserMedia", { - configurable: true, - value: async () => { - state.getUserMediaCalls += 1; - if (state.hold) { - await new Promise((resolve) => state.pending.push(resolve)); - } - const context = new AudioContext(); - const oscillator = context.createOscillator(); - const destination = context.createMediaStreamDestination(); - oscillator.connect(destination); - oscillator.start(); - for (const track of destination.stream.getTracks()) { - const stop = track.stop.bind(track); - track.stop = () => { - state.trackStops += 1; - stop(); - }; - } - return destination.stream; - }, - }); - }); -} - -export async function microphoneStats( - page: Page, -): Promise<{ getUserMediaCalls: number; trackStops: number }> { - return await page.evaluate(() => { - const state = (window as unknown as { __paseoSyntheticMicrophone: SyntheticMicrophoneState }) - .__paseoSyntheticMicrophone; - return { getUserMediaCalls: state.getUserMediaCalls, trackStops: state.trackStops }; - }); -} - -/** Makes the next getUserMedia calls wait until `releaseMicrophone`. */ -export async function holdMicrophone(page: Page): Promise { - await page.evaluate(() => { - ( - window as unknown as { __paseoSyntheticMicrophone: SyntheticMicrophoneState } - ).__paseoSyntheticMicrophone.hold = true; - }); -} - -export async function releaseMicrophone(page: Page): Promise { - await page.evaluate(() => { - const state = (window as unknown as { __paseoSyntheticMicrophone: SyntheticMicrophoneState }) - .__paseoSyntheticMicrophone; - state.hold = false; - for (const resolve of state.pending.splice(0)) { - resolve(); - } - }); -} - -export interface DictationWsHarness { - /** Every `dictation_stream_start` the app sent. */ - streamStarts(): number; - /** Audio chunks the app sent, across all dictation ids. */ - chunks(): number; - /** Resolves on the first chunk the app sends after this call. */ - nextChunk(): Promise; - /** The final transcript the next finish replies with. */ - setTranscript(text: string): void; - /** Keeps the next final reply until `releaseFinal`. */ - holdFinal(): void; - /** True once the held finish has arrived and its final reply is waiting. */ - hasHeldFinal(): boolean; - releaseFinal(): void; - /** Answers the next finish with a stream error instead of a final. */ - failNextFinish(error: string): void; -} - -/** - * Answers the app's dictation stream in place of the daemon: acknowledges starts - * and chunks, accepts finishes and replies with the scenario's transcript. Rewrites - * `server_info` to the given dictation capability and forwards everything else, so - * terminals, agents and workspaces keep using the real daemon. - * `interceptClientMessage` lets a spec answer other requests itself; return true - * when it handled one. - */ -export async function installDictationWsHarness( - page: Page, - options: { - transcript?: string; - capability?: DictationCapability; - interceptClientMessage?: (request: Record, ws: SessionSocket) => boolean; - } = {}, -): Promise { - const capability = options.capability ?? { enabled: true, reason: "" }; - let transcript = options.transcript ?? ""; - let starts = 0; - let chunkCount = 0; - let chunkWaiters: Array<() => void> = []; - let holdNextFinal = false; - let heldFinal: (() => void) | null = null; - let nextFinishError: string | null = null; - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - - ws.onMessage((message) => { - const request = sessionMessage(message); - const type = request?.type; - const dictationId = typeof request?.dictationId === "string" ? request.dictationId : null; - - if (type === "dictation_stream_start" && dictationId) { - starts += 1; - sendSessionMessage(ws, { - type: "dictation_stream_ack", - payload: { dictationId, ackSeq: -1 }, - }); - return; - } - if (type === "dictation_stream_chunk" && dictationId) { - chunkCount += 1; - const seq = typeof request?.seq === "number" ? request.seq : 0; - sendSessionMessage(ws, { - type: "dictation_stream_ack", - payload: { dictationId, ackSeq: seq }, - }); - const waiters = chunkWaiters; - chunkWaiters = []; - for (const resolve of waiters) { - resolve(); - } - return; - } - if (type === "dictation_stream_finish" && dictationId) { - sendSessionMessage(ws, { - type: "dictation_stream_finish_accepted", - payload: { dictationId, timeoutMs: 30_000 }, - }); - if (nextFinishError !== null) { - const error = nextFinishError; - nextFinishError = null; - sendSessionMessage(ws, { - type: "dictation_stream_error", - payload: { dictationId, error, retryable: true }, - }); - return; - } - const text = transcript; - const reply = () => - sendSessionMessage(ws, { - type: "dictation_stream_final", - payload: { dictationId, text }, - }); - if (holdNextFinal) { - holdNextFinal = false; - heldFinal = reply; - } else { - reply(); - } - return; - } - if (type === "dictation_stream_cancel") { - return; - } - if (request && options.interceptClientMessage?.(request, ws)) { - return; - } - - server.send(message); - }); - - server.onMessage((message) => ws.send(rewriteDictationCapability(message, capability))); - }); - - return { - streamStarts: () => starts, - chunks: () => chunkCount, - nextChunk: () => new Promise((resolve) => chunkWaiters.push(resolve)), - setTranscript: (text) => { - transcript = text; - }, - holdFinal: () => { - holdNextFinal = true; - }, - hasHeldFinal: () => heldFinal !== null, - releaseFinal: () => { - const reply = heldFinal; - heldFinal = null; - if (!reply) { - throw new Error("No final reply is being held"); - } - reply(); - }, - failNextFinish: (error) => { - nextFinishError = error; - }, - }; -} diff --git a/packages/app/src/attachments/selected-file.ts b/packages/app/src/attachments/selected-file.ts index d59fbecd30..f0746ab760 100644 --- a/packages/app/src/attachments/selected-file.ts +++ b/packages/app/src/attachments/selected-file.ts @@ -5,8 +5,6 @@ import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url export interface SelectedFile { fileName: string; mimeType: string; - /** Where the file sits on this machine's disk; set only by the desktop app's file dialog. */ - desktopPath?: string; readBytes(): Promise; } diff --git a/packages/app/src/audio/audio-engine.native.ts b/packages/app/src/audio/audio-engine.native.ts index f14ede49f7..ed9f15a765 100644 --- a/packages/app/src/audio/audio-engine.native.ts +++ b/packages/app/src/audio/audio-engine.native.ts @@ -5,39 +5,18 @@ import type { AudioPlaybackSource, } from "./audio-engine-types"; -import type * as TwoWayAudio from "@getpaseo/expo-two-way-audio"; import { createAudioPlayer, setAudioModeAsync } from "expo-audio"; import { File, Paths } from "expo-file-system"; -import { AttemptCancelledError } from "@/utils/attempt-guard"; import { createPlaybackQueue } from "./playback"; import { playFile } from "./file-playback"; import { playPcm16 } from "./pcm"; -export interface NativeCaptureStartOptions { - /** - * Aborting cancels a start still waiting on microphone permission or native - * initialization: it rejects with AttemptCancelledError and never starts capture. - */ - signal?: AbortSignal; -} - -export interface NativeAudioEngine extends AudioEngine { - startCapture(options?: NativeCaptureStartOptions): Promise; -} - export function createAudioEngine( callbacks: AudioEngineCallbacks, options?: AudioEngineOptions, -): NativeAudioEngine { - return createNativeAudioEngine(require("@getpaseo/expo-two-way-audio"), callbacks, options); -} +): AudioEngine { + const native = require("@getpaseo/expo-two-way-audio"); -/** The engine over a given native module; tests pass a fake of the OS audio APIs. */ -export function createNativeAudioEngine( - native: typeof TwoWayAudio, - callbacks: AudioEngineCallbacks, - options?: AudioEngineOptions, -): NativeAudioEngine { const refs: { initialized: boolean; captureActive: boolean; @@ -93,9 +72,6 @@ export function createNativeAudioEngine( ); async function ensureInitialized(): Promise { - if (refs.destroyed) { - throw new AttemptCancelledError("Audio engine was destroyed"); - } if (refs.initialized) { return; } @@ -103,20 +79,9 @@ export function createNativeAudioEngine( if (!success) { throw new Error("expo-two-way-audio: native initialize() returned false"); } - if (refs.destroyed) { - // Destroyed while initializing: destroy() saw nothing to tear down. - native.tearDown(); - throw new AttemptCancelledError("Audio engine was destroyed"); - } refs.initialized = true; } - function throwIfStartCancelled(signal: AbortSignal | undefined): void { - if (refs.destroyed || signal?.aborted) { - throw new AttemptCancelledError("Audio capture start was cancelled"); - } - } - /** * Release the OS audio session as soon as we are neither capturing nor playing. * Holding it keeps the user's background music paused — on iOS the non-mixing @@ -218,20 +183,14 @@ export function createNativeAudioEngine( interruptionSubscription.remove(); }, - async startCapture(startOptions) { + async startCapture() { if (refs.captureActive) { return; } - const signal = startOptions?.signal; try { - // Permission and initialization can wait on the user or the OS; a destroy or - // an abort in that time must not leave capture running with no owner. - throwIfStartCancelled(signal); await ensureMicrophonePermission(); - throwIfStartCancelled(signal); await ensureInitialized(); - throwIfStartCancelled(signal); const isRecording = options?.captureMode === "dictation" ? native.startDictation() @@ -243,10 +202,6 @@ export function createNativeAudioEngine( } refs.captureActive = true; } catch (error) { - if (error instanceof AttemptCancelledError) { - releaseSessionIfIdle(); - throw error; - } const wrapped = error instanceof Error ? error : new Error(String(error)); callbacks.onError?.(wrapped); throw wrapped; diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index 033f9d40da..2191f0d480 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next"; import { VolumeMeter } from "./volume-meter"; import { FOOTER_HEIGHT } from "@/constants/layout"; import type { DictationStatus } from "@/hooks/use-dictation"; -import { formatDictationDuration } from "@/utils/format-dictation-duration"; interface DictationControlsProps { volume: number; @@ -25,6 +24,12 @@ interface DictationControlsProps { disabled?: boolean; } +function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; +} + export function DictationControls({ volume, duration, @@ -83,7 +88,7 @@ export function DictationControls({ - {status === "paused" ? t("message.dictation.paused") : formatDictationDuration(duration)} + {status === "paused" ? t("message.dictation.paused") : formatDuration(duration)} & { - errorText?: string; - /** Omit to show insert as the only confirm action (terminals never send). */ - onAcceptAndSend?: () => void; - testID?: string; -}) { +}: Omit & { errorText?: string }) { const { theme } = useUnistyles(); const { t } = useTranslation(); const isFailed = status === "failed"; @@ -191,7 +190,7 @@ export function DictationOverlay({ } return ( - + - {formatDictationDuration(duration)} + {formatDuration(duration)} {status === "paused" ? ( @@ -243,7 +242,7 @@ export function DictationOverlay({ ) : null} - {!actionsDisabled && !isFailed && onAcceptAndSend ? ( + {!actionsDisabled && !isFailed ? ( <> ) : null} - {!actionsDisabled && !isFailed && !onAcceptAndSend ? ( - - - - ) : null} ); diff --git a/packages/app/src/components/terminal-copy-paste-actions.tsx b/packages/app/src/components/terminal-copy-paste-actions.tsx index 0370a7c6f1..bf5f86eedb 100644 --- a/packages/app/src/components/terminal-copy-paste-actions.tsx +++ b/packages/app/src/components/terminal-copy-paste-actions.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, type ReactNode } from "react"; +import { useCallback, useMemo } from "react"; import { Pressable, Text, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -45,23 +45,8 @@ export function TerminalFloatingCopyAction({ ); } -export interface TerminalKeyBarIconActionProps { - accessibilityLabel: string; - testID: string; - icon: ReactNode; - disabled?: boolean; - onPress: () => void; -} - -/** A key-bar action shown as an icon, styled like the bar's other keys. */ -export function TerminalKeyBarIconAction(props: TerminalKeyBarIconActionProps) { - return ; -} - interface TerminalActionButtonProps { - /** Text shown when no icon is given. */ - label?: string; - icon?: ReactNode; + label: string; accessibilityLabel: string; testID: string; disabled?: boolean; @@ -71,7 +56,6 @@ interface TerminalActionButtonProps { function TerminalActionButton({ label, - icon, accessibilityLabel, testID, disabled = false, @@ -110,7 +94,7 @@ function TerminalActionButton({ onPress={handlePress} style={pressableStyle} > - {icon ?? {label}} + {label} ); } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index b208ca58b6..6c1561bc5b 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -2,7 +2,7 @@ import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find"; import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useRetainedPanelActive } from "@/components/retained-panel"; -import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as Clipboard from "expo-clipboard"; import { Pressable, @@ -57,28 +57,8 @@ import { useFileDrop } from "@/components/file-drop/use-file-drop"; import type { DroppedItem } from "@/components/file-drop/types"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { getDesktopHost } from "@/desktop/host"; -import { - routeTerminalDrop, - routeTerminalPickedFile, - type TerminalDropDelivery, -} from "@/terminal/drop/terminal-file-drop"; -import { preparePathsForTerminalInput } from "@/terminal/drop/terminal-path"; -import { - useRegisterTerminalInputSink, - type TerminalDictationActions, - type TerminalInputSink, -} from "@/terminal/input-sink"; -import { - TerminalDictationOwner, - type TerminalDictationOwnerActions, -} from "@/terminal/dictation/terminal-dictation-owner"; -import { isTerminalKeyboardOwner } from "@/terminal/dictation/terminal-dictation-lifecycle"; -import { - TerminalKeyBarDictationButton, - TerminalKeyBarDictationGate, -} from "@/terminal/toolbar/terminal-key-bar-dictation"; -import type { SelectedFile } from "@/attachments/selected-file"; -import { TerminalUploadButton } from "@/terminal/toolbar/terminal-upload-button"; +import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop"; +import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path"; import { toErrorMessage } from "@/utils/error-messages"; import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; import { @@ -104,7 +84,7 @@ import { type TerminalResizeRequest, } from "./terminal-resize-debouncer"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { isNative, isWeb } from "@/constants/platform"; +import { isNative } from "@/constants/platform"; import { applyTerminalRendererReadyChange, resolveTerminalStreamTarget, @@ -933,11 +913,11 @@ export function TerminalPane({ }); }); - // Shared by drops and picks: own paths are pasted together at once, uploads go - // through the paste queue in gesture order. - const deliverToTerminal = useStableEvent((deliveries: TerminalDropDelivery[]) => { + const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => { + const bridge = getDesktopHost(); const paths: string[] = []; - for (const delivery of deliveries) { + for (const item of items) { + const delivery = routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge }); if (delivery.kind === "path") { paths.push(delivery.path); } else { @@ -945,55 +925,11 @@ export function TerminalPane({ } } if (paths.length > 0) { - pasteTerminalText(preparePathsForTerminalInput(paths)); + pasteTerminalText(prepareDroppedPathsForTerminal(paths)); } requestTerminalFocus(); }); - const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => { - const bridge = getDesktopHost(); - deliverToTerminal( - items.map((item) => routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge })), - ); - }); - - const deliverPickedFiles = useStableEvent((files: SelectedFile[]) => { - deliverToTerminal( - files.map((file) => routeTerminalPickedFile(file, { hostIsLocal: isLocalHost })), - ); - }); - - // The owner mounts only while connected and remounts per terminal; the sink keeps - // one stable forwarding object and reaches whichever owner is mounted now. - const dictationOwnerId = useId(); - const dictationActionsRef = useRef(null); - const dictationActions = useMemo( - () => ({ - ownerId: dictationOwnerId, - toggle: () => dictationActionsRef.current?.toggle(), - cancel: () => dictationActionsRef.current?.cancel(), - confirm: () => dictationActionsRef.current?.confirm(), - retry: () => dictationActionsRef.current?.retry(), - discard: () => dictationActionsRef.current?.discard(), - }), - [dictationOwnerId], - ); - const terminalSurfaceRef = useRef(null); - const isDictationKeyboardOwner = useStableEvent( - () => isWeb && isTerminalKeyboardOwner(terminalSurfaceRef.current, document.activeElement), - ); - - const inputSink = useMemo( - () => ({ - pasteText: pasteTerminalText, - deliverFiles: deliverPickedFiles, - focus: requestTerminalFocus, - dictation: dictationActions, - }), - [pasteTerminalText, deliverPickedFiles, requestTerminalFocus, dictationActions], - ); - useRegisterTerminalInputSink(serverId, terminalId, inputSink); - const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => { const nextSize = { rows: input.rows, cols: input.cols }; const claim = resolveTerminalResizeClaim({ @@ -1235,10 +1171,6 @@ export function TerminalPane({ onPaste={handleTerminalPaste} /> ) : null; - case "upload": - return ; - case "dictation": - return ; case "keyboardToggle": return ( - - + - - {virtualKeyboardRows.map((row) => ( - - {row.map(renderVirtualKeyboardControl)} - - ))} - + {virtualKeyboardRows.map((row) => ( + + {row.map(renderVirtualKeyboardControl)} + + ))} ) : null} diff --git a/packages/app/src/hooks/use-dictation-audio-source.native.test.tsx b/packages/app/src/hooks/use-dictation-audio-source.native.test.tsx deleted file mode 100644 index cbbf0b609d..0000000000 --- a/packages/app/src/hooks/use-dictation-audio-source.native.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type * as TwoWayAudio from "@getpaseo/expo-two-way-audio"; -import type { AudioEngineCallbacks, AudioEngineOptions } from "@/audio/audio-engine-types"; -import type { UseDictationOptions } from "./use-dictation.shared"; - -// The actual native audio source and engine under useDictation; only the OS audio -// module is fake, with its permission and initialize calls settled by the test. -const os = vi.hoisted(() => { - const state = { - calls: [] as string[], - capturing: false, - initialized: false, - permission: null as null | ((granted: boolean) => void), - initialize: null as null | (() => void), - holdPermission: false, - holdInitialize: false, - }; - return state; -}); - -function createFakeNativeModule() { - return { - addExpoTwoWayAudioEventListener: () => ({ remove: () => {} }), - initialize: () => { - os.calls.push("initialize"); - return new Promise((resolve) => { - const finish = () => { - os.initialized = true; - resolve(true); - }; - if (os.holdInitialize) { - os.initialize = finish; - } else { - finish(); - } - }); - }, - getMicrophonePermissionsAsync: async () => ({ granted: !os.holdPermission }), - requestMicrophonePermissionsAsync: () => - new Promise<{ granted: boolean }>((resolve) => { - os.permission = (granted) => resolve({ granted }); - }), - startDictation: () => { - os.calls.push("startDictation"); - os.capturing = true; - return true; - }, - toggleRecording: (value: boolean) => { - os.calls.push(`toggleRecording:${value}`); - os.capturing = value; - return value; - }, - tearDown: () => { - os.calls.push("tearDown"); - os.capturing = false; - os.initialized = false; - }, - releaseAudioSession: () => {}, - }; -} - -// The engine's file playback is not exercised; these keep Expo's runtime out of Node. -vi.mock("expo-audio", () => ({ createAudioPlayer: vi.fn(), setAudioModeAsync: vi.fn() })); -vi.mock("expo-file-system", () => ({ File: vi.fn(), Paths: {} })); -vi.mock("@/audio", async () => { - const { createNativeAudioEngine } = await import("@/audio/audio-engine.native"); - return { - createAudioEngine: (callbacks: AudioEngineCallbacks, options?: AudioEngineOptions) => - createNativeAudioEngine( - createFakeNativeModule() as unknown as typeof TwoWayAudio, - callbacks, - options, - ), - }; -}); -vi.mock("@/hooks/use-dictation-audio-source", () => import("./use-dictation-audio-source.native")); - -const { useDictation } = await import("./use-dictation"); - -function renderDictation() { - const onError = vi.fn(); - const rendered = renderHook(() => - useDictation({ - client: null as UseDictationOptions["client"], - onTranscript: () => {}, - onError, - }), - ); - return { ...rendered, onError }; -} - -async function flush(): Promise { - await act(async () => { - for (let i = 0; i < 5; i += 1) { - await Promise.resolve(); - } - }); -} - -beforeEach(() => { - os.calls = []; - os.capturing = false; - os.initialized = false; - os.permission = null; - os.initialize = null; - os.holdPermission = false; - os.holdInitialize = false; -}); - -describe("native dictation source pending start", () => { - it("starts capture when the start is not cancelled", async () => { - const { result, onError } = renderDictation(); - - await act(async () => { - await result.current.startDictation(); - }); - - expect(os.calls).toEqual(["initialize", "startDictation"]); - expect(os.capturing).toBe(true); - expect(result.current.isRecording).toBe(true); - expect(onError).not.toHaveBeenCalled(); - }); - - it("does not start capture when permission is granted after the owner unmounts", async () => { - os.holdPermission = true; - const { result, unmount, onError } = renderDictation(); - - let start!: Promise; - act(() => { - start = result.current.startDictation(); - }); - await flush(); - expect(os.permission).not.toBeNull(); - unmount(); - - await act(async () => { - os.permission?.(true); - await start; - }); - - expect(os.calls).not.toContain("startDictation"); - expect(os.capturing).toBe(false); - expect(os.initialized).toBe(false); - expect(onError).not.toHaveBeenCalled(); - }); - - it("does not start capture when permission is granted after a cancel, and starts again", async () => { - os.holdPermission = true; - const { result, onError } = renderDictation(); - - let start!: Promise; - act(() => { - start = result.current.startDictation(); - }); - await flush(); - await act(async () => { - await result.current.cancelDictation(); - }); - await act(async () => { - os.permission?.(true); - await start; - }); - - expect(os.calls).not.toContain("startDictation"); - expect(os.capturing).toBe(false); - expect(result.current.status).toBe("idle"); - expect(onError).not.toHaveBeenCalled(); - - os.holdPermission = false; - await act(async () => { - await result.current.startDictation(); - }); - expect(os.calls.filter((call) => call === "startDictation")).toHaveLength(1); - expect(os.capturing).toBe(true); - expect(result.current.isRecording).toBe(true); - }); - - it("tears the native session down when initialization finishes after the owner unmounts", async () => { - os.holdInitialize = true; - const { result, unmount, onError } = renderDictation(); - - let start!: Promise; - act(() => { - start = result.current.startDictation(); - }); - await flush(); - expect(os.initialize).not.toBeNull(); - unmount(); - - await act(async () => { - os.initialize?.(); - await start; - }); - - expect(os.calls).toEqual(["initialize", "tearDown"]); - expect(os.capturing).toBe(false); - expect(os.initialized).toBe(false); - expect(onError).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/hooks/use-dictation-audio-source.native.ts b/packages/app/src/hooks/use-dictation-audio-source.native.ts index 89e5eadbf2..c34f9bbd02 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.native.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.native.ts @@ -3,7 +3,6 @@ import { Buffer } from "buffer"; import { useState } from "react"; import { createAudioEngine } from "@/audio"; -import { AttemptCancelledError } from "@/utils/attempt-guard"; import type { DictationAudioSource, @@ -17,9 +16,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic const [volume, setVolume] = useState(0); const [isPaused, setIsPaused] = useState(false); const engineRef = useRef | null>(null); - // The start in flight. stop() and unmount abort it, so a permission granted or an - // initialization finished afterwards cannot start capture nobody will stop. - const startAbortRef = useRef(null); const getOrCreateEngine = useCallback(() => { if (engineRef.current) { @@ -54,28 +50,18 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic }, [config.onPcmSegment, config.onError, config.onInterruption]); const start = useCallback(async () => { - startAbortRef.current?.abort(); - const controller = new AbortController(); - startAbortRef.current = controller; const engine = getOrCreateEngine(); await engine.initialize(); - if (controller.signal.aborted) { - throw new AttemptCancelledError("Dictation start was cancelled"); - } - await engine.startCapture({ signal: controller.signal }); + await engine.startCapture(); }, [getOrCreateEngine]); const stop = useCallback(async () => { - startAbortRef.current?.abort(); - startAbortRef.current = null; await engineRef.current?.stopCapture(); setVolume(0); }, []); useEffect(() => { return () => { - startAbortRef.current?.abort(); - startAbortRef.current = null; const engine = engineRef.current; engineRef.current = null; void engine?.destroy().catch(() => undefined); diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts index cead422696..6b71ca7181 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.web.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -2,7 +2,6 @@ import { resampleToPcm16 } from "@/audio/pcm"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { parsePcm16Wav } from "@/utils/pcm16-wav"; import { isElectronRuntime } from "@/desktop/host"; -import { AttemptCancelledError } from "@/utils/attempt-guard"; import type { DictationAudioSource, @@ -155,9 +154,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic started: boolean; mode: "pcm" | "recorder"; recorder: RecorderRefs; - // Advanced by every stop(), so a start still waiting on getUserMedia can tell it - // was stopped and must not install its stream. - startGeneration: number; }>({ stream: null, context: null, @@ -174,7 +170,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic stoppedResolve: null, stoppedReject: null, }, - startGeneration: 0, }); const decodeAudioData = useCallback( @@ -238,8 +233,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic throw new Error("AudioContext unavailable"); } - const startGeneration = refs.current.startGeneration; - const stream = await navigator.mediaDevices.getUserMedia({ + const rawStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, noiseSuppression: true, @@ -247,11 +241,8 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic autoGainControl: true, }, }); - if (refs.current.startGeneration !== startGeneration) { - // Stopped while the permission prompt or device acquisition was pending. - disconnectDictationAudioGraph({ processor: null, source: null, gain: null, stream }); - throw new AttemptCancelledError("Dictation start was cancelled"); - } + + const stream = rawStream; const context = new AudioContextCtor(); @@ -380,7 +371,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic const stop = useCallback(async () => { refs.current.started = false; - refs.current.startGeneration += 1; setVolume(0); const { processor, source, gain, context, stream, pending, mode, recorder } = refs.current; @@ -428,7 +418,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic stoppedResolve: null, stoppedReject: null, }, - startGeneration: refs.current.startGeneration, }; }, [decodeAudioData, emitPcmSegments]); @@ -447,9 +436,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic await start(); } catch (err) { const normalized = err instanceof Error ? err : new Error(String(err)); - if (!(normalized instanceof AttemptCancelledError)) { - onErrorRef.current?.(normalized); - } + onErrorRef.current?.(normalized); throw normalized; } }, diff --git a/packages/app/src/hooks/use-dictation.test.tsx b/packages/app/src/hooks/use-dictation.test.tsx deleted file mode 100644 index f9c108196a..0000000000 --- a/packages/app/src/hooks/use-dictation.test.tsx +++ /dev/null @@ -1,372 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AttemptCancelledError } from "@/utils/attempt-guard"; -import type { UseDictationOptions } from "./use-dictation.shared"; - -// jsdom has no microphone: the source's start() returns a promise the test settles, -// standing in for a permission prompt or a getUserMedia call that is still pending. -const audioSource = vi.hoisted(() => { - const state = { - settleStart: null as null | { resolve: () => void; reject: (error: unknown) => void }, - start: null as unknown as ReturnType, - stop: null as unknown as ReturnType, - onPcmSegment: null as null | ((audio: string) => void), - }; - return state; -}); - -vi.mock("@/hooks/use-dictation-audio-source", () => ({ - useDictationAudioSource: (config: { onPcmSegment: (audio: string) => void }) => { - audioSource.onPcmSegment = config.onPcmSegment; - return { - start: audioSource.start, - stop: audioSource.stop, - volume: 0, - isPaused: false, - }; - }, -})); - -const { useDictation } = await import("./use-dictation"); - -function createFakeClient() { - let rawMessageHandler: ((message: unknown) => void) | null = null; - return { - isConnected: true, - startDictationStream: vi.fn(async () => {}), - // The daemon acknowledges each chunk, which lets finish() stop waiting for delivery. - sendDictationStreamChunk: vi.fn((dictationId: string, seq: number) => { - rawMessageHandler?.({ type: "dictation_stream_ack", payload: { dictationId, ackSeq: seq } }); - }), - finishDictationStream: vi.fn(async (dictationId: string) => ({ dictationId, text: "" })), - cancelDictationStream: vi.fn(), - subscribeRawMessages: vi.fn((handler: (message: unknown) => void) => { - rawMessageHandler = handler; - return () => { - rawMessageHandler = null; - }; - }), - subscribeConnectionStatus: vi.fn(() => () => {}), - on: vi.fn(() => () => {}), - }; -} - -/** A finish reply the test settles, standing in for a transcription still in progress. */ -function holdFinish(client: ReturnType) { - const held: { - resolve: (text: string) => void; - reject: (error: Error) => void; - } = { resolve: () => {}, reject: () => {} }; - client.finishDictationStream.mockImplementationOnce( - (dictationId: string) => - new Promise((resolve, reject) => { - held.resolve = (text) => resolve({ dictationId, text }); - held.reject = reject; - }), - ); - return held; -} - -function renderDictation(client: ReturnType) { - const onError = vi.fn(); - const onTranscript = vi.fn(); - const rendered = renderHook(() => - useDictation({ - client: client as unknown as UseDictationOptions["client"], - onTranscript, - onError, - }), - ); - return { ...rendered, onError, onTranscript }; -} - -async function flush(): Promise { - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); -} - -beforeEach(() => { - audioSource.settleStart = null; - audioSource.start = vi.fn( - () => - new Promise((resolve, reject) => { - audioSource.settleStart = { resolve, reject }; - }), - ); - audioSource.stop = vi.fn(async () => {}); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("useDictation pending startup", () => { - it("cancels a start that is still acquiring the microphone", async () => { - const client = createFakeClient(); - const { result, onError } = renderDictation(client); - - act(() => { - void result.current.startDictation(); - }); - expect(result.current.status).toBe("recording"); - expect(audioSource.settleStart).not.toBeNull(); - - await act(async () => { - await result.current.cancelDictation(); - }); - expect(result.current.status).toBe("idle"); - // The source is stopped while acquisition is pending, so it can release a late stream itself. - expect(audioSource.stop).toHaveBeenCalledTimes(1); - - await act(async () => { - audioSource.settleStart?.resolve(); - }); - await flush(); - - expect(audioSource.stop).toHaveBeenCalledTimes(2); - expect(result.current.isRecording).toBe(false); - expect(result.current.isRecordingActive()).toBe(false); - expect(result.current.status).toBe("idle"); - expect(client.startDictationStream).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - expect(result.current.error).toBeNull(); - }); - - it("treats the source's cancelled acquisition as a silent cancel", async () => { - const client = createFakeClient(); - const { result, onError } = renderDictation(client); - - act(() => { - void result.current.startDictation(); - }); - await act(async () => { - await result.current.cancelDictation(); - }); - await act(async () => { - audioSource.settleStart?.reject(new AttemptCancelledError()); - }); - await flush(); - - expect(result.current.status).toBe("idle"); - expect(result.current.isRecording).toBe(false); - expect(client.startDictationStream).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - expect(result.current.error).toBeNull(); - }); - - it("stops the late-acquired microphone after an unmount during acquisition", async () => { - const client = createFakeClient(); - const { result, unmount, onError } = renderDictation(client); - - act(() => { - void result.current.startDictation(); - }); - unmount(); - const stopsAtUnmount = audioSource.stop.mock.calls.length; - expect(stopsAtUnmount).toBeGreaterThanOrEqual(1); - - await act(async () => { - audioSource.settleStart?.resolve(); - }); - await flush(); - - expect(audioSource.stop.mock.calls.length).toBe(stopsAtUnmount + 1); - expect(client.startDictationStream).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); - - it("records and opens the stream when the start is not cancelled", async () => { - const client = createFakeClient(); - const { result, onError } = renderDictation(client); - - act(() => { - void result.current.startDictation(); - }); - await act(async () => { - audioSource.settleStart?.resolve(); - }); - await flush(); - - expect(result.current.isRecording).toBe(true); - expect(result.current.status).toBe("recording"); - expect(client.startDictationStream).toHaveBeenCalledTimes(1); - expect(audioSource.stop).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); - - it("drops the partial segment the source flushes while a recording is cancelled", async () => { - const client = createFakeClient(); - // Stopping a real source flushes the audio it still holds. - audioSource.stop = vi.fn(async () => { - audioSource.onPcmSegment?.("AAAA"); - }); - const { result } = renderDictation(client); - - act(() => { - void result.current.startDictation(); - }); - await act(async () => { - audioSource.settleStart?.resolve(); - }); - await flush(); - expect(client.startDictationStream).toHaveBeenCalledTimes(1); - - await act(async () => { - await result.current.cancelDictation(); - }); - await flush(); - - expect(result.current.status).toBe("idle"); - expect(client.startDictationStream).toHaveBeenCalledTimes(1); - expect(client.sendDictationStreamChunk).not.toHaveBeenCalled(); - }); -}); - -describe("useDictation retry", () => { - // Records one segment, then lets confirm fail so the attempt waits on retry. - async function failFirstConfirm( - client: ReturnType, - result: { current: ReturnType }, - ) { - act(() => { - void result.current.startDictation(); - }); - await act(async () => { - audioSource.settleStart?.resolve(); - }); - await flush(); - act(() => { - audioSource.onPcmSegment?.("AAAA"); - }); - client.finishDictationStream.mockRejectedValueOnce(new Error("Synthetic failure")); - await act(async () => { - await result.current.confirmDictation(); - }); - expect(result.current.status).toBe("failed"); - } - - it("delivers the transcript of a retry that is not cancelled", async () => { - const client = createFakeClient(); - const { result, onTranscript } = renderDictation(client); - await failFirstConfirm(client, result); - - client.finishDictationStream.mockImplementationOnce(async (dictationId: string) => ({ - dictationId, - text: "retried words", - })); - await act(async () => { - await result.current.retryFailedDictation(); - }); - - expect(onTranscript).toHaveBeenCalledTimes(1); - expect(onTranscript.mock.calls[0]?.[0]).toBe("retried words"); - expect(result.current.status).toBe("idle"); - }); - - it("drops the transcript of a retry cancelled before its reply", async () => { - const client = createFakeClient(); - const { result, onTranscript } = renderDictation(client); - await failFirstConfirm(client, result); - - const held = holdFinish(client); - act(() => { - void result.current.retryFailedDictation(); - }); - await flush(); - expect(result.current.status).toBe("uploading"); - await act(async () => { - await result.current.cancelDictation(); - }); - expect(result.current.status).toBe("idle"); - - await act(async () => { - held.resolve("cancelled retry words"); - }); - await flush(); - - expect(onTranscript).not.toHaveBeenCalled(); - expect(result.current.status).toBe("idle"); - }); - - it("drops the failure of a retry cancelled before its reply", async () => { - const client = createFakeClient(); - const { result, onError } = renderDictation(client); - await failFirstConfirm(client, result); - onError.mockClear(); - - const held = holdFinish(client); - act(() => { - void result.current.retryFailedDictation(); - }); - await flush(); - await act(async () => { - await result.current.cancelDictation(); - }); - - await act(async () => { - held.reject(new Error("Late failure")); - }); - await flush(); - - expect(onError).not.toHaveBeenCalled(); - expect(result.current.status).toBe("idle"); - expect(result.current.error).toBeNull(); - }); - - it("keeps a newer recording when a cancelled retry's reply arrives", async () => { - const client = createFakeClient(); - const { result, onTranscript } = renderDictation(client); - await failFirstConfirm(client, result); - - const held = holdFinish(client); - act(() => { - void result.current.retryFailedDictation(); - }); - await flush(); - await act(async () => { - await result.current.cancelDictation(); - }); - act(() => { - void result.current.startDictation(); - }); - await act(async () => { - audioSource.settleStart?.resolve(); - }); - await flush(); - expect(result.current.status).toBe("recording"); - - await act(async () => { - held.reject(new Error("Late failure")); - }); - await flush(); - - expect(onTranscript).not.toHaveBeenCalled(); - expect(result.current.status).toBe("recording"); - expect(result.current.isRecording).toBe(true); - }); - - it("drops the transcript of a retry when the owner unmounts before its reply", async () => { - const client = createFakeClient(); - const { result, unmount, onTranscript } = renderDictation(client); - await failFirstConfirm(client, result); - - const held = holdFinish(client); - act(() => { - void result.current.retryFailedDictation(); - }); - await flush(); - unmount(); - - await act(async () => { - held.resolve("unmounted retry words"); - }); - await flush(); - - expect(onTranscript).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts index 0d2b825ae4..a2a3f46bcc 100644 --- a/packages/app/src/hooks/use-dictation.ts +++ b/packages/app/src/hooks/use-dictation.ts @@ -70,19 +70,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { const durationIntervalRef = useRef | null>(null); const attemptGuardRef = useRef(new AttemptGuard()); - // Advanced only by cancel and unmount, never by confirm: a confirm while the stream - // is still starting must not make the start tear itself down. - const startGuardRef = useRef(new AttemptGuard()); const actionGateRef = useRef<{ starting: boolean; confirming: boolean; cancelling: boolean }>({ starting: false, confirming: false, cancelling: false, }); - // Audio is accepted from start until a cancel: stopping the source flushes its last - // partial segment, which after a cancel would open a new stream for audio nobody wants. - const acceptAudioRef = useRef(false); - const senderRef = useRef(null); if (!senderRef.current) { senderRef.current = new DictationStreamSender({ @@ -235,9 +228,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { const audio = useDictationAudioSource({ onPcmSegment: (audioData) => { - if (!acceptAudioRef.current) { - return; - } senderRef.current?.enqueueSegment(audioData); }, onError: (err) => { @@ -274,36 +264,15 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { } actionGateRef.current.starting = true; - // A cancel or unmount while the microphone is still being acquired advances the - // start guard; the checks below then release whatever the start acquired late. - const startId = startGuardRef.current.next(); - const abandonStaleStart = async () => { - acceptAudioRef.current = false; - try { - senderRef.current?.cancel(); - } catch { - // no-op - } - await audio.stop().catch(() => undefined); - stopDurationTracking(); - isRecordingRef.current = false; - setIsRecording(false); - setStatus("idle"); - }; setError(null); setPartialTranscript(""); setDuration(0); setIsProcessing(false); setStatus("recording"); clearStreamingState(); - acceptAudioRef.current = true; try { await audio.start(); - if (!startGuardRef.current.isCurrent(startId)) { - await abandonStaleStart(); - return; - } isRecordingRef.current = true; setIsRecording(true); if (enableDuration) { @@ -311,16 +280,8 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { } if (client?.isConnected) { await startNewStream("start"); - if (!startGuardRef.current.isCurrent(startId)) { - await abandonStaleStart(); - return; - } } } catch (err) { - if (!startGuardRef.current.isCurrent(startId)) { - await abandonStaleStart(); - return; - } await audio.stop().catch(() => undefined); stopDurationTracking(); isRecordingRef.current = false; @@ -342,35 +303,15 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { stopDurationTracking, ]); - // The start is still waiting on the microphone. Show idle at once and stop the - // source now, so a stream acquired after this point is released by the source - // itself; the start continuation then sees the stale attempt and tears down the rest. - const cancelPendingStart = useCallback(async () => { - acceptAudioRef.current = false; - stopDurationTracking(); - setDuration(0); - setError(null); - setStatus("idle"); - clearStreamingState(); - await audio.stop().catch((err: unknown) => { - reportError(err, "Failed to stop pending dictation audio"); - }); - }, [audio, clearStreamingState, reportError, stopDurationTracking]); - const cancelDictation = useCallback(async () => { attemptGuardRef.current.cancel(); - startGuardRef.current.cancel(); if (actionGateRef.current.cancelling) { return; } if (!isRecordingRef.current && !isProcessingRef.current) { - if (actionGateRef.current.starting) { - await cancelPendingStart(); - } return; } actionGateRef.current.cancelling = true; - acceptAudioRef.current = false; stopDurationTracking(); setDuration(0); setError(null); @@ -393,7 +334,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { clearStreamingState(); actionGateRef.current.cancelling = false; } - }, [audio, cancelPendingStart, clearStreamingState, reportError, stopDurationTracking]); + }, [audio, clearStreamingState, reportError, stopDurationTracking]); const confirmDictation = useCallback(async () => { if (actionGateRef.current.confirming) { @@ -433,8 +374,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { attemptGuardRef.current.assertCurrent(attemptId); handleStreamingTranscriptionSuccess(transcriptText, generateMessageId()); } catch (err) { - // A failure that lands after a cancel belongs to an abandoned attempt. - if (!attemptGuardRef.current.isCurrent(attemptId)) { + if (err instanceof Error && err.name === "AttemptCancelledError") { return; } handleDictationFailure(err); @@ -451,12 +391,9 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { ]); const retryFailedDictation = useCallback(async () => { - if (isProcessingRef.current || !senderRef.current?.hasSegments()) { + if (!senderRef.current?.hasSegments()) { return; } - // Same attempt rule as confirm: a cancel, unmount or newer attempt while the - // final transcript is pending makes both its result and its failure stale. - const attemptId = attemptGuardRef.current.next(); setError(null); setStatus("uploading"); setIsProcessing(true); @@ -469,10 +406,9 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { senderRef.current.resetStreamForReplay(); const finalSeq = senderRef.current.getFinalSeq(); const text = await ensureFinalTranscript(finalSeq); - attemptGuardRef.current.assertCurrent(attemptId); handleStreamingTranscriptionSuccess(text, generateMessageId()); } catch (err) { - if (!attemptGuardRef.current.isCurrent(attemptId)) { + if (err instanceof Error && err.name === "AttemptCancelledError") { return; } handleDictationFailure(err); @@ -508,11 +444,9 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { useEffect(() => { const attemptGuard = attemptGuardRef.current; - const startGuard = startGuardRef.current; const audioStop = audioStopRef; return () => { attemptGuard.cancel(); - startGuard.cancel(); stopDurationTracking(); void audioStop.current().catch(() => undefined); senderRef.current?.dispose(); diff --git a/packages/app/src/hooks/use-file-picker.ts b/packages/app/src/hooks/use-file-picker.ts index 71b67a2c0f..44f7c6b0db 100644 --- a/packages/app/src/hooks/use-file-picker.ts +++ b/packages/app/src/hooks/use-file-picker.ts @@ -32,12 +32,7 @@ async function pickFilesWithDesktopDialog(): Promise { for (const filePath of paths) { const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath; const mimeType = getMimeTypeFromPath(filePath); - result.push({ - fileName, - mimeType, - desktopPath: filePath, - readBytes: () => readDesktopFileBytes(filePath), - }); + result.push({ fileName, mimeType, readBytes: () => readDesktopFileBytes(filePath) }); } return result; diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index b95a7ae078..6f332b0a98 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -30,7 +30,7 @@ import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useStableEvent } from "@/hooks/use-stable-event"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { isNative } from "@/constants/platform"; -import { dispatchableShortcutBindings, keyboardShortcutsAvailable } from "@/keyboard/availability"; +import { keyboardShortcutsAvailable } from "@/keyboard/availability"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { buildOpenProjectRoute } from "@/utils/host-routes"; @@ -66,10 +66,6 @@ export function useKeyboardShortcuts({ const { overrides } = useKeyboardShortcutOverrides(); const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); const shortcutsAvailable = keyboardShortcutsAvailable({ isNative, isCompact: isMobile }); - const dispatchBindings = useMemo( - () => dispatchableShortcutBindings(bindings, { isNative, isCompact: isMobile }), - [bindings, isMobile], - ); const isDesktopApp = getIsElectronRuntime(); const isMac = getShortcutOs() === "mac"; const chordStateRef = useRef({ @@ -273,7 +269,7 @@ export function useKeyboardShortcuts({ }; publishBrowserShortcutPolicy(); }, - bindings: dispatchBindings, + bindings, }); chordStateRef.current = result.nextChordState; if ( @@ -370,26 +366,6 @@ export function useKeyboardShortcuts({ }); }); - // Compact web dispatches only its `compact` bindings, so it skips the badge and settings - // handling above, which belong to the full set. Overlays keep their own listener. - const handleCompactKeyDown = useStableEvent((event: KeyboardEvent) => { - if (!shouldHandle() || isImeComposingKeyboardEvent(event)) { - return; - } - const store = useKeyboardShortcutsStore.getState(); - if (store.capturingShortcut) { - return; - } - resolveAndPerformShortcut({ - event, - focusScope: resolveKeyboardFocusScope({ - target: event.target, - commandCenterOpen: store.commandCenterOpen, - }), - domEvent: event, - }); - }); - const handleKeyUp = useStableEvent((event: KeyboardEvent) => { const key = event.key ?? ""; if (key === badgeModifierKey) { @@ -410,14 +386,6 @@ export function useKeyboardShortcuts({ }); }); - useEffect(() => { - if (!enabled || shortcutsAvailable || dispatchBindings.length === 0) return; - window.addEventListener("keydown", handleCompactKeyDown, true); - return () => { - window.removeEventListener("keydown", handleCompactKeyDown, true); - }; - }, [dispatchBindings, enabled, handleCompactKeyDown, shortcutsAvailable]); - useEffect(() => { if (!enabled) return; if (!shortcutsAvailable) return; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b375df7044..108fad41d4 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -620,16 +620,6 @@ export const ar: TranslationResources = { fileUploaded: "تم رفع الملف", pasteFileFailed: "تعذر لصق الملف: {{reason}}", uploadingFile: "جارٍ رفع الملف…", - actions: { - uploadFile: "رفع ملف", - startDictation: "بدء الإملاء", - stopDictation: "إيقاف ولصق النص المفرغ", - }, - dictation: { - updateHost: "حدّث المضيف لاستخدام الإملاء في الطرفيات.", - unavailable: "الإملاء غير متاح على هذا المضيف.", - discard: "تجاهل التسجيل", - }, }, sheet: { openAsTab: "فتح كعلامة تبويب", @@ -2614,7 +2604,6 @@ export const ar: TranslationResources = { cycleAgentMode: "تبديل وضع الوكيل", toggleVoiceMode: "تبديل الوضع الصوتي", startStopDictation: "بدء إملاء /stop", - startStopTerminalDictation: "بدء/إيقاف الإملاء في الطرفية", interruptAgent: "عامل المقاطعة", sendMessage: "أرسل رسالة", queueMessage: "رسالة قائمة الانتظار", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index eb86899e63..16bddd9cf9 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -617,16 +617,6 @@ export const en = { fileUploaded: "File uploaded", pasteFileFailed: "Failed to paste file: {{reason}}", uploadingFile: "Uploading file…", - actions: { - uploadFile: "Upload file", - startDictation: "Start dictation", - stopDictation: "Stop and paste transcript", - }, - dictation: { - updateHost: "Update the host to use dictation in terminals.", - unavailable: "Dictation is not available on this host.", - discard: "Discard recording", - }, }, sheet: { openAsTab: "Open as tab", @@ -2701,7 +2691,6 @@ export const en = { cycleAgentMode: "Cycle agent mode", toggleVoiceMode: "Toggle voice mode", startStopDictation: "Start/stop dictation", - startStopTerminalDictation: "Start/stop dictation in terminal", interruptAgent: "Interrupt agent", sendMessage: "Send message", queueMessage: "Queue message", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 364ce81463..2226583781 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -626,16 +626,6 @@ export const es: TranslationResources = { fileUploaded: "Archivo subido", pasteFileFailed: "No se pudo pegar el archivo: {{reason}}", uploadingFile: "Subiendo archivo…", - actions: { - uploadFile: "Subir archivo", - startDictation: "Iniciar dictado", - stopDictation: "Detener y pegar la transcripción", - }, - dictation: { - updateHost: "Actualiza el host para usar el dictado en terminales.", - unavailable: "El dictado no está disponible en este host.", - discard: "Descartar grabación", - }, }, sheet: { openAsTab: "Abrir como pestaña", @@ -2632,7 +2622,6 @@ export const es: TranslationResources = { cycleAgentMode: "Alternar modo del agente", toggleVoiceMode: "Alternar modo de voz", startStopDictation: "Iniciar dictado/stop", - startStopTerminalDictation: "Iniciar/detener dictado en la terminal", interruptAgent: "agente de interrupción", sendMessage: "enviar mensaje", queueMessage: "mensaje de cola", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c11e1edac6..65d72d405b 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -626,16 +626,6 @@ export const fr: TranslationResources = { fileUploaded: "Fichier envoyé", pasteFileFailed: "Impossible de coller le fichier : {{reason}}", uploadingFile: "Envoi du fichier…", - actions: { - uploadFile: "Envoyer un fichier", - startDictation: "Démarrer la dictée", - stopDictation: "Arrêter et coller la transcription", - }, - dictation: { - updateHost: "Mettez à jour l'hôte pour utiliser la dictée dans les terminaux.", - unavailable: "La dictée n'est pas disponible sur cet hôte.", - discard: "Supprimer l'enregistrement", - }, }, sheet: { openAsTab: "Ouvrir dans un onglet", @@ -2633,7 +2623,6 @@ export const fr: TranslationResources = { cycleAgentMode: "Parcourir les modes de l’agent", toggleVoiceMode: "Activer/désactiver le mode vocal", startStopDictation: "Démarrer/arrêter la dictée", - startStopTerminalDictation: "Démarrer/arrêter la dictée dans le terminal", interruptAgent: "Interrompre l’agent", sendMessage: "Envoyer le message", queueMessage: "Mettre le message en file d’attente", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4d7e992334..5829bf4fa3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -626,16 +626,6 @@ export const ja: TranslationResources = { fileUploaded: "ファイルをアップロードしました", pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}", uploadingFile: "ファイルをアップロード中…", - actions: { - uploadFile: "ファイルをアップロード", - startDictation: "音声入力を開始", - stopDictation: "停止して文字起こしを貼り付け", - }, - dictation: { - updateHost: "ターミナルで音声入力を使うにはホストを更新してください。", - unavailable: "このホストでは音声入力を利用できません。", - discard: "録音を破棄", - }, }, sheet: { openAsTab: "タブで開く", @@ -2599,7 +2589,6 @@ export const ja: TranslationResources = { cycleAgentMode: "エージェントモードを順に切り替え", toggleVoiceMode: "音声モードを切り替え", startStopDictation: "音声入力を開始/停止", - startStopTerminalDictation: "ターミナルで音声入力を開始/停止", interruptAgent: "エージェントを中断", sendMessage: "メッセージを送信", queueMessage: "メッセージをキューに追加", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index ae75977259..36a27fc84e 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -621,16 +621,6 @@ export const ko: TranslationResources = { fileUploaded: "파일을 업로드했습니다", pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}", uploadingFile: "파일 업로드 중…", - actions: { - uploadFile: "파일 업로드", - startDictation: "받아쓰기 시작", - stopDictation: "중지하고 받아쓴 내용 붙여넣기", - }, - dictation: { - updateHost: "터미널에서 받아쓰기를 사용하려면 호스트를 업데이트하세요.", - unavailable: "이 호스트에서는 받아쓰기를 사용할 수 없습니다.", - discard: "녹음 삭제", - }, }, sheet: { openAsTab: "탭으로 열기", @@ -2588,7 +2578,6 @@ export const ko: TranslationResources = { cycleAgentMode: "에이전트 모드 전환", toggleVoiceMode: "음성 모드 토글", startStopDictation: "받아쓰기 시작/중지", - startStopTerminalDictation: "터미널에서 받아쓰기 시작/중지", interruptAgent: "에이전트 중단", sendMessage: "메시지 보내기", queueMessage: "메시지 대기열에 추가", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 003ece7202..5a83b4345c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -625,16 +625,6 @@ export const ptBR: TranslationResources = { fileUploaded: "Arquivo enviado", pasteFileFailed: "Falha ao colar o arquivo: {{reason}}", uploadingFile: "Enviando arquivo…", - actions: { - uploadFile: "Enviar arquivo", - startDictation: "Iniciar ditado", - stopDictation: "Parar e colar a transcrição", - }, - dictation: { - updateHost: "Atualize o host para usar o ditado nos terminais.", - unavailable: "O ditado não está disponível neste host.", - discard: "Descartar gravação", - }, }, sheet: { openAsTab: "Abrir como aba", @@ -2613,7 +2603,6 @@ export const ptBR: TranslationResources = { cycleAgentMode: "Alternar modo do agente", toggleVoiceMode: "Alternar modo de voz", startStopDictation: "Iniciar/parar ditado", - startStopTerminalDictation: "Iniciar/parar ditado no terminal", interruptAgent: "Interromper agente", sendMessage: "Enviar mensagem", queueMessage: "Enfileirar mensagem", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index de5837bb0d..135d5fe2f9 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -627,16 +627,6 @@ export const ru: TranslationResources = { fileUploaded: "Файл загружен", pasteFileFailed: "Не удалось вставить файл: {{reason}}", uploadingFile: "Загрузка файла…", - actions: { - uploadFile: "Загрузить файл", - startDictation: "Начать диктовку", - stopDictation: "Остановить и вставить расшифровку", - }, - dictation: { - updateHost: "Обновите хост, чтобы использовать диктовку в терминалах.", - unavailable: "Диктовка недоступна на этом хосте.", - discard: "Удалить запись", - }, }, sheet: { openAsTab: "Открыть во вкладке", @@ -2620,7 +2610,6 @@ export const ru: TranslationResources = { cycleAgentMode: "Переключить режим агента", toggleVoiceMode: "Переключить голосовой режим", startStopDictation: "Начать/остановить диктовку", - startStopTerminalDictation: "Начать/остановить диктовку в терминале", interruptAgent: "Прервать агента", sendMessage: "Отправить сообщение", queueMessage: "Поставить сообщение в очередь", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 4b310cc215..78b0f06442 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -620,16 +620,6 @@ export const zhCN: TranslationResources = { fileUploaded: "文件已上传", pasteFileFailed: "无法粘贴文件:{{reason}}", uploadingFile: "正在上传文件…", - actions: { - uploadFile: "上传文件", - startDictation: "开始听写", - stopDictation: "停止并粘贴转写内容", - }, - dictation: { - updateHost: "请更新主机以在终端中使用听写。", - unavailable: "此主机不支持听写。", - discard: "丢弃录音", - }, }, sheet: { openAsTab: "作为标签页打开", @@ -2550,7 +2540,6 @@ export const zhCN: TranslationResources = { cycleAgentMode: "循环切换代理模式", toggleVoiceMode: "切换语音模式", startStopDictation: "开始/停止听写", - startStopTerminalDictation: "在终端中开始/停止听写", interruptAgent: "中断 Agent", sendMessage: "发送消息", queueMessage: "消息排队", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 0299850b0a..1bf78d923f 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -48,7 +48,6 @@ export type KeyboardActionId = | "command-center.files" | "shortcuts.dialog.toggle" | "workspace.terminal.new" - | "terminal.dictation-toggle" | "workspace.new" | "workspace.project.pick" | "worktree.new" diff --git a/packages/app/src/keyboard/availability.test.ts b/packages/app/src/keyboard/availability.test.ts index f96b8ceff0..282a879006 100644 --- a/packages/app/src/keyboard/availability.test.ts +++ b/packages/app/src/keyboard/availability.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { dispatchableShortcutBindings, keyboardShortcutsAvailable } from "./availability"; +import { keyboardShortcutsAvailable } from "./availability"; describe("keyboardShortcutsAvailable", () => { it("matches the environments where the shortcut dispatcher runs", () => { @@ -9,31 +9,3 @@ describe("keyboardShortcutsAvailable", () => { expect(keyboardShortcutsAvailable({ isNative: true, isCompact: true })).toBe(false); }); }); - -describe("dispatchableShortcutBindings", () => { - const bindings = [ - { id: "everywhere-but-compact" }, - { id: "compact-too", compact: true as const }, - ]; - - it("dispatches every binding on wide web", () => { - expect(dispatchableShortcutBindings(bindings, { isNative: false, isCompact: false })).toEqual( - bindings, - ); - }); - - it("dispatches only the compact bindings on compact web", () => { - expect( - dispatchableShortcutBindings(bindings, { isNative: false, isCompact: true }).map( - (binding) => binding.id, - ), - ).toEqual(["compact-too"]); - }); - - it("dispatches nothing on native, compact or not", () => { - expect(dispatchableShortcutBindings(bindings, { isNative: true, isCompact: false })).toEqual( - [], - ); - expect(dispatchableShortcutBindings(bindings, { isNative: true, isCompact: true })).toEqual([]); - }); -}); diff --git a/packages/app/src/keyboard/availability.ts b/packages/app/src/keyboard/availability.ts index bf286950f4..ec3957f58f 100644 --- a/packages/app/src/keyboard/availability.ts +++ b/packages/app/src/keyboard/availability.ts @@ -13,24 +13,6 @@ export function keyboardShortcutsAvailable({ return !native && !isCompact; } -/** - * The bindings the shortcut dispatcher listens for. Native has no dispatcher. Compact web - * keeps only the bindings marked `compact`; everything else about shortcuts, hints and - * help included, follows `keyboardShortcutsAvailable`. - */ -export function dispatchableShortcutBindings( - bindings: readonly Binding[], - { isNative: native, isCompact }: KeyboardShortcutEnvironment, -): readonly Binding[] { - if (native) { - return []; - } - if (isCompact) { - return bindings.filter((binding) => binding.compact === true); - } - return bindings; -} - export function useKeyboardShortcutsAvailable(): boolean { const isCompact = useIsCompactFormFactor(); return keyboardShortcutsAvailable({ isNative, isCompact }); diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index f6234a1d09..a3f2b4c515 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -44,7 +44,6 @@ export type KeyboardActionId = | "workspace.pane.close" | "workspace.focus.toggle" | "workspace.terminal.new" - | "terminal.dictation-toggle" | "workspace.browser.new" | "sidebar.toggle.right" | "sidebar.toggle.both" @@ -103,7 +102,6 @@ export type KeyboardActionDefinition = | { id: "workspace.pane.close"; scope: KeyboardActionScope } | { id: "workspace.focus.toggle"; scope: KeyboardActionScope } | { id: "workspace.terminal.new"; scope: KeyboardActionScope } - | { id: "terminal.dictation-toggle"; scope: KeyboardActionScope } | { id: "workspace.browser.new"; scope: KeyboardActionScope } | { id: "sidebar.toggle.right"; scope: KeyboardActionScope } | { id: "sidebar.toggle.both"; scope: KeyboardActionScope } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 6168e229d1..770d7e710c 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { formatShortcut } from "@/utils/format-shortcut"; import { keyboardEventToComboString } from "./shortcut-string"; -import { dispatchableShortcutBindings } from "./availability"; import { buildKeyboardShortcutHelpSections, buildEffectiveBindings, @@ -324,12 +323,6 @@ describe("keyboard-shortcuts", () => { action: "message-input.action", payload: { kind: "dictation-toggle" }, }, - { - name: "routes Cmd+D to terminal dictation, not the message input, when terminal is focused on macOS", - event: { key: "d", code: "KeyD", metaKey: true }, - context: { isMac: true, focusScope: "terminal" }, - action: "terminal.dictation-toggle", - }, { name: "routes Shift+Tab to cycle agent mode from the message input", event: { key: "Tab", code: "Tab", shiftKey: true }, @@ -488,14 +481,9 @@ describe("keyboard-shortcuts", () => { context: { isMac: false, focusScope: "terminal" }, }, { - name: "leaves Ctrl+D to the PTY as EOF when terminal is focused on non-mac", - event: { key: "d", code: "KeyD", ctrlKey: true }, - context: { isMac: false, focusScope: "terminal" }, - }, - { - name: "does not bind Cmd+D to terminal dictation on non-mac", + name: "does not route message-input actions when terminal is focused", event: { key: "d", code: "KeyD", metaKey: true }, - context: { isMac: false, focusScope: "terminal" }, + context: { isMac: true, focusScope: "terminal" }, }, { name: "does not cycle agent mode outside the message input", @@ -1423,61 +1411,3 @@ describe("shortcut capture completion", () => { expect(isShortcutCaptureComplete("show-shortcuts", ["Ctrl+0"])).toBe(true); }); }); - -describe("compact web shortcuts", () => { - const compactBindings = dispatchableShortcutBindings(buildEffectiveBindings({}), { - isNative: false, - isCompact: true, - }); - - it("keeps only terminal dictation's Cmd+D", () => { - expect(compactBindings.map((binding) => binding.id)).toEqual([ - "terminal-dictation-toggle-cmd-d-mac", - ]); - }); - - it("routes Cmd+D in a focused terminal on macOS", () => { - const result = resolveShortcut({ - event: { key: "d", code: "KeyD", metaKey: true }, - context: { isMac: true, focusScope: "terminal" }, - bindings: compactBindings, - }); - expect(result.match?.action).toBe("terminal.dictation-toggle"); - }); - - it("leaves Ctrl+D off macOS to the PTY", () => { - const result = resolveShortcut({ - event: { key: "d", code: "KeyD", ctrlKey: true }, - context: { isMac: false, focusScope: "terminal" }, - bindings: compactBindings, - }); - expect(result.match).toBeNull(); - expect(result.preventDefault).toBe(false); - }); - - it.each([ - { - name: "the composer's Cmd+D", - event: { key: "d", code: "KeyD", metaKey: true }, - focusScope: "message-input" as const, - }, - { - name: "the command center", - event: { key: "k", code: "KeyK", metaKey: true }, - focusScope: "other" as const, - }, - { - name: "agent interrupt", - event: { key: "Escape", code: "Escape" }, - focusScope: "message-input" as const, - }, - ])("keeps $name off", ({ event, focusScope }) => { - const result = resolveShortcut({ - event, - context: { isMac: true, focusScope }, - bindings: compactBindings, - }); - expect(result.match).toBeNull(); - expect(result.preventDefault).toBe(false); - }); -}); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index bbada864c0..9f6329c013 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -111,11 +111,6 @@ interface ShortcutBinding { preventDefault?: boolean; stopPropagation?: boolean; help?: ShortcutHelp; - /** - * Also dispatched on compact web, where every other shortcut is off. Only for a key a - * hardware keyboard user at a narrow width has no other way to reach. - */ - compact?: true; } export interface ParsedShortcutBinding extends ShortcutBinding { @@ -204,7 +199,6 @@ export const SHORTCUT_HELP_ROW_ORDER: Record = { "cycle-agent-mode": "settings.shortcuts.help.cycleAgentMode", "voice-toggle": "settings.shortcuts.help.toggleVoiceMode", "dictation-toggle": "settings.shortcuts.help.startStopDictation", - "terminal-dictation-toggle": "settings.shortcuts.help.startStopTerminalDictation", "agent-interrupt": "settings.shortcuts.help.interruptAgent", "voice-mute-toggle": "settings.shortcuts.help.muteUnmuteVoiceMode", }; @@ -1143,20 +1136,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ label: "Start/stop dictation", }, }, - // Terminals take Cmd+D on macOS only: elsewhere Ctrl+D is the PTY's EOF and passes through. - { - id: "terminal-dictation-toggle-cmd-d-mac", - action: "terminal.dictation-toggle", - combo: "Cmd+D", - when: { mac: true, commandCenter: false, focusScope: "terminal" }, - // A narrow browser window has no tab bar mic, only the key bar's. - compact: true, - help: { - id: "terminal-dictation-toggle", - section: "agent-input", - label: "Start/stop dictation in terminal", - }, - }, { id: "agent-interrupt", action: "agent.interrupt", diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index 0a3d49eb32..5eddf3eeff 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -39,7 +39,6 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => { ["workspace.pin", { id: "workspace.pin", scope: "sidebar" }], ["worktree.new", { id: "worktree.new", scope: "sidebar" }], ["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }], - ["terminal.dictation-toggle", { id: "terminal.dictation-toggle", scope: "workspace" }], ["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }], ["sidebar.toggle.right", { id: "sidebar.toggle.right", scope: "sidebar" }], ["workspace.pane.split.right", { id: "workspace.pane.split.right", scope: "workspace" }], diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index a1fa5b41ba..aa7679a7bf 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -51,7 +51,6 @@ const PASSTHROUGH_DISPATCH: Record = { "workspace.pin": { id: "workspace.pin", scope: "sidebar" }, "worktree.new": { id: "worktree.new", scope: "sidebar" }, "workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" }, - "terminal.dictation-toggle": { id: "terminal.dictation-toggle", scope: "workspace" }, "workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" }, "sidebar.toggle.right": { id: "sidebar.toggle.right", scope: "sidebar" }, "workspace.pane.split.right": { id: "workspace.pane.split.right", scope: "workspace" }, diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 2fa403f0b0..f7645ac168 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -8,7 +8,6 @@ import React, { useRef, useState, type Dispatch, - type ReactNode, type SetStateAction, } from "react"; import { Pressable, Text, View, type LayoutChangeEvent, type ScrollView } from "react-native"; @@ -74,7 +73,6 @@ import { type WorkspaceTabRailSurface, } from "@/screens/workspace/workspace-tab-chip-backdrop"; import type { Theme } from "@/styles/theme"; -import { TerminalToolbarSlot } from "@/terminal/toolbar/terminal-toolbar-slot"; import { RenderProfile } from "@/utils/render-profiler"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; @@ -105,12 +103,6 @@ const PANE_SPLIT_ACTIONS_RESERVED_WIDTH = PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING * 2 + PANE_SPLIT_ACTIONS_OUTER_MARGIN; const PANE_MAXIMIZE_ACTION_RESERVED_WIDTH = smallIconButtonChromeFrameSize(false) + 1; -// The toolbar's gap between controls (ToolbarControls). -const TOOLBAR_CONTROL_GAP = 1; -// The terminal slot's width before its first layout: two icon buttons (upload, mic). The slot -// is measured afterwards, because the mic grows into a pill while the terminal dictates and the -// row itself keeps its width when that happens. -const TERMINAL_SLOT_IDLE_WIDTH = 2 * smallIconButtonChromeFrameSize(false) + TOOLBAR_CONTROL_GAP; // Chip geometry. `layoutMetrics` measures tabs from these same numbers, so a chip that changes // shape without changing them mis-measures and drops the row into the overflow-scroll fallback at // the wrong width. Keep them together. @@ -286,7 +278,6 @@ function WorkspacePaneToolbarActions({ onSplitRight, onSplitDown, onTogglePaneMaximized, - terminalSlot, }: { panePanelKinds: PanePanelKinds; host: PaneHost; @@ -301,8 +292,6 @@ function WorkspacePaneToolbarActions({ onSplitRight?: () => void; onSplitDown?: () => void; onTogglePaneMaximized?: () => void; - /** The active terminal tab's controls, rendered immediately before "...". */ - terminalSlot?: ReactNode; }) { const { t } = useTranslation(); const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); @@ -325,9 +314,7 @@ function WorkspacePaneToolbarActions({ [splitDownKeys], ); const maximizeActionVisible = showMaximizeAction && Boolean(onTogglePaneMaximized); - if (!showNewTabButton && !splitActionsVisible && !maximizeActionVisible && !terminalSlot) { - return null; - } + if (!showNewTabButton && !splitActionsVisible && !maximizeActionVisible) return null; return ( @@ -360,7 +347,6 @@ function WorkspacePaneToolbarActions({ )} ) : null} - {terminalSlot} {splitActionsVisible && onSplitRight && onSplitDown ? ( item.isActive)?.tab; - return activeTab?.target.kind === "terminal" ? activeTab.target.terminalId : null; -} - function ResolvedWorkspaceDesktopTabsRow({ host = "main", launchPurpose = "primary", @@ -1096,26 +1072,6 @@ function ResolvedWorkspaceDesktopTabsRow({ updateMeasuredWidth(setExitFocusModeWidth, event); }, []); - const [terminalSlotWidth, setTerminalSlotWidth] = useState(TERMINAL_SLOT_IDLE_WIDTH); - const handleTerminalSlotLayout = useCallback((event: LayoutChangeEvent) => { - updateMeasuredWidth(setTerminalSlotWidth, event); - }, []); - - const activeTerminalId = resolveSlotTerminalId(tabs); - const terminalSlot = useMemo( - () => - activeTerminalId ? ( - - - - ) : null, - [activeTerminalId, handleTerminalSlotLayout, normalizedServerId], - ); - const layoutMetrics = useMemo( () => ({ rowHorizontalInset: 0, @@ -1124,8 +1080,7 @@ function ResolvedWorkspaceDesktopTabsRow({ DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH + (focusModeEnabled ? exitFocusModeWidth : 0) + (showPaneSplitActions ? PANE_SPLIT_ACTIONS_RESERVED_WIDTH : 0) + - (showPaneMaximizeAction ? PANE_MAXIMIZE_ACTION_RESERVED_WIDTH : 0) + - (activeTerminalId ? terminalSlotWidth + TOOLBAR_CONTROL_GAP : 0), + (showPaneMaximizeAction ? PANE_MAXIMIZE_ACTION_RESERVED_WIDTH : 0), ), rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, tabGap: TAB_CHIP_GAP, @@ -1136,14 +1091,7 @@ function ResolvedWorkspaceDesktopTabsRow({ tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING, closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH, }), - [ - activeTerminalId, - exitFocusModeWidth, - focusModeEnabled, - showPaneMaximizeAction, - showPaneSplitActions, - terminalSlotWidth, - ], + [exitFocusModeWidth, focusModeEnabled, showPaneMaximizeAction, showPaneSplitActions], ); const panePanelKinds = useMemo(() => tabs.map(({ tab }) => tab.kind), [tabs]); @@ -1498,7 +1446,6 @@ function ResolvedWorkspaceDesktopTabsRow({ onSplitRight={onSplitRight} onSplitDown={onSplitDown} onTogglePaneMaximized={onTogglePaneMaximized} - terminalSlot={terminalSlot} /> ); @@ -1699,11 +1646,6 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING, marginRight: PANE_SPLIT_ACTIONS_OUTER_MARGIN, }, - terminalSlot: { - flexDirection: "row", - alignItems: "center", - gap: TOOLBAR_CONTROL_GAP, - }, tab: { height: buttonControlHeight.xs, paddingHorizontal: TAB_CHIP_HORIZONTAL_PADDING, diff --git a/packages/app/src/terminal/dictation/format-terminal-transcript.test.ts b/packages/app/src/terminal/dictation/format-terminal-transcript.test.ts deleted file mode 100644 index 8a50c20a4c..0000000000 --- a/packages/app/src/terminal/dictation/format-terminal-transcript.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { formatTerminalTranscript } from "./format-terminal-transcript"; - -describe("formatTerminalTranscript", () => { - it.each([ - ["first\nsecond", "first second "], - ["first\r\nsecond", "first second "], - ["first\rsecond", "first second "], - ["\n hello \r\n", "hello "], - ["héllo 日本語", "héllo 日本語 "], - ])("formats %j as %j", (input, expected) => { - expect(formatTerminalTranscript(input)).toBe(expected); - }); - - it.each(["", " ", "\r\n"])("returns null for %j", (input) => { - expect(formatTerminalTranscript(input)).toBeNull(); - }); -}); diff --git a/packages/app/src/terminal/dictation/format-terminal-transcript.ts b/packages/app/src/terminal/dictation/format-terminal-transcript.ts deleted file mode 100644 index 53147f6036..0000000000 --- a/packages/app/src/terminal/dictation/format-terminal-transcript.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Turns a dictation transcript into terminal input: embedded line breaks become - * spaces (a newline would submit the line in most TUIs), the ends are trimmed, and - * one trailing space separates it from whatever is typed next. Never adds Enter. - * Returns null when nothing is left to paste. - */ -export function formatTerminalTranscript(text: string): string | null { - const singleLine = text.replace(/\r\n|\r|\n/g, " ").trim(); - return singleLine.length > 0 ? `${singleLine} ` : null; -} diff --git a/packages/app/src/terminal/dictation/terminal-dictation-availability.test.ts b/packages/app/src/terminal/dictation/terminal-dictation-availability.test.ts deleted file mode 100644 index 9e44d4652d..0000000000 --- a/packages/app/src/terminal/dictation/terminal-dictation-availability.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DaemonServerInfo } from "@/stores/session-store"; -import { resolveTerminalDictationAvailability } from "./terminal-dictation-availability"; - -function serverInfo(capabilities?: DaemonServerInfo["capabilities"]): DaemonServerInfo { - return { - serverId: "server-a", - hostname: null, - version: "0.13.2", - ...(capabilities ? { capabilities } : {}), - }; -} - -function voice(dictation: { enabled: boolean; reason: string }): DaemonServerInfo["capabilities"] { - return { voice: { dictation, voice: { enabled: false, reason: "" } } }; -} - -describe("resolveTerminalDictationAvailability", () => { - it("is disconnected while the host is not connected, whatever it reports", () => { - expect( - resolveTerminalDictationAvailability({ - serverInfo: serverInfo(voice({ enabled: true, reason: "" })), - isConnected: false, - }), - ).toEqual({ kind: "disconnected" }); - }); - - it("asks to update a host that reports no capabilities", () => { - expect( - resolveTerminalDictationAvailability({ serverInfo: serverInfo(), isConnected: true }), - ).toEqual({ kind: "unavailable", reason: "update-host" }); - expect(resolveTerminalDictationAvailability({ serverInfo: null, isConnected: true })).toEqual({ - kind: "unavailable", - reason: "update-host", - }); - }); - - it("asks to update a host whose capabilities have no voice field", () => { - expect( - resolveTerminalDictationAvailability({ serverInfo: serverInfo({}), isConnected: true }), - ).toEqual({ kind: "unavailable", reason: "update-host" }); - }); - - it("carries the host's trimmed reason when dictation is disabled", () => { - expect( - resolveTerminalDictationAvailability({ - serverInfo: serverInfo(voice({ enabled: false, reason: " No speech model installed " })), - isConnected: true, - }), - ).toEqual({ kind: "unavailable", reason: "disabled", message: "No speech model installed" }); - }); - - it.each(["", " "])("leaves the message empty for a disabled reason of %j", (reason) => { - expect( - resolveTerminalDictationAvailability({ - serverInfo: serverInfo(voice({ enabled: false, reason })), - isConnected: true, - }), - ).toEqual({ kind: "unavailable", reason: "disabled", message: null }); - }); - - it("is ready when the host enables dictation", () => { - expect( - resolveTerminalDictationAvailability({ - serverInfo: serverInfo(voice({ enabled: true, reason: "" })), - isConnected: true, - }), - ).toEqual({ kind: "ready" }); - }); -}); diff --git a/packages/app/src/terminal/dictation/terminal-dictation-availability.ts b/packages/app/src/terminal/dictation/terminal-dictation-availability.ts deleted file mode 100644 index 647d8e90c4..0000000000 --- a/packages/app/src/terminal/dictation/terminal-dictation-availability.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { DaemonServerInfo } from "@/stores/session-store"; -import { getVoiceReadinessState } from "@/utils/server-info-capabilities"; - -export type TerminalDictationAvailability = - | { kind: "ready" } - | { kind: "disconnected" } - | { kind: "unavailable"; reason: "update-host" } - | { kind: "unavailable"; reason: "disabled"; message: string | null }; - -/** - * Whether a terminal can dictate on this host. Gates on the host's explicit - * dictation capability only: a host too old to report it is told to update, so a - * terminal never falls back to the composer's agent-directory heuristic. - */ -export function resolveTerminalDictationAvailability(input: { - serverInfo: DaemonServerInfo | null | undefined; - isConnected: boolean; -}): TerminalDictationAvailability { - if (!input.isConnected) { - return { kind: "disconnected" }; - } - const dictation = getVoiceReadinessState({ serverInfo: input.serverInfo, mode: "dictation" }); - if (!dictation) { - return { kind: "unavailable", reason: "update-host" }; - } - if (!dictation.enabled) { - const message = dictation.reason.trim(); - return { - kind: "unavailable", - reason: "disabled", - message: message.length > 0 ? message : null, - }; - } - return { kind: "ready" }; -} diff --git a/packages/app/src/terminal/dictation/terminal-dictation-lifecycle.test.ts b/packages/app/src/terminal/dictation/terminal-dictation-lifecycle.test.ts deleted file mode 100644 index aedbb4adf4..0000000000 --- a/packages/app/src/terminal/dictation/terminal-dictation-lifecycle.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { describe, expect, it } from "vitest"; -import type { DictationStatus } from "@/hooks/use-dictation"; -import { - isTerminalKeyboardOwner, - resolveTerminalDictationLifecycle, -} from "./terminal-dictation-lifecycle"; - -describe("resolveTerminalDictationLifecycle", () => { - const active: DictationStatus[] = ["recording", "paused", "uploading"]; - - it("keeps an idle terminal whatever happens to it", () => { - expect( - resolveTerminalDictationLifecycle({ - isPresented: false, - isRecordingOwner: false, - status: "idle", - }), - ).toBe("keep"); - }); - - it.each(active)("keeps a presented recording owner while %s", (status) => { - expect( - resolveTerminalDictationLifecycle({ isPresented: true, isRecordingOwner: true, status }), - ).toBe("keep"); - }); - - it.each(active)("cancels when the terminal stops being presented while %s", (status) => { - expect( - resolveTerminalDictationLifecycle({ isPresented: false, isRecordingOwner: true, status }), - ).toBe("cancel"); - }); - - it.each(active)("cancels when another terminal takes the recording while %s", (status) => { - expect( - resolveTerminalDictationLifecycle({ isPresented: true, isRecordingOwner: false, status }), - ).toBe("cancel"); - }); - - it("discards a failed attempt once hidden or superseded", () => { - expect( - resolveTerminalDictationLifecycle({ - isPresented: false, - isRecordingOwner: true, - status: "failed", - }), - ).toBe("discard"); - expect( - resolveTerminalDictationLifecycle({ - isPresented: true, - isRecordingOwner: false, - status: "failed", - }), - ).toBe("discard"); - }); -}); - -describe("isTerminalKeyboardOwner", () => { - function surfaceWithInput() { - const surface = document.createElement("div"); - const input = document.createElement("textarea"); - surface.append(input); - document.body.append(surface); - return { surface, input }; - } - - it("is true when focus is inside the surface", () => { - const { surface, input } = surfaceWithInput(); - expect(isTerminalKeyboardOwner(surface, input)).toBe(true); - surface.remove(); - }); - - it("is false when focus is in another surface", () => { - const first = surfaceWithInput(); - const second = surfaceWithInput(); - expect(isTerminalKeyboardOwner(first.surface, second.input)).toBe(false); - first.surface.remove(); - second.surface.remove(); - }); - - it("is false without a surface or a focused element", () => { - const { surface, input } = surfaceWithInput(); - expect(isTerminalKeyboardOwner(surface, null)).toBe(false); - expect(isTerminalKeyboardOwner(null, input)).toBe(false); - surface.remove(); - }); -}); diff --git a/packages/app/src/terminal/dictation/terminal-dictation-lifecycle.ts b/packages/app/src/terminal/dictation/terminal-dictation-lifecycle.ts deleted file mode 100644 index ec2a9b55ac..0000000000 --- a/packages/app/src/terminal/dictation/terminal-dictation-lifecycle.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DictationStatus } from "@/hooks/use-dictation"; - -export type TerminalDictationLifecycleDecision = "keep" | "cancel" | "discard"; - -/** - * What a terminal's dictation does when its pane stops being presented (another - * tab or terminal selected, workspace left, explorer closed) or another terminal - * takes the recording. Recording, paused and processing are cancelled, a failed - * attempt is discarded. Status `recording` also covers a start still acquiring - * the microphone, because the hook sets it before awaiting the source. - */ -export function resolveTerminalDictationLifecycle(input: { - isPresented: boolean; - isRecordingOwner: boolean; - status: DictationStatus; -}): TerminalDictationLifecycleDecision { - if (input.status === "idle") { - return "keep"; - } - if (input.isPresented && input.isRecordingOwner) { - return "keep"; - } - return input.status === "failed" ? "discard" : "cancel"; -} - -/** - * True when DOM keyboard focus is inside this pane's terminal surface: the same - * focus that gives a shortcut the `terminal` scope, so exactly one pane owns it. - */ -export function isTerminalKeyboardOwner(surface: unknown, activeElement: unknown): boolean { - if (typeof Element === "undefined") { - return false; - } - return ( - surface instanceof Element && - activeElement instanceof Element && - surface.contains(activeElement) - ); -} diff --git a/packages/app/src/terminal/dictation/terminal-dictation-owner.tsx b/packages/app/src/terminal/dictation/terminal-dictation-owner.tsx deleted file mode 100644 index b21a38be27..0000000000 --- a/packages/app/src/terminal/dictation/terminal-dictation-owner.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { useEffect, useRef, type MutableRefObject } from "react"; -import { useTranslation } from "react-i18next"; -import { useToast } from "@/contexts/toast-context"; -import { useDictation } from "@/hooks/use-dictation"; -import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useStableEvent } from "@/hooks/use-stable-event"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { - claimTerminalRecording, - clearTerminalDictationState, - getTerminalRecordingOwnerId, - publishTerminalDictationState, - releaseTerminalRecording, - useTerminalRecordingOwnerId, - type TerminalDictationActions, -} from "@/terminal/input-sink"; -import { formatTerminalTranscript } from "./format-terminal-transcript"; -import { - resolveTerminalDictationAvailability, - type TerminalDictationAvailability, -} from "./terminal-dictation-availability"; -import { resolveTerminalDictationLifecycle } from "./terminal-dictation-lifecycle"; - -export type TerminalDictationOwnerActions = Omit; - -interface TerminalDictationOwnerProps { - ownerId: string; - serverId: string; - terminalId: string; - /** The pane's terminal is the one on screen: its tab is selected and its workspace focused. */ - isPresented: boolean; - pasteText: (text: string) => void; - focus: () => void; - /** Evaluated when the shortcut fires: true while DOM focus is inside this pane's terminal. */ - isKeyboardOwner: () => boolean; - /** Where the owner hands its actions to the pane's input sink. */ - actionsRef: MutableRefObject; -} - -/** - * Owns one terminal pane's dictation. Mounted inside the pane and keyed by its - * terminal, so a transcript can only reach the pane that recorded it; the tab-bar - * control and the key bar are views of the state it publishes. Renders nothing, - * and lives apart from the pane so volume ticks re-render only this component. - * - * The recording ends, microphone and pending result included, when the pane - * unmounts or changes terminal (remount through the key), when the terminal stops - * being presented, and when another terminal starts recording. - */ -export function TerminalDictationOwner({ - ownerId, - serverId, - terminalId, - isPresented, - pasteText, - focus, - isKeyboardOwner, - actionsRef, -}: TerminalDictationOwnerProps) { - const { t } = useTranslation(); - const toast = useToast(); - const client = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); - const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null); - const recordingOwnerId = useTerminalRecordingOwnerId(); - const isPresentedRef = useRef(isPresented); - isPresentedRef.current = isPresented; - - const handleTranscript = useStableEvent((text: string) => { - // A result resolving in the same tick as a cancel: drop it unless this pane is - // still on screen and still the recording terminal. - if (!isPresentedRef.current || getTerminalRecordingOwnerId() !== ownerId) { - return; - } - const formatted = formatTerminalTranscript(text); - if (formatted !== null) { - pasteText(formatted); - } - focus(); - }); - const handleError = useStableEvent((error: Error) => { - toast.error(error.message); - }); - - const dictation = useDictation({ - client, - onTranscript: handleTranscript, - onError: handleError, - enableDuration: true, - }); - const statusRef = useRef(dictation.status); - statusRef.current = dictation.status; - - const unavailableMessage = ( - availability: Exclude, - ) => { - if (availability.kind === "disconnected") { - return t("workspace.terminal.hostDisconnected"); - } - if (availability.reason === "update-host") { - return t("workspace.terminal.dictation.updateHost"); - } - return availability.message ?? t("workspace.terminal.dictation.unavailable"); - }; - - const toggle = useStableEvent(() => { - if (!isPresentedRef.current) { - return; - } - const status = statusRef.current; - if (status === "recording" || status === "paused") { - void dictation.confirmDictation(); - return; - } - if (status !== "idle") { - return; - } - const availability = resolveTerminalDictationAvailability({ serverInfo, isConnected }); - if (availability.kind !== "ready") { - toast.error(unavailableMessage(availability)); - return; - } - claimTerminalRecording(ownerId); - void dictation.startDictation(); - }); - const cancel = useStableEvent(() => { - void dictation.cancelDictation(); - focus(); - }); - const confirm = useStableEvent(() => { - void dictation.confirmDictation(); - }); - const retry = useStableEvent(() => { - void dictation.retryFailedDictation(); - }); - const discard = useStableEvent(() => { - dictation.discardFailedDictation(); - focus(); - }); - - useEffect(() => { - const actions: TerminalDictationOwnerActions = { toggle, cancel, confirm, retry, discard }; - actionsRef.current = actions; - return () => { - if (actionsRef.current === actions) { - actionsRef.current = null; - } - }; - }, [actionsRef, cancel, confirm, discard, retry, toggle]); - - // Only a change of presentation or recording owner ends the recording, so the - // status is read at that moment instead of re-running on every status change. - const applyLifecycle = useStableEvent( - (input: { isPresented: boolean; isRecordingOwner: boolean }) => { - const decision = resolveTerminalDictationLifecycle({ ...input, status: statusRef.current }); - if (decision === "cancel") { - void dictation.cancelDictation(); - } else if (decision === "discard") { - dictation.discardFailedDictation(); - } - }, - ); - useEffect(() => { - applyLifecycle({ isPresented, isRecordingOwner: recordingOwnerId === ownerId }); - }, [applyLifecycle, isPresented, ownerId, recordingOwnerId]); - - useEffect(() => { - return () => releaseTerminalRecording(ownerId); - }, [ownerId]); - - const { status, isRecording, isProcessing, error, duration, volume } = dictation; - useEffect(() => { - publishTerminalDictationState(ownerId, { - status, - isRecording, - isProcessing, - error, - duration, - volume, - }); - }, [duration, error, isProcessing, isRecording, ownerId, status, volume]); - useEffect(() => () => clearTerminalDictationState(ownerId), [ownerId]); - - useKeyboardActionHandler({ - handlerId: `terminal-dictation:${serverId}:${terminalId}:${ownerId}`, - actions: ["terminal.dictation-toggle"], - enabled: isPresented, - priority: 0, - isActive: isKeyboardOwner, - handle: () => { - toggle(); - return true; - }, - }); - - return null; -} diff --git a/packages/app/src/terminal/drop/terminal-file-drop.test.ts b/packages/app/src/terminal/drop/terminal-file-drop.test.ts index 356bbdb567..5a439f316b 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.test.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { DesktopHostBridge } from "@/desktop/host"; -import type { SelectedFile } from "@/attachments/selected-file"; -import { routeTerminalDrop, routeTerminalPickedFile } from "./terminal-file-drop"; +import { routeTerminalDrop } from "./terminal-file-drop"; function fakeFile(input: { name: string; type?: string; legacyPath?: string }): File { const file = { name: input.name, type: input.type ?? "" } as unknown as File; @@ -69,46 +68,3 @@ describe("terminal file drop routing", () => { }); }); }); - -describe("terminal picked file routing", () => { - const bytes = new Uint8Array([7, 8, 9]); - - function picked(input: { desktopPath?: string } = {}): SelectedFile { - return { - fileName: "report.pdf", - mimeType: "application/pdf", - ...input, - readBytes: async () => bytes, - }; - } - - it("pastes the desktop dialog's own path when the host is the client's machine", () => { - expect( - routeTerminalPickedFile(picked({ desktopPath: "/Users/me/report.pdf" }), { - hostIsLocal: true, - }), - ).toEqual({ kind: "path", path: "/Users/me/report.pdf" }); - }); - - it("uploads the desktop dialog's file when the host is another machine", async () => { - const delivery = routeTerminalPickedFile(picked({ desktopPath: "/Users/me/report.pdf" }), { - hostIsLocal: false, - }); - - expect(delivery.kind).toBe("upload"); - await expect(delivery.kind === "upload" ? delivery.read() : null).resolves.toEqual({ - fileName: "report.pdf", - mimeType: "application/pdf", - bytes, - }); - }); - - it("uploads browser and native document-picker files, which carry no path", () => { - expect(routeTerminalPickedFile(picked(), { hostIsLocal: true })).toMatchObject({ - kind: "upload", - }); - expect(routeTerminalPickedFile(picked(), { hostIsLocal: false })).toMatchObject({ - kind: "upload", - }); - }); -}); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.ts b/packages/app/src/terminal/drop/terminal-file-drop.ts index f65c36ea32..18221271e5 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.ts @@ -1,5 +1,5 @@ import { getMimeTypeFromPath } from "@/attachments/file-types"; -import { readDesktopFileBytes, type SelectedFile } from "@/attachments/selected-file"; +import { readDesktopFileBytes } from "@/attachments/selected-file"; import type { DroppedItem } from "@/components/file-drop/types"; import type { DesktopHostBridge } from "@/desktop/host"; import { readTerminalFile, type TerminalFile } from "../file-paste"; @@ -61,25 +61,3 @@ export function routeTerminalDrop( ? { kind: "path", path: clientPath } : { kind: "upload", read: () => readTerminalFile(item.file) }; } - -/** - * A picked file follows the same rule as a dropped one: its own path when the - * host is this machine and the picker knows where the file is (the desktop app's - * dialog), otherwise its bytes are uploaded. - */ -export function routeTerminalPickedFile( - file: SelectedFile, - input: { hostIsLocal: boolean }, -): TerminalDropDelivery { - if (input.hostIsLocal && file.desktopPath) { - return { kind: "path", path: file.desktopPath }; - } - return { - kind: "upload", - read: async () => ({ - fileName: file.fileName, - mimeType: file.mimeType, - bytes: await file.readBytes(), - }), - }; -} diff --git a/packages/app/src/terminal/drop/terminal-path.test.ts b/packages/app/src/terminal/drop/terminal-path.test.ts index b034027412..6c4b580e48 100644 --- a/packages/app/src/terminal/drop/terminal-path.test.ts +++ b/packages/app/src/terminal/drop/terminal-path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { prepareTerminalPath, preparePathsForTerminalInput } from "./terminal-path"; +import { prepareDroppedPathsForTerminal, prepareTerminalPath } from "./terminal-path"; describe("terminal path quoting", () => { it("prepares POSIX paths with conservative escaping", () => { @@ -18,12 +18,9 @@ describe("terminal path quoting", () => { ); }); - it("ends every path with one space so consecutive pastes stay separate words", () => { - expect(preparePathsForTerminalInput(["/tmp/a.png", "/tmp/b c.png"])).toBe( - "'/tmp/a.png' '/tmp/b c.png' ", - ); - expect(preparePathsForTerminalInput(["C:\\Users\\me\\photo one.png"])).toBe( - '"C:\\Users\\me\\photo one.png" ', + it("joins multiple dropped paths for one terminal input", () => { + expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"])).toBe( + "'/tmp/a.png' '/tmp/b c.png'", ); }); }); diff --git a/packages/app/src/terminal/drop/terminal-path.ts b/packages/app/src/terminal/drop/terminal-path.ts index 7ff2a3c2fd..bc29edd54b 100644 --- a/packages/app/src/terminal/drop/terminal-path.ts +++ b/packages/app/src/terminal/drop/terminal-path.ts @@ -35,10 +35,6 @@ export function prepareTerminalPath(path: string): string { return escapeNonWindowsPath(path); } -/** - * Paths as terminal input: each quoted and followed by one space, so paths pasted - * together or by consecutive gestures stay separate shell words. Never Enter. - */ -export function preparePathsForTerminalInput(paths: readonly string[]): string { - return paths.map((path) => `${prepareTerminalPath(path)} `).join(""); +export function prepareDroppedPathsForTerminal(paths: readonly string[]): string { + return paths.map(prepareTerminalPath).join(" "); } diff --git a/packages/app/src/terminal/file-paste/index.ts b/packages/app/src/terminal/file-paste/index.ts index d157b83d96..f793edb643 100644 --- a/packages/app/src/terminal/file-paste/index.ts +++ b/packages/app/src/terminal/file-paste/index.ts @@ -1,5 +1,5 @@ import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit"; -import { preparePathsForTerminalInput } from "../drop/terminal-path"; +import { prepareTerminalPath } from "../drop/terminal-path"; /** File bytes from a paste or drop gesture, before they become a file on the host. */ export interface TerminalFile { @@ -75,7 +75,7 @@ export class TerminalFilePasteQueue { this.setUploading(true); const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) }); if (isCurrent()) { - target.paste(preparePathsForTerminalInput([path])); + target.paste(`${prepareTerminalPath(path)} `); this.events.onUploaded(); } } catch (error) { diff --git a/packages/app/src/terminal/input-sink/index.test.tsx b/packages/app/src/terminal/input-sink/index.test.tsx deleted file mode 100644 index 22b3caca09..0000000000 --- a/packages/app/src/terminal/input-sink/index.test.tsx +++ /dev/null @@ -1,171 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; -import { - claimTerminalRecording, - clearTerminalDictationState, - getTerminalInputSink, - getTerminalRecordingOwnerId, - publishTerminalDictationState, - registerTerminalInputSink, - releaseTerminalRecording, - useRegisterTerminalInputSink, - useTerminalDictationState, - useTerminalInputSink, - type TerminalDictationState, - type TerminalInputSink, -} from "./index"; - -function fakeSink(): TerminalInputSink { - return { - pasteText: () => {}, - deliverFiles: () => {}, - focus: () => {}, - dictation: { - ownerId: "owner", - toggle: () => {}, - cancel: () => {}, - confirm: () => {}, - retry: () => {}, - discard: () => {}, - }, - }; -} - -function dictationState(volume: number): TerminalDictationState { - return { - status: "recording", - isRecording: true, - isProcessing: false, - error: null, - duration: 0, - volume, - }; -} - -const cleanups: Array<() => void> = []; - -function register(serverId: string, terminalId: string, sink: TerminalInputSink): () => void { - let cleanup = () => {}; - act(() => { - cleanup = registerTerminalInputSink(serverId, terminalId, sink); - }); - const remove = () => act(() => cleanup()); - cleanups.push(remove); - return remove; -} - -afterEach(() => { - while (cleanups.length > 0) { - cleanups.pop()?.(); - } -}); - -describe("terminal input sinks", () => { - it("finds a registered sink by its exact server and terminal", () => { - const sink = fakeSink(); - register("server-a", "term-1", sink); - - expect(getTerminalInputSink("server-a", "term-1")).toBe(sink); - expect(getTerminalInputSink("server-a", "term-2")).toBeNull(); - expect(getTerminalInputSink("server-b", "term-1")).toBeNull(); - }); - - it("lets a newer pane replace a terminal's sink and ignores the older pane's late cleanup", () => { - const older = fakeSink(); - const newer = fakeSink(); - const removeOlder = register("server-a", "term-1", older); - const removeNewer = register("server-a", "term-1", newer); - - expect(getTerminalInputSink("server-a", "term-1")).toBe(newer); - removeOlder(); - expect(getTerminalInputSink("server-a", "term-1")).toBe(newer); - removeNewer(); - expect(getTerminalInputSink("server-a", "term-1")).toBeNull(); - }); - - it("removes only its own terminal's entry", () => { - const first = fakeSink(); - const second = fakeSink(); - const removeFirst = register("server-a", "term-1", first); - register("server-a", "term-2", second); - - removeFirst(); - - expect(getTerminalInputSink("server-a", "term-1")).toBeNull(); - expect(getTerminalInputSink("server-a", "term-2")).toBe(second); - }); - - it("re-renders a terminal's controls only for changes to that terminal", () => { - let renders = 0; - const { result } = renderHook(() => { - renders += 1; - return useTerminalInputSink("server-a", "term-1"); - }); - expect(result.current).toBeNull(); - const rendersBefore = renders; - - register("server-a", "term-2", fakeSink()); - expect(renders).toBe(rendersBefore); - - const sink = fakeSink(); - register("server-a", "term-1", sink); - expect(result.current).toBe(sink); - expect(renders).toBe(rendersBefore + 1); - }); - - it("registers a pane's sink while mounted and removes it on unmount", () => { - const sink = fakeSink(); - const { unmount } = renderHook(() => useRegisterTerminalInputSink("server-a", "term-1", sink)); - - expect(getTerminalInputSink("server-a", "term-1")).toBe(sink); - unmount(); - expect(getTerminalInputSink("server-a", "term-1")).toBeNull(); - }); -}); - -describe("terminal dictation state", () => { - afterEach(() => { - act(() => { - clearTerminalDictationState("owner-a"); - clearTerminalDictationState("owner-b"); - releaseTerminalRecording("owner-a"); - releaseTerminalRecording("owner-b"); - }); - }); - - it("re-renders an owner's controls only for that owner's state", () => { - let renders = 0; - const { result } = renderHook(() => { - renders += 1; - return useTerminalDictationState("owner-a"); - }); - expect(result.current).toBeNull(); - const rendersBefore = renders; - - act(() => publishTerminalDictationState("owner-b", dictationState(0.5))); - expect(renders).toBe(rendersBefore); - - const published = dictationState(0.2); - act(() => publishTerminalDictationState("owner-a", published)); - expect(result.current).toBe(published); - - act(() => clearTerminalDictationState("owner-a")); - expect(result.current).toBeNull(); - }); - - it("keeps one recording owner and releases it only for that owner", () => { - act(() => claimTerminalRecording("owner-a")); - expect(getTerminalRecordingOwnerId()).toBe("owner-a"); - - act(() => claimTerminalRecording("owner-b")); - expect(getTerminalRecordingOwnerId()).toBe("owner-b"); - - act(() => releaseTerminalRecording("owner-a")); - expect(getTerminalRecordingOwnerId()).toBe("owner-b"); - act(() => releaseTerminalRecording("owner-b")); - expect(getTerminalRecordingOwnerId()).toBeNull(); - }); -}); diff --git a/packages/app/src/terminal/input-sink/index.ts b/packages/app/src/terminal/input-sink/index.ts deleted file mode 100644 index 750a857857..0000000000 --- a/packages/app/src/terminal/input-sink/index.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { useEffect } from "react"; -import { create } from "zustand"; -import type { SelectedFile } from "@/attachments/selected-file"; -import type { DictationStatus } from "@/hooks/use-dictation"; - -/** - * What a mounted terminal pane accepts from its controls. The tab-bar slot is a - * sibling of the pane under three different hosts (split pane, fallback row, - * explorer sidebar), so it finds the pane here by terminal identity instead of - * through props. The key bar renders inside the pane and receives the pane's own - * sink as a prop: when two panes show one terminal the registry holds only the - * newer one, and each key bar must still act on its own pane. - */ -export interface TerminalInputSink { - /** Pastes text the way the terminal's TUI expects it (bracketed when enabled). */ - pasteText(text: string): void; - /** Delivers picked files: own paths when the host can read them, uploads otherwise. */ - deliverFiles(files: SelectedFile[]): void; - /** Returns keyboard focus to the terminal. */ - focus(): void; - /** Forwards to the pane's dictation owner; does nothing while no owner is mounted. */ - dictation: TerminalDictationActions; -} - -export interface TerminalDictationActions { - /** Identifies the pane's owner; its published state is under this id. */ - ownerId: string; - /** Starts dictation, or confirms it while recording. */ - toggle(): void; - cancel(): void; - confirm(): void; - retry(): void; - discard(): void; -} - -/** What a dictation owner publishes for its controls. */ -export interface TerminalDictationState { - status: DictationStatus; - isRecording: boolean; - isProcessing: boolean; - error: string | null; - duration: number; - volume: number; -} - -interface TerminalInputSinkState { - sinks: Record; - /** Keyed by owner id, apart from `sinks` so volume ticks never change a sink. */ - dictationStates: Record; - /** The owner whose terminal is recording; one terminal records at a time. */ - recordingOwnerId: string | null; -} - -const useTerminalInputSinkStore = create(() => ({ - sinks: {}, - dictationStates: {}, - recordingOwnerId: null, -})); - -export function terminalInputSinkKey(serverId: string, terminalId: string): string { - return `${serverId}:${terminalId}`; -} - -/** - * Registers `sink` under the terminal's key. The returned cleanup removes the - * entry only while it is still this sink, so a retained pane that unmounts after - * a newer pane registered the same terminal never removes the newer one. - */ -export function registerTerminalInputSink( - serverId: string, - terminalId: string, - sink: TerminalInputSink, -): () => void { - const key = terminalInputSinkKey(serverId, terminalId); - useTerminalInputSinkStore.setState((state) => ({ sinks: { ...state.sinks, [key]: sink } })); - return () => { - useTerminalInputSinkStore.setState((state) => { - if (state.sinks[key] !== sink) { - return state; - } - const { [key]: _removed, ...sinks } = state.sinks; - return { sinks }; - }); - }; -} - -export function getTerminalInputSink( - serverId: string, - terminalId: string, -): TerminalInputSink | null { - return ( - useTerminalInputSinkStore.getState().sinks[terminalInputSinkKey(serverId, terminalId)] ?? null - ); -} - -/** Registers the pane's sink while it is mounted. Pass a memoized sink. */ -export function useRegisterTerminalInputSink( - serverId: string, - terminalId: string, - sink: TerminalInputSink, -): void { - useEffect( - () => registerTerminalInputSink(serverId, terminalId, sink), - [serverId, terminalId, sink], - ); -} - -/** The mounted pane's sink for this terminal; re-renders only when that entry changes. */ -export function useTerminalInputSink( - serverId: string, - terminalId: string, -): TerminalInputSink | null { - const key = terminalInputSinkKey(serverId, terminalId); - return useTerminalInputSinkStore((state) => state.sinks[key] ?? null); -} - -export function publishTerminalDictationState( - ownerId: string, - dictationState: TerminalDictationState, -): void { - useTerminalInputSinkStore.setState((state) => ({ - dictationStates: { ...state.dictationStates, [ownerId]: dictationState }, - })); -} - -export function clearTerminalDictationState(ownerId: string): void { - useTerminalInputSinkStore.setState((state) => { - if (!(ownerId in state.dictationStates)) { - return state; - } - const { [ownerId]: _removed, ...dictationStates } = state.dictationStates; - return { dictationStates }; - }); -} - -/** The owner's published state, or null while no owner with this id is mounted. */ -export function useTerminalDictationState(ownerId: string): TerminalDictationState | null { - return useTerminalInputSinkStore((state) => state.dictationStates[ownerId] ?? null); -} - -/** Makes `ownerId` the recording terminal; the previous owner cancels itself. */ -export function claimTerminalRecording(ownerId: string): void { - useTerminalInputSinkStore.setState({ recordingOwnerId: ownerId }); -} - -/** Clears the recording owner only while it is still `ownerId`. */ -export function releaseTerminalRecording(ownerId: string): void { - useTerminalInputSinkStore.setState((state) => - state.recordingOwnerId === ownerId ? { recordingOwnerId: null } : state, - ); -} - -export function getTerminalRecordingOwnerId(): string | null { - return useTerminalInputSinkStore.getState().recordingOwnerId; -} - -export function useTerminalRecordingOwnerId(): string | null { - return useTerminalInputSinkStore((state) => state.recordingOwnerId); -} diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts index fc06a35c01..d2ef76fefd 100644 --- a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts @@ -82,13 +82,11 @@ describe("terminal virtual keyboard policy", () => { "terminal-key-esc", "terminal-key-tab", "terminal-key-ctrl", - "terminal-key-shift", "terminal-key-up", - "terminal-key-alt", + "terminal-key-shift", "terminal-keyboard-toggle", + "terminal-key-alt", "terminal-paste", - "terminal-upload", - "terminal-dictation", "terminal-key-left", "terminal-key-down", "terminal-key-right", @@ -105,26 +103,13 @@ describe("terminal virtual keyboard policy", () => { down: positions.get("terminal-key-down"), right: positions.get("terminal-key-right"), }).toEqual({ - up: { row: 0, col: 4 }, - left: { row: 1, col: 3 }, - down: { row: 1, col: 4 }, - right: { row: 1, col: 5 }, + up: { row: 0, col: 3 }, + left: { row: 1, col: 2 }, + down: { row: 1, col: 3 }, + right: { row: 1, col: 4 }, }); }); - it("keeps Paste, Upload and Dictation next to each other in every arrangement", () => { - for (const rows of [ - TERMINAL_VIRTUAL_KEYBOARD_ROWS, - TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS, - TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS, - ]) { - const rowIds = rows.map((row) => row.map(getTerminalVirtualKeyboardControlId)); - const ids = rowIds.find((row) => row.includes("terminal-paste")) ?? []; - expect(ids.indexOf("terminal-upload")).toBe(ids.indexOf("terminal-paste") + 1); - expect(ids.indexOf("terminal-dictation")).toBe(ids.indexOf("terminal-paste") + 2); - } - }); - it("keeps Paste in the keyboard and Copy out of the permanent row", () => { expect(controlsByType("paste")).toHaveLength(1); expect(controlIds()).toContain("terminal-paste"); diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts index 3d5023089f..3dd244fa95 100644 --- a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts @@ -18,31 +18,22 @@ export type TerminalVirtualKeyboardControl = | { type: "paste"; } - | { - type: "upload"; - } - | { - type: "dictation"; - } | { type: "keyboardToggle"; }; -// Paste, upload and dictation stay next to each other in every arrangement. export const TERMINAL_VIRTUAL_KEYBOARD_ROWS = [ [ { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.esc }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.tab }, { type: "modifier", modifier: "ctrl" }, - { type: "modifier", modifier: "shift" }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, - { type: "modifier", modifier: "alt" }, + { type: "modifier", modifier: "shift" }, { type: "keyboardToggle" }, ], [ + { type: "modifier", modifier: "alt" }, { type: "paste" }, - { type: "upload" }, - { type: "dictation" }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.left }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.down }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right }, @@ -62,8 +53,6 @@ export const TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS = [ { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right }, { type: "paste" }, - { type: "upload" }, - { type: "dictation" }, { type: "keyboardToggle" }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter }, ], @@ -80,8 +69,8 @@ export const TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS = [ { type: "modifier", modifier: "shift" }, { type: "keyboardToggle" }, ], - [{ type: "paste" }, { type: "upload" }, { type: "dictation" }], [ + { type: "paste" }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter }, ], @@ -124,10 +113,6 @@ export function getTerminalVirtualKeyboardControlId( return `terminal-key-${control.modifier}`; case "paste": return "terminal-paste"; - case "upload": - return "terminal-upload"; - case "dictation": - return "terminal-dictation"; case "keyboardToggle": return "terminal-keyboard-toggle"; } diff --git a/packages/app/src/terminal/toolbar/terminal-dictation-control.tsx b/packages/app/src/terminal/toolbar/terminal-dictation-control.tsx deleted file mode 100644 index 41153163b9..0000000000 --- a/packages/app/src/terminal/toolbar/terminal-dictation-control.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { Check, Mic, RefreshCcw, X } from "lucide-react-native"; -import { Text, View } from "react-native"; -import { useTranslation } from "react-i18next"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { ToolbarButton } from "@/components/ui/pane-content-toolbar"; -import { VolumeMeter } from "@/components/volume-meter"; -import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; -import type { Theme } from "@/styles/theme"; -import { formatDictationDuration } from "@/utils/format-dictation-duration"; -import type { TerminalDictationState, TerminalInputSink } from "@/terminal/input-sink"; - -const ThemedMic = withUnistyles(Mic); -const ThemedX = withUnistyles(X); -const ThemedCheck = withUnistyles(Check); -const ThemedRefresh = withUnistyles(RefreshCcw); -const ThemedVolumeMeter = withUnistyles(VolumeMeter); -const ThemedSpinner = withUnistyles(LoadingSpinner); -const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); -const activeIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); -const accentIconColorMapping = (theme: Theme) => ({ color: theme.colors.accent }); - -interface TerminalDictationControlProps { - sink: TerminalInputSink; - /** Null while the pane has no dictation owner mounted (host disconnected). */ - state: TerminalDictationState | null; -} - -/** - * The terminal's mic in its pane's tab bar. Idle it is one button; while the - * terminal records, processes or has failed, the same slot becomes a pill with - * cancel, meter and duration, and confirm (or retry and discard after a failure). - * A view of the pane's dictation owner: every press goes through the sink. - */ -export function TerminalDictationControl({ sink, state }: TerminalDictationControlProps) { - const { t } = useTranslation(); - const shortcut = useShortcutKeys("terminal-dictation-toggle"); - const status = state?.status ?? "idle"; - - if (!state || status === "idle") { - return ( - - - - ); - } - - const isFailed = status === "failed"; - const isProcessing = state.isProcessing; - - return ( - - {isFailed ? ( - - - - ) : ( - - - - )} - {isFailed ? ( - - {state.error - ? t("message.dictation.failed", { error: state.error }) - : t("message.dictation.failedRetry")} - - ) : ( - - - - {status === "paused" - ? t("message.dictation.paused") - : formatDictationDuration(state.duration)} - - - )} - {isProcessing ? ( - - - - ) : null} - {!isProcessing && isFailed ? ( - - - - ) : null} - {!isProcessing && !isFailed ? ( - - - - ) : null} - - ); -} - -const styles = StyleSheet.create((theme) => ({ - pill: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[1], - borderRadius: theme.borderRadius.full, - borderWidth: 1, - borderColor: theme.colors.accent, - backgroundColor: theme.colors.surface1, - }, - meter: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - duration: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontVariant: ["tabular-nums"], - }, - errorText: { - maxWidth: 160, - color: theme.colors.destructive, - fontSize: theme.fontSize.sm, - }, - spinner: { - width: 20, - alignItems: "center", - justifyContent: "center", - }, -})); diff --git a/packages/app/src/terminal/toolbar/terminal-key-bar-dictation.tsx b/packages/app/src/terminal/toolbar/terminal-key-bar-dictation.tsx deleted file mode 100644 index 50d08dad5b..0000000000 --- a/packages/app/src/terminal/toolbar/terminal-key-bar-dictation.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useMemo, type ReactNode } from "react"; -import { Mic } from "lucide-react-native"; -import { useTranslation } from "react-i18next"; -import { withUnistyles } from "react-native-unistyles"; -import { DictationOverlay } from "@/components/dictation-controls"; -import { TerminalKeyBarIconAction } from "@/components/terminal-copy-paste-actions"; -import type { Theme } from "@/styles/theme"; -import { useTerminalDictationState, type TerminalInputSink } from "@/terminal/input-sink"; - -const ThemedMic = withUnistyles(Mic); -const keyBarIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -interface TerminalKeyBarDictationProps { - /** The pane's own sink: a key bar acts on its pane, never on a newer pane of the same terminal. */ - sink: TerminalInputSink; -} - -/** The key-bar mic. Disabled while the pane has no dictation owner (host disconnected). */ -export function TerminalKeyBarDictationButton({ sink }: TerminalKeyBarDictationProps) { - const { t } = useTranslation(); - const state = useTerminalDictationState(sink.dictation.ownerId); - const icon = useMemo(() => , []); - return ( - - ); -} - -/** - * Shows the key bar's rows while the pane's terminal is not dictating, and the - * dictation overlay in their place through recording, paused, processing and - * failed. The rows come back once the attempt is delivered, cancelled or discarded. - * Lives apart from the pane so volume ticks re-render only this component. - */ -export function TerminalKeyBarDictationGate({ - sink, - children, -}: TerminalKeyBarDictationProps & { children: ReactNode }) { - const state = useTerminalDictationState(sink.dictation.ownerId); - if (!state || state.status === "idle") { - return children; - } - // Also recording while the microphone is still being acquired, so cancel is reachable then. - const isRecording = - state.isRecording || state.status === "recording" || state.status === "paused"; - return ( - - ); -} diff --git a/packages/app/src/terminal/toolbar/terminal-toolbar-slot.tsx b/packages/app/src/terminal/toolbar/terminal-toolbar-slot.tsx deleted file mode 100644 index 356e917df8..0000000000 --- a/packages/app/src/terminal/toolbar/terminal-toolbar-slot.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useTerminalDictationState, useTerminalInputSink } from "@/terminal/input-sink"; -import { TerminalDictationControl } from "./terminal-dictation-control"; -import { TerminalUploadButton } from "./terminal-upload-button"; - -interface TerminalToolbarSlotProps { - serverId: string; - terminalId: string; -} - -/** - * The terminal controls in a pane's tab bar, shown while the pane's active tab is - * a terminal: upload, then the mic. A view over the mounted pane's input sink and - * its dictation state: it owns no terminal state and renders nothing until the - * pane has registered. - */ -export function TerminalToolbarSlot({ serverId, terminalId }: TerminalToolbarSlotProps) { - const sink = useTerminalInputSink(serverId, terminalId); - if (!sink) { - return null; - } - return ; -} - -function TerminalToolbarSlotControls({ - sink, -}: { - sink: NonNullable>; -}) { - const state = useTerminalDictationState(sink.dictation.ownerId); - return ( - <> - - - - ); -} diff --git a/packages/app/src/terminal/toolbar/terminal-upload-button.tsx b/packages/app/src/terminal/toolbar/terminal-upload-button.tsx deleted file mode 100644 index 8d4dcbd075..0000000000 --- a/packages/app/src/terminal/toolbar/terminal-upload-button.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { Paperclip } from "lucide-react-native"; -import { useTranslation } from "react-i18next"; -import { withUnistyles } from "react-native-unistyles"; -import type { Theme } from "@/styles/theme"; -import { ToolbarButton } from "@/components/ui/pane-content-toolbar"; -import { TerminalKeyBarIconAction } from "@/components/terminal-copy-paste-actions"; -import { useToast } from "@/contexts/toast-context"; -import { useFilePicker } from "@/hooks/use-file-picker"; -import { toErrorMessage } from "@/utils/error-messages"; -import type { SelectedFile } from "@/attachments/selected-file"; -import type { TerminalInputSink } from "@/terminal/input-sink"; - -const ThemedPaperclip = withUnistyles(Paperclip); -const toolbarIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); -const keyBarIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -interface TerminalUploadButtonProps { - sink: TerminalInputSink; - placement: "toolbar" | "keybar"; -} - -/** - * Picks files and hands them to the terminal's sink. The sink is the one this - * button rendered with when pressed, so files picked for a terminal reach it even - * if the pane switched to another terminal while the picker was open. - */ -export function TerminalUploadButton({ sink, placement }: TerminalUploadButtonProps) { - const { t } = useTranslation(); - const toast = useToast(); - const { pickFiles } = useFilePicker(); - const label = t("workspace.terminal.actions.uploadFile"); - - const handlePress = useCallback(async () => { - let files: SelectedFile[] | null; - try { - files = await pickFiles(); - } catch (error) { - toast.error(t("workspace.terminal.pasteFileFailed", { reason: toErrorMessage(error) })); - return; - } - if (files && files.length > 0) { - sink.deliverFiles(files); - } else { - sink.focus(); - } - }, [pickFiles, sink, t, toast]); - const keyBarIcon = useMemo( - () => , - [], - ); - - if (placement === "toolbar") { - return ( - - - - ); - } - - return ( - - ); -} diff --git a/packages/app/src/utils/format-dictation-duration.ts b/packages/app/src/utils/format-dictation-duration.ts deleted file mode 100644 index 4a0d20f2f2..0000000000 --- a/packages/app/src/utils/format-dictation-duration.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Formats a dictation's elapsed seconds as `mm:ss`. */ -export function formatDictationDuration(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; -}