mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
fix(terminal): separate uploaded file paths when pasting
Keep the remote file-drop regression and its test coverage from the contributor change, without introducing new terminal controls. Each uploaded path needs a trailing separator because adjacent quoted paths are one shell word. Co-authored-by: Sebastian Arena <sebastian@walkofcode.io>
This commit is contained in:
co-authored by
Sebastian Arena
parent
65ae51dde1
commit
237e784af8
@@ -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<string, unknown>;
|
||||
} | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw) as { type?: unknown; message?: Record<string, unknown> };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseEnvelope(message);
|
||||
return envelope?.type === "session" && envelope.message ? envelope.message : null;
|
||||
}
|
||||
|
||||
function sendSessionMessage(
|
||||
ws: { send(message: string): void },
|
||||
message: Record<string, unknown>,
|
||||
): 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<string, unknown>).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<void> {
|
||||
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<void>((resolve) => {
|
||||
resolveAudioChunk = resolve;
|
||||
});
|
||||
const createRequest = new Promise<void>((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,
|
||||
|
||||
@@ -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:<json>.
|
||||
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:<json> 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<void> {
|
||||
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<void> {
|
||||
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<TerminalInstance> {
|
||||
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<DictationWsHarness> {
|
||||
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<string> {
|
||||
const capture = await harness.client.captureTerminal(terminalId);
|
||||
return capture.lines.join("").replace(/\s/g, "");
|
||||
}
|
||||
|
||||
async function expectTerminalOutput(terminalId: string, text: string): Promise<void> {
|
||||
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<string> {
|
||||
const label = kind === "bracketed" ? "INPUT" : "RAW_INPUT";
|
||||
const output = await terminalOutput(terminalId);
|
||||
return [
|
||||
...output.matchAll(
|
||||
new RegExp(
|
||||
`${kind === "bracketed" ? "(?<!RAW_)" : ""}${label}:("(?:[^"\\\\]|\\\\.)*")`,
|
||||
"g",
|
||||
),
|
||||
),
|
||||
]
|
||||
.map(([, json]) => JSON.parse(json) as string)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function expectReceived(
|
||||
terminalId: string,
|
||||
kind: ReceiverKind,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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:<json>, 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<string | null> {
|
||||
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 {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
} | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw) as { type?: unknown; message?: Record<string, unknown> };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseEnvelope(message);
|
||||
return envelope?.type === "session" && envelope.message ? envelope.message : null;
|
||||
}
|
||||
|
||||
export function sendSessionMessage(ws: SessionSocket, message: Record<string, unknown>): 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<string, unknown>;
|
||||
if (capability === "missing") {
|
||||
const capabilities = serverInfo.capabilities as Record<string, unknown> | 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<void> {
|
||||
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<void>((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<void> {
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as unknown as { __paseoSyntheticMicrophone: SyntheticMicrophoneState }
|
||||
).__paseoSyntheticMicrophone.hold = true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function releaseMicrophone(page: Page): Promise<void> {
|
||||
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<void>;
|
||||
/** 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<string, unknown>, ws: SessionSocket) => boolean;
|
||||
} = {},
|
||||
): Promise<DictationWsHarness> {
|
||||
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<void>((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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<Uint8Array>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -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({
|
||||
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
|
||||
</View>
|
||||
<Text style={timerTextStyle}>
|
||||
{status === "paused" ? t("message.dictation.paused") : formatDictationDuration(duration)}
|
||||
{status === "paused" ? t("message.dictation.paused") : formatDuration(duration)}
|
||||
</Text>
|
||||
<View style={styles.actionGroup}>
|
||||
<Pressable
|
||||
@@ -147,13 +152,7 @@ export function DictationOverlay({
|
||||
onAcceptAndSend,
|
||||
onRetry,
|
||||
onDiscard,
|
||||
testID,
|
||||
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript" | "onAcceptAndSend"> & {
|
||||
errorText?: string;
|
||||
/** Omit to show insert as the only confirm action (terminals never send). */
|
||||
onAcceptAndSend?: () => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isFailed = status === "failed";
|
||||
@@ -191,7 +190,7 @@ export function DictationOverlay({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID={testID}>
|
||||
<View style={containerStyle}>
|
||||
<Pressable
|
||||
onPress={handleCancel}
|
||||
disabled={actionsDisabled && !isFailed}
|
||||
@@ -211,7 +210,7 @@ export function DictationOverlay({
|
||||
orientation="horizontal"
|
||||
color={theme.colors.accentForeground}
|
||||
/>
|
||||
<Text style={overlayTimerTextStyle}>{formatDictationDuration(duration)}</Text>
|
||||
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
|
||||
</View>
|
||||
{status === "paused" ? (
|
||||
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
|
||||
@@ -243,7 +242,7 @@ export function DictationOverlay({
|
||||
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
{!actionsDisabled && !isFailed && onAcceptAndSend ? (
|
||||
{!actionsDisabled && !isFailed ? (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={onAccept}
|
||||
@@ -267,16 +266,6 @@ export function DictationOverlay({
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
{!actionsDisabled && !isFailed && !onAcceptAndSend ? (
|
||||
<Pressable
|
||||
onPress={onAccept}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.dictation.insert")}
|
||||
style={overlayConfirmButtonStyle}
|
||||
>
|
||||
<Check size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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 <TerminalActionButton {...props} variant="key" />;
|
||||
}
|
||||
|
||||
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 ?? <Text style={textStyle}>{label}</Text>}
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TerminalDictationOwnerActions | null>(null);
|
||||
const dictationActions = useMemo<TerminalDictationActions>(
|
||||
() => ({
|
||||
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<View>(null);
|
||||
const isDictationKeyboardOwner = useStableEvent(
|
||||
() => isWeb && isTerminalKeyboardOwner(terminalSurfaceRef.current, document.activeElement),
|
||||
);
|
||||
|
||||
const inputSink = useMemo<TerminalInputSink>(
|
||||
() => ({
|
||||
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 <TerminalUploadButton key={controlId} sink={inputSink} placement="keybar" />;
|
||||
case "dictation":
|
||||
return <TerminalKeyBarDictationButton key={controlId} sink={inputSink} />;
|
||||
case "keyboardToggle":
|
||||
return (
|
||||
<KeyboardToggleButton
|
||||
@@ -1268,20 +1200,9 @@ export function TerminalPane({
|
||||
|
||||
return (
|
||||
<Animated.View style={containerStyle}>
|
||||
<TerminalDictationOwner
|
||||
key={`${serverId}:${terminalId}`}
|
||||
ownerId={dictationOwnerId}
|
||||
serverId={serverId}
|
||||
terminalId={terminalId}
|
||||
isPresented={isTerminalPresented}
|
||||
pasteText={pasteTerminalText}
|
||||
focus={requestTerminalFocus}
|
||||
isKeyboardOwner={isDictationKeyboardOwner}
|
||||
actionsRef={dictationActionsRef}
|
||||
/>
|
||||
<FileDropZone style={styles.outputContainer}>
|
||||
<TerminalFileDropSink onFiles={handleDroppedFiles} />
|
||||
<View ref={terminalSurfaceRef} style={styles.terminalGestureContainer}>
|
||||
<View style={styles.terminalGestureContainer}>
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
||||
@@ -1355,16 +1276,14 @@ export function TerminalPane({
|
||||
onLayout={handleKeyBarLayout}
|
||||
testID={isNative && !isMobile ? "terminal-virtual-keyboard-native-wide" : undefined}
|
||||
>
|
||||
<TerminalKeyBarDictationGate sink={inputSink}>
|
||||
{virtualKeyboardRows.map((row) => (
|
||||
<View
|
||||
key={row.map(getTerminalVirtualKeyboardControlId).join(":")}
|
||||
style={styles.keyboardRow}
|
||||
>
|
||||
{row.map(renderVirtualKeyboardControl)}
|
||||
</View>
|
||||
))}
|
||||
</TerminalKeyBarDictationGate>
|
||||
{virtualKeyboardRows.map((row) => (
|
||||
<View
|
||||
key={row.map(getTerminalVirtualKeyboardControlId).join(":")}
|
||||
style={styles.keyboardRow}
|
||||
>
|
||||
{row.map(renderVirtualKeyboardControl)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : 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<boolean>((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<void> {
|
||||
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<void>;
|
||||
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<void>;
|
||||
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<void>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof createAudioEngine> | 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<AbortController | null>(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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<typeof import("vitest").vi.fn>,
|
||||
stop: null as unknown as ReturnType<typeof import("vitest").vi.fn>,
|
||||
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<typeof createFakeClient>) {
|
||||
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<typeof createFakeClient>) {
|
||||
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<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
audioSource.settleStart = null;
|
||||
audioSource.start = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((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<typeof createFakeClient>,
|
||||
result: { current: ReturnType<typeof useDictation> },
|
||||
) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -70,19 +70,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
|
||||
const durationIntervalRef = useRef<ReturnType<typeof setInterval> | 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<DictationStreamSender | null>(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();
|
||||
|
||||
@@ -32,12 +32,7 @@ async function pickFilesWithDesktopDialog(): Promise<SelectedFile[] | null> {
|
||||
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;
|
||||
|
||||
@@ -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<ChordState>({
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "رسالة قائمة الانتظار",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "メッセージをキューに追加",
|
||||
|
||||
@@ -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: "메시지 대기열에 추가",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Поставить сообщение в очередь",
|
||||
|
||||
@@ -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: "消息排队",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Binding extends { compact?: true }>(
|
||||
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 });
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ShortcutSectionId, readonly string[
|
||||
"cycle-agent-mode",
|
||||
"voice-toggle",
|
||||
"dictation-toggle",
|
||||
"terminal-dictation-toggle",
|
||||
"agent-interrupt",
|
||||
"voice-mute-toggle",
|
||||
],
|
||||
@@ -252,7 +246,6 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -51,7 +51,6 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
|
||||
"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" },
|
||||
|
||||
@@ -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 (
|
||||
<ToolbarControls style={styles.paneSplitActions}>
|
||||
@@ -360,7 +347,6 @@ function WorkspacePaneToolbarActions({
|
||||
)}
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
{terminalSlot}
|
||||
{splitActionsVisible && onSplitRight && onSplitDown ? (
|
||||
<DropdownMenu>
|
||||
<ToolbarButton
|
||||
@@ -1031,16 +1017,6 @@ export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The terminal whose controls the tab-bar slot shows: the active tab's, on web and Electron. */
|
||||
function resolveSlotTerminalId(tabs: readonly WorkspaceDesktopTabRowItem[]): string | null {
|
||||
// Native tablets use the terminal's key bar instead.
|
||||
if (isNative) {
|
||||
return null;
|
||||
}
|
||||
const activeTab = tabs.find((item) => 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 ? (
|
||||
<View
|
||||
key={`${normalizedServerId}:${activeTerminalId}`}
|
||||
style={styles.terminalSlot}
|
||||
onLayout={handleTerminalSlotLayout}
|
||||
>
|
||||
<TerminalToolbarSlot serverId={normalizedServerId} terminalId={activeTerminalId} />
|
||||
</View>
|
||||
) : 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}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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<TerminalDictationActions, "ownerId">;
|
||||
|
||||
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<TerminalDictationOwnerActions | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TerminalDictationAvailability, { kind: "ready" }>,
|
||||
) => {
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, TerminalInputSink>;
|
||||
/** Keyed by owner id, apart from `sinks` so volume ticks never change a sink. */
|
||||
dictationStates: Record<string, TerminalDictationState>;
|
||||
/** The owner whose terminal is recording; one terminal records at a time. */
|
||||
recordingOwnerId: string | null;
|
||||
}
|
||||
|
||||
const useTerminalInputSinkStore = create<TerminalInputSinkState>(() => ({
|
||||
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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ToolbarButton
|
||||
label={t("workspace.terminal.actions.startDictation")}
|
||||
shortcut={shortcut}
|
||||
testID="terminal-toolbar-dictation"
|
||||
disabled={!state}
|
||||
onPress={sink.dictation.toggle}
|
||||
>
|
||||
<ThemedMic size={14} uniProps={iconColorMapping} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
const isFailed = status === "failed";
|
||||
const isProcessing = state.isProcessing;
|
||||
|
||||
return (
|
||||
<View style={styles.pill} testID="terminal-dictation-pill">
|
||||
{isFailed ? (
|
||||
<ToolbarButton
|
||||
label={t("workspace.terminal.dictation.discard")}
|
||||
testID="terminal-dictation-discard"
|
||||
round
|
||||
onPress={sink.dictation.discard}
|
||||
>
|
||||
<ThemedX size={14} uniProps={activeIconColorMapping} />
|
||||
</ToolbarButton>
|
||||
) : (
|
||||
<ToolbarButton
|
||||
label={t("message.dictation.cancel")}
|
||||
testID="terminal-dictation-cancel"
|
||||
round
|
||||
onPress={sink.dictation.cancel}
|
||||
>
|
||||
<ThemedX size={14} uniProps={activeIconColorMapping} />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
{isFailed ? (
|
||||
<Text style={styles.errorText} numberOfLines={1} testID="terminal-dictation-error">
|
||||
{state.error
|
||||
? t("message.dictation.failed", { error: state.error })
|
||||
: t("message.dictation.failedRetry")}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.meter}>
|
||||
<ThemedVolumeMeter
|
||||
volume={state.volume}
|
||||
orientation="horizontal"
|
||||
variant="compact"
|
||||
uniProps={accentIconColorMapping}
|
||||
/>
|
||||
<Text style={styles.duration}>
|
||||
{status === "paused"
|
||||
? t("message.dictation.paused")
|
||||
: formatDictationDuration(state.duration)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{isProcessing ? (
|
||||
<View style={styles.spinner}>
|
||||
<ThemedSpinner size="small" uniProps={activeIconColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
{!isProcessing && isFailed ? (
|
||||
<ToolbarButton
|
||||
label={t("message.dictation.retry")}
|
||||
testID="terminal-dictation-retry"
|
||||
round
|
||||
onPress={sink.dictation.retry}
|
||||
>
|
||||
<ThemedRefresh size={14} uniProps={accentIconColorMapping} />
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
{!isProcessing && !isFailed ? (
|
||||
<ToolbarButton
|
||||
label={t("workspace.terminal.actions.stopDictation")}
|
||||
shortcut={shortcut}
|
||||
testID="terminal-dictation-confirm"
|
||||
round
|
||||
onPress={sink.dictation.confirm}
|
||||
>
|
||||
<ThemedCheck size={14} uniProps={accentIconColorMapping} />
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}));
|
||||
@@ -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(() => <ThemedMic size={16} uniProps={keyBarIconColorMapping} />, []);
|
||||
return (
|
||||
<TerminalKeyBarIconAction
|
||||
accessibilityLabel={t("workspace.terminal.actions.startDictation")}
|
||||
testID="terminal-dictation"
|
||||
icon={icon}
|
||||
disabled={!state}
|
||||
onPress={sink.dictation.toggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<DictationOverlay
|
||||
testID="terminal-dictation-overlay"
|
||||
volume={state.volume}
|
||||
duration={state.duration}
|
||||
isRecording={isRecording}
|
||||
isProcessing={state.isProcessing}
|
||||
status={state.status}
|
||||
errorText={state.status === "failed" ? (state.error ?? undefined) : undefined}
|
||||
onCancel={sink.dictation.cancel}
|
||||
onAccept={sink.dictation.confirm}
|
||||
onRetry={sink.dictation.retry}
|
||||
onDiscard={sink.dictation.discard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <TerminalToolbarSlotControls sink={sink} />;
|
||||
}
|
||||
|
||||
function TerminalToolbarSlotControls({
|
||||
sink,
|
||||
}: {
|
||||
sink: NonNullable<ReturnType<typeof useTerminalInputSink>>;
|
||||
}) {
|
||||
const state = useTerminalDictationState(sink.dictation.ownerId);
|
||||
return (
|
||||
<>
|
||||
<TerminalUploadButton sink={sink} placement="toolbar" />
|
||||
<TerminalDictationControl sink={sink} state={state} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
() => <ThemedPaperclip size={16} uniProps={keyBarIconColorMapping} />,
|
||||
[],
|
||||
);
|
||||
|
||||
if (placement === "toolbar") {
|
||||
return (
|
||||
<ToolbarButton label={label} testID="terminal-toolbar-upload" onPress={handlePress}>
|
||||
<ThemedPaperclip size={14} uniProps={toolbarIconColorMapping} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalKeyBarIconAction
|
||||
accessibilityLabel={label}
|
||||
testID="terminal-upload"
|
||||
icon={keyBarIcon}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user