From 0ecc51bd994884163422dfc505a586f6282986f9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 2 May 2026 23:22:14 +0700 Subject: [PATCH] feat(app): add find in pane across workspace panes --- docs/find-in-pane-electron-qa.md | 4 +- .../app/e2e/find-in-pane-electron.spec.ts | 1480 +++++++++++++---- packages/app/e2e/find-in-pane.spec.ts | 196 +++ .../app/src/agent-stream/strategy-native.tsx | 79 +- .../app/src/agent-stream/strategy-web.tsx | 38 +- packages/app/src/agent-stream/strategy.ts | 7 + packages/app/src/agent-stream/view.tsx | 358 +++- .../agent-stream-search-model.test.ts | 205 +++ .../components/agent-stream-search-model.ts | 294 ++++ .../file-pane-text-render-data.test.ts | 119 ++ .../components/file-pane-text-render-data.ts | 198 +++ .../app/src/components/file-pane.test.tsx | 426 +++++ packages/app/src/components/file-pane.tsx | 681 ++++++-- packages/app/src/components/message.tsx | 216 ++- .../components/stream-strategy-native.test.ts | 31 + .../app/src/components/terminal-emulator.tsx | 29 + .../app/src/components/terminal-pane.test.tsx | 433 +++++ packages/app/src/components/terminal-pane.tsx | 77 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 33 +- packages/app/src/keyboard/actions.ts | 1 + .../keyboard/keyboard-action-dispatcher.ts | 2 + .../src/keyboard/keyboard-shortcuts.test.ts | 14 + .../app/src/keyboard/keyboard-shortcuts.ts | 26 + packages/app/src/keyboard/route-shortcut.ts | 5 +- packages/app/src/panels/pane-context.tsx | 1 + .../app/src/panels/pane-find-registry.test.ts | 121 ++ packages/app/src/panels/pane-find-registry.ts | 81 + packages/app/src/panels/pane-find.test.tsx | 496 ++++++ packages/app/src/panels/pane-find.tsx | 310 ++++ .../workspace/workspace-pane-content.test.tsx | 1 + .../workspace/workspace-pane-content.tsx | 27 +- .../screens/workspace/workspace-screen.tsx | 1 + .../runtime/terminal-emulator-runtime.test.ts | 135 ++ .../runtime/terminal-emulator-runtime.ts | 44 +- 34 files changed, 5698 insertions(+), 471 deletions(-) create mode 100644 packages/app/e2e/find-in-pane.spec.ts create mode 100644 packages/app/src/components/agent-stream-search-model.test.ts create mode 100644 packages/app/src/components/agent-stream-search-model.ts create mode 100644 packages/app/src/components/file-pane-text-render-data.test.ts create mode 100644 packages/app/src/components/file-pane-text-render-data.ts create mode 100644 packages/app/src/components/file-pane.test.tsx create mode 100644 packages/app/src/components/stream-strategy-native.test.ts create mode 100644 packages/app/src/components/terminal-pane.test.tsx create mode 100644 packages/app/src/panels/pane-find-registry.test.ts create mode 100644 packages/app/src/panels/pane-find-registry.ts create mode 100644 packages/app/src/panels/pane-find.test.tsx create mode 100644 packages/app/src/panels/pane-find.tsx diff --git a/docs/find-in-pane-electron-qa.md b/docs/find-in-pane-electron-qa.md index 94449ae910..80692a1497 100644 --- a/docs/find-in-pane-electron-qa.md +++ b/docs/find-in-pane-electron-qa.md @@ -8,8 +8,8 @@ npm run test:e2e --workspace=@getpaseo/app -- find-in-pane-electron.spec.ts --pr The spec starts a fresh desktop app from the current worktree with: -- isolated `PASEO_HOME` under `/tmp/paseo-find-pane-electron-rerun/home` -- isolated Electron user data under `/tmp/paseo-find-pane-electron-rerun/electron-user-data` +- per-run isolated `PASEO_HOME` under `/tmp/paseo-find-pane-electron-rerun/home-*` +- per-run isolated Electron user data under `/tmp/paseo-find-pane-electron-rerun/electron-user-data-*` - `PASEO_LISTEN=127.0.0.1:0`, so it must not use port `6767` - a local HTTP page containing three `electronneedle` matches diff --git a/packages/app/e2e/find-in-pane-electron.spec.ts b/packages/app/e2e/find-in-pane-electron.spec.ts index 7dca90150e..60273f026c 100644 --- a/packages/app/e2e/find-in-pane-electron.spec.ts +++ b/packages/app/e2e/find-in-pane-electron.spec.ts @@ -3,21 +3,30 @@ import { spawn, execFile, type ChildProcessWithoutNullStreams } from "node:child import { createServer, type Server } from "node:http"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { chromium, type Browser } from "playwright"; +import { _electron as electronDriver, type ElectronApplication } from "playwright"; +import { connectWorkspaceSetupClient } from "./helpers/workspace-setup"; +import { createTempGitRepo } from "./helpers/workspace"; const REPO_ROOT = path.resolve(__dirname, "../../.."); const OUTPUT_DIR = process.env.PASEO_ELECTRON_FIND_QA_OUTPUT_DIR ?? "/tmp/paseo-find-pane-electron-rerun"; -const PASEO_HOME = path.join(OUTPUT_DIR, "home"); -const USER_DATA_DIR = path.join(OUTPUT_DIR, "electron-user-data"); +const RUN_ID = `${process.pid}-${Date.now()}`; +const PASEO_HOME = path.join(OUTPUT_DIR, `home-${RUN_ID}`); +const USER_DATA_DIR = path.join(OUTPUT_DIR, `electron-user-data-${RUN_ID}`); +let paseoListen = "127.0.0.1:0"; const QA_PAGE_TEXT = [ "Electron Webview Find QA", "alpha electronneedle first", "beta no match", "gamma electronneedle second", "delta ELECTRONNEEDLE third", + "experiment_only_marker", + "focus_test_a", + "focus_test_b", + "control_repeat_unique", + "control_after_reload", ].join("\n\n"); -const QA_PAGE_HTML = `Find QA Page

Electron Webview Find QA

alpha electronneedle first

beta no match

gamma electronneedle second

delta ELECTRONNEEDLE third

`; +const QA_PAGE_HTML = `Find QA Page

Electron Webview Find QA

alpha electronneedle first

beta no match

gamma electronneedle second

delta ELECTRONNEEDLE third

experiment_only_marker

focus_test_a

focus_test_b

control_repeat_unique

control_after_reload

`; function rootEnv(extra?: Record): NodeJS.ProcessEnv { const env = { ...process.env, ...extra }; @@ -32,40 +41,91 @@ function rootEnv(extra?: Record): NodeJS.ProcessEnv return env; } +function electronLaunchEnv(extra?: Record): Record { + return Object.fromEntries( + Object.entries(rootEnv(extra)).filter((entry): entry is [string, string] => { + return typeof entry[1] === "string"; + }), + ); +} + interface ElectronProcess { - child: ChildProcessWithoutNullStreams; + app: ElectronApplication; + metroChild: ChildProcessWithoutNullStreams; cdpPort: number; metroPort: number; logs: string[]; } +interface TempRepo { + path: string; + cleanup: () => Promise; +} + +interface WorkspaceSetupClient { + connect(): Promise; + close(): Promise; + openProject(cwd: string): Promise<{ + workspace: { + id: string; + name: string; + workspaceDirectory: string; + projectRootPath: string; + } | null; + error: string | null; + }>; +} + +interface PageSnapshot { + url: string; + title: string; + bodyText: string | null; +} + interface QaEvidence { timestamps: Record; - domReadyEvents: Array<{ ts: number }>; - listenerSnapshots: Record>; - findCalls: Array<{ - text: string; - options: Record; - requestId: number | null; - ts: number; - }>; - stopCalls: Array<{ action: string; ts: number }>; - foundEvents: Array<{ result: unknown; ts: number }>; - manualFoundEvents: Array<{ result: unknown; ts: number }>; + pages: PageSnapshot[]; + selectedPage: PageSnapshot; + bridge: { + available: boolean; + findInPageType: string | null; + stopFindInPageType: string | null; + onFoundInPageType: string | null; + findCalls: Array<{ + browserId: string; + text: string; + options: Record; + requestId: number | null; + ts: number; + }>; + stopCalls: Array<{ browserId: string; action: string; ts: number }>; + listenerRegistrations: Array<{ browserId: string; ts: number }>; + foundEvents: Array<{ browserId: string; result: unknown; ts: number }>; + }; + mainProcess: { + installed: boolean; + ipcHandleWraps: Array<{ channel: string; ok: boolean; reason?: string }>; + ipcFindCalls: Array<{ args: unknown[]; ts: number; requestId: number | null }>; + ipcStopCalls: Array<{ args: unknown[]; ts: number }>; + guestFoundInPageEvents: Array<{ id: number; url: string; result: unknown; ts: number }>; + ownerForwards: Array<{ id: number; channel: string; payload: unknown; ts: number }>; + webContentsCreated: Array<{ id: number; type: string; url: string; ts: number }>; + } | null; webview: { url: string | null; text: string | null; webContentsId: number | null; findInPageType: string | null; stopFindInPageType: string | null; - domReady: boolean; }; app: { url: string; + title: string; bodyText: string; logboxCount: number; findBarText: string | null; findInputValue: string | null; + counterText: string | null; }; process: { cdpPort: number; @@ -128,15 +188,6 @@ function execFileText( }); } -function parseJsonObjectFromOutput(output: string): unknown { - const start = output.indexOf("{"); - const end = output.lastIndexOf("}"); - if (start < 0 || end < start) { - throw new Error(`No JSON object found in command output:\n${output}`); - } - return JSON.parse(output.slice(start, end + 1)); -} - async function waitFor( label: string, callback: () => Promise, @@ -181,38 +232,57 @@ async function stopServer(server: Server): Promise { async function startElectron(): Promise { await mkdir(OUTPUT_DIR, { recursive: true }); const cdpPort = await freePort(); + const metroPort = await freePort(); const logs: string[] = []; - const child = spawn("npm", ["--prefix", REPO_ROOT, "run", "dev:desktop"], { - cwd: REPO_ROOT, - env: rootEnv({ - PASEO_HOME, - PASEO_ELECTRON_USER_DATA_DIR: USER_DATA_DIR, - PASEO_LISTEN: "127.0.0.1:0", - PASEO_ELECTRON_FLAGS: `--remote-debugging-port=${cdpPort} --remote-allow-origins=*`, - }), + + await execFileText("npm", [ + "--prefix", + REPO_ROOT, + "run", + "build:main", + "--workspace=@getpaseo/desktop", + ]); + + const metroChild = spawn("npx", ["expo", "start", "--port", String(metroPort)], { + cwd: path.join(REPO_ROOT, "packages/app"), + env: rootEnv({ PASEO_WEB_PLATFORM: "electron" }), stdio: ["pipe", "pipe", "pipe"], }); const append = (chunk: Buffer) => { - const text = chunk.toString(); - logs.push(text); + logs.push(chunk.toString()); }; - child.stdout.on("data", append); - child.stderr.on("data", append); + metroChild.stdout.on("data", append); + metroChild.stderr.on("data", append); - let metroPort: number; try { - metroPort = await waitFor("desktop dev Metro port", async () => { - if (child.exitCode !== null || child.signalCode !== null) { - throw new Error(`desktop process exited early: ${child.exitCode ?? child.signalCode}`); + await waitFor("Metro dev server", async () => { + if (metroChild.exitCode !== null || metroChild.signalCode !== null) { + throw new Error( + `metro process exited early: ${metroChild.exitCode ?? metroChild.signalCode}`, + ); } - const logText = logs.join(""); - const match = - logText.match(/Metro:\s+http:\/\/localhost:(\d+)/) ?? - logText.match(/Waiting on\s+http:\/\/localhost:(\d+)/); - return match ? Number(match[1]) : null; + const response = await fetch(`http://127.0.0.1:${metroPort}`).catch(() => null); + return response ? true : null; }); + } catch (error) { + await writeFile(path.join(OUTPUT_DIR, "electron-dev-start-failure.log"), logs.join(""), "utf8"); + throw error; + } + const app = await electronDriver.launch({ + args: [path.join(REPO_ROOT, "packages/desktop")], + cwd: REPO_ROOT, + env: electronLaunchEnv({ + PASEO_HOME, + PASEO_ELECTRON_USER_DATA_DIR: USER_DATA_DIR, + PASEO_LISTEN: paseoListen, + PASEO_ELECTRON_FLAGS: `--remote-debugging-port=${cdpPort} --remote-allow-origins=*`, + EXPO_DEV_URL: `http://localhost:${metroPort}`, + }), + }); + + try { await waitFor("Electron CDP endpoint", async () => { const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`).catch(() => null); return response?.ok ? true : null; @@ -222,16 +292,17 @@ async function startElectron(): Promise { throw error; } - return { child, cdpPort, metroPort, logs }; + return { app, metroChild, cdpPort, metroPort, logs }; } async function stopElectron(input: ElectronProcess): Promise { - if (input.child.exitCode === null && input.child.signalCode === null) { - input.child.kill("SIGTERM"); + await input.app.close().catch(() => undefined); + if (input.metroChild.exitCode === null && input.metroChild.signalCode === null) { + input.metroChild.kill("SIGTERM"); } await new Promise((resolve) => { const timer = setTimeout(resolve, 5_000); - input.child.once("exit", () => { + input.metroChild.once("exit", () => { clearTimeout(timer); resolve(); }); @@ -239,6 +310,7 @@ async function stopElectron(input: ElectronProcess): Promise { await writeFile(path.join(OUTPUT_DIR, "electron-dev.log"), input.logs.join(""), "utf8"); await execFileText("npm", ["--prefix", REPO_ROOT, "run", "cli", "--", "daemon", "stop"], { PASEO_HOME, + PASEO_LISTEN: paseoListen, }).catch(async (error) => { await writeFile(path.join(OUTPUT_DIR, "daemon-stop-error.txt"), String(error), "utf8"); }); @@ -248,20 +320,8 @@ async function readServerId(): Promise { return (await readFile(path.join(PASEO_HOME, "server-id"), "utf8")).trim(); } -async function readDaemonListen(): Promise { - const output = await execFileText( - "npm", - ["--prefix", REPO_ROOT, "run", "cli", "--", "daemon", "status"], - { - PASEO_HOME, - }, - ); - const match = output.match(/Listen\s+([^\s]+)/); - return match?.[1] ?? null; -} - -async function createMockAgent(): Promise { - const output = await execFileText( +async function startIsolatedDaemon(): Promise { + await execFileText( "npm", [ "--prefix", @@ -269,26 +329,25 @@ async function createMockAgent(): Promise { "run", "cli", "--", - "run", - "--provider", - "mock", - "--mode", - "load-test", - "--cwd", - REPO_ROOT, - "--title", - "Electron browser find QA", - "-d", - "electron browser find qa", - "--json", + "daemon", + "start", + "--listen", + paseoListen, + "--home", + PASEO_HOME, ], - { PASEO_HOME }, + { PASEO_HOME, PASEO_LISTEN: paseoListen }, ); - const parsed = parseJsonObjectFromOutput(output) as { agentId?: string }; - if (!parsed.agentId) { - throw new Error(`Mock agent output did not include agentId:\n${output}`); - } - return parsed.agentId; +} + +async function readDaemonListen(): Promise { + const output = await execFileText( + "npm", + ["--prefix", REPO_ROOT, "run", "cli", "--", "daemon", "status"], + { PASEO_HOME, PASEO_LISTEN: paseoListen }, + ); + const match = output.match(/Listen\s+([^\s]+)/); + return match?.[1] ?? null; } async function screenshot(page: Page, name: string): Promise { @@ -297,29 +356,826 @@ async function screenshot(page: Page, name: string): Promise { return filePath; } -async function getListenerCounts(page: Page): Promise> { - const client = await page.context().newCDPSession(page); - const evaluated = await client.send("Runtime.evaluate", { - expression: "document.querySelector('webview')", - objectGroup: "paseo-find-qa", +async function readWebviewBodyText(page: Page): Promise { + return page.evaluate(async () => { + const webview = document.querySelector("webview") as + | (Element & { executeJavaScript?: (code: string) => Promise }) + | null; + return webview?.executeJavaScript + ? String(await webview.executeJavaScript("document.body.innerText")) + : ""; }); - const objectId = evaluated.result.objectId; - if (!objectId) return {}; - const listeners = await client.send("DOMDebugger.getEventListeners", { objectId }); - await client.send("Runtime.releaseObjectGroup", { objectGroup: "paseo-find-qa" }); - return listeners.listeners.reduce>((acc, listener) => { - acc[listener.type] = (acc[listener.type] ?? 0) + 1; - return acc; - }, {}); +} + +async function snapshotPage(page: Page): Promise { + const evaluated = await page + .evaluate(() => ({ + url: window.location.href, + title: document.title, + bodyText: document.body?.innerText ?? null, + })) + .catch(() => null); + return { + url: evaluated?.url ?? page.url(), + title: evaluated?.title ?? (await page.title().catch(() => "")), + bodyText: evaluated?.bodyText ?? null, + }; +} + +function isAppRendererSnapshot(snapshot: PageSnapshot, metroPort: number): boolean { + const appHosts = [`localhost:${metroPort}`, `127.0.0.1:${metroPort}`]; + return appHosts.some((host) => snapshot.url.includes(host)); +} + +async function collectPageSnapshots(electronApp: ElectronApplication): Promise { + return Promise.all(electronApp.windows().map((page) => snapshotPage(page))); +} + +async function findAppRendererPage( + electronApp: ElectronApplication, + metroPort: number, +): Promise { + return waitFor("Paseo app renderer page", async () => { + for (const page of electronApp.windows()) { + const snapshot = await snapshotPage(page); + if (isAppRendererSnapshot(snapshot, metroPort)) { + return page; + } + } + return null; + }); +} + +async function instrumentBridge(page: Page): Promise { + await page.evaluate(() => { + const desktop = window.paseoDesktop; + const bridge = desktop?.browser; + const qa: NonNullable = { + timestamps: { instrumentationAttached: Date.now() }, + bridgeAvailable: Boolean(bridge), + findInPageType: typeof bridge?.findInPage, + stopFindInPageType: typeof bridge?.stopFindInPage, + onFoundInPageType: typeof bridge?.onFoundInPage, + bridgeFindCalls: [], + bridgeStopCalls: [], + bridgeListenerRegistrations: [], + bridgeFoundEvents: [], + }; + window.__paseoElectronFindQa = qa; + if (!bridge?.findInPage || !bridge.stopFindInPage || !bridge.onFoundInPage) { + return; + } + + const wrapped = bridge; + const originalFindInPage = bridge.findInPage.bind(bridge); + const originalStopFindInPage = bridge.stopFindInPage.bind(bridge); + const originalOnFoundInPage = bridge.onFoundInPage.bind(bridge); + + wrapped.findInPage = (browserId, text, options) => { + const entry: NonNullable["bridgeFindCalls"][number] = { + browserId, + text, + options: { ...options }, + requestId: null, + ts: Date.now(), + }; + qa.timestamps.firstBridgeFindCall ??= entry.ts; + qa.bridgeFindCalls.push(entry); + const result = originalFindInPage(browserId, text, options); + void Promise.resolve(result ?? null).then((requestId) => { + entry.requestId = typeof requestId === "number" ? requestId : null; + return undefined; + }); + return result; + }; + + wrapped.stopFindInPage = (browserId, action) => { + qa.bridgeStopCalls.push({ browserId, action, ts: Date.now() }); + return originalStopFindInPage(browserId, action); + }; + + wrapped.onFoundInPage = (browserId, listener) => { + qa.bridgeListenerRegistrations.push({ browserId, ts: Date.now() }); + return originalOnFoundInPage(browserId, (result) => { + qa.bridgeFoundEvents.push({ browserId, result, ts: Date.now() }); + listener(result); + }); + }; + if (desktop) { + try { + Object.defineProperty(desktop, "browser", { + configurable: true, + enumerable: true, + get: () => wrapped, + }); + } catch { + qa.timestamps.browserDescriptorWrapFailed = Date.now(); + } + } + }); +} + +async function installMainProcessInstrumentation( + electronApp: ElectronApplication, + fixtureOrigin: string, +): Promise { + await electronApp.evaluate(({ app, ipcMain, webContents }, qaOrigin) => { + const globalScope = globalThis as typeof globalThis & { + __paseoFindEvidence?: { + installed: boolean; + ipcHandleWraps: Array<{ channel: string; ok: boolean; reason?: string }>; + ipcFindCalls: Array<{ args: unknown[]; ts: number; requestId: number | null }>; + ipcStopCalls: Array<{ args: unknown[]; ts: number }>; + guestFoundInPageEvents: Array<{ id: number; url: string; result: unknown; ts: number }>; + ownerForwards: Array<{ id: number; channel: string; payload: unknown; ts: number }>; + webContentsCreated: Array<{ id: number; type: string; url: string; ts: number }>; + }; + }; + type MainFindEvidence = NonNullable; + const evidence: MainFindEvidence = { + installed: true, + ipcHandleWraps: [], + ipcFindCalls: [], + ipcStopCalls: [], + guestFoundInPageEvents: [], + ownerForwards: [], + webContentsCreated: [], + }; + globalScope.__paseoFindEvidence = evidence; + + const invokeHandlers = (ipcMain as unknown as { _invokeHandlers?: Map }) + ._invokeHandlers; + const wrapInvoke = (channel: string) => { + const original = invokeHandlers?.get(channel); + if (typeof original !== "function") { + evidence.ipcHandleWraps.push({ channel, ok: false, reason: "handler not found" }); + return; + } + invokeHandlers?.set(channel, (event: unknown, ...args: unknown[]) => { + const ts = Date.now(); + const result = (original as (event: unknown, ...args: unknown[]) => unknown)( + event, + ...args, + ); + if (channel === "paseo:browser:find-in-page") { + const entry = { args, ts, requestId: null as number | null }; + evidence.ipcFindCalls.push(entry); + void Promise.resolve(result).then((requestId) => { + entry.requestId = typeof requestId === "number" ? requestId : null; + return undefined; + }); + } else { + evidence.ipcStopCalls.push({ + args, + ts, + }); + } + return result; + }); + evidence.ipcHandleWraps.push({ channel, ok: true }); + }; + wrapInvoke("paseo:browser:find-in-page"); + wrapInvoke("paseo:browser:stop-find-in-page"); + + const attachFoundListener = (contents: Electron.WebContents) => { + if ((contents as unknown as { __paseoFindQaAttached?: boolean }).__paseoFindQaAttached) { + return; + } + (contents as unknown as { __paseoFindQaAttached?: boolean }).__paseoFindQaAttached = true; + evidence.webContentsCreated.push({ + id: contents.id, + type: contents.getType(), + url: contents.getURL(), + ts: Date.now(), + }); + contents.on("found-in-page", (_event, result) => { + evidence.guestFoundInPageEvents.push({ + id: contents.id, + url: contents.getURL(), + result, + ts: Date.now(), + }); + }); + const originalSend = contents.send.bind(contents); + contents.send = ((channel: string, ...args: unknown[]) => { + if (channel === "paseo:event:browser-found-in-page") { + evidence.ownerForwards.push({ + id: contents.id, + channel, + payload: args[0] ?? null, + ts: Date.now(), + }); + } + return originalSend(channel, ...args); + }) as typeof contents.send; + }; + + for (const contents of webContents.getAllWebContents()) { + attachFoundListener(contents); + } + app.on("web-contents-created", (_event, contents) => { + attachFoundListener(contents); + contents.on("did-navigate", () => { + if (contents.getURL().startsWith(qaOrigin)) { + evidence.webContentsCreated.push({ + id: contents.id, + type: contents.getType(), + url: contents.getURL(), + ts: Date.now(), + }); + } + }); + }); + }, fixtureOrigin); +} + +async function readMainProcessEvidence( + electronApp: ElectronApplication, +): Promise { + return electronApp.evaluate(() => { + const globalScope = globalThis as typeof globalThis & { + __paseoFindEvidence?: QaEvidence["mainProcess"]; + }; + return globalScope.__paseoFindEvidence ?? null; + }); +} + +interface DiagnosticExperimentEvidence { + electronVersion: string; + control: { + target: { id: number; type: string; url: string } | null; + requestId: number | null; + events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; + }; + guest: { + target: { id: number; type: string; url: string } | null; + requestId: number | null; + contentsAtFind: Array<{ id: number; type: string; url: string }>; + allEvents: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; + }; + conclusion: string; +} + +// eslint-disable-next-line no-unused-vars +async function runDiagnosticExperiment( + electronApp: ElectronApplication, + fixtureOrigin: string, +): Promise { + return electronApp.evaluate(async ({ webContents }, qaOrigin) => { + interface ContentsSummary { + id: number; + type: string; + url: string; + } + interface FindEvent extends ContentsSummary { + sourceId: number; + result: unknown; + ts: number; + } + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const summarize = (contents: Electron.WebContents): ContentsSummary => ({ + id: contents.id, + type: contents.getType(), + url: contents.getURL(), + }); + const allContents = () => + webContents.getAllWebContents().filter((contents) => !contents.isDestroyed()); + const mainWindow = + allContents().find((contents) => contents.getType() === "window") ?? allContents()[0] ?? null; + const guest = + allContents().find((contents) => contents.getURL().startsWith(qaOrigin)) ?? + allContents().find((contents) => contents.getType() === "webview") ?? + null; + + const controlEvents: FindEvent[] = []; + const guestEvents: FindEvent[] = []; + const addListener = (contents: Electron.WebContents, bucket: FindEvent[]) => { + const listener = (_event: Electron.Event, result: Electron.Result) => { + bucket.push({ + sourceId: contents.id, + ...summarize(contents), + result, + ts: Date.now(), + }); + }; + contents.on("found-in-page", listener); + return () => contents.removeListener("found-in-page", listener); + }; + + let controlRequestId: number | null = null; + const cleanupControl = mainWindow ? [addListener(mainWindow, controlEvents)] : []; + if (mainWindow) { + controlRequestId = mainWindow.findInPage("Workspace"); + await sleep(2000); + mainWindow.stopFindInPage("clearSelection"); + } + for (const cleanup of cleanupControl) cleanup(); + + const contentsAtFind = allContents().map(summarize); + const cleanupGuest = allContents().map((contents) => addListener(contents, guestEvents)); + let guestRequestId: number | null = null; + if (guest) { + guestRequestId = guest.findInPage("experiment_only_marker"); + } + await sleep(2000); + if (guest) { + guest.stopFindInPage("clearSelection"); + } + if (mainWindow) { + mainWindow.stopFindInPage("clearSelection"); + } + for (const cleanup of cleanupGuest) cleanup(); + + let conclusion = + "No WebContents delivered found-in-page for either the main-window control or guest find."; + if (controlEvents.length > 0 && guestEvents.length > 0) { + conclusion = "Both main-window and guest WebContents delivered found-in-page events."; + } else if (controlEvents.length > 0) { + conclusion = + "Main-window find delivers found-in-page, but guest WebContents find does not deliver any found-in-page event."; + } else if (guestEvents.length > 0) { + conclusion = + "Guest WebContents delivered found-in-page, but the main-window control did not."; + } + + return { + electronVersion: process.versions.electron, + control: { + target: mainWindow ? summarize(mainWindow) : null, + requestId: controlRequestId, + events: controlEvents, + }, + guest: { + target: guest ? summarize(guest) : null, + requestId: guestRequestId, + contentsAtFind, + allEvents: guestEvents, + }, + conclusion, + }; + }, fixtureOrigin); +} + +// eslint-disable-next-line no-unused-vars +async function writeDiagnosticExperiment( + experiment: DiagnosticExperimentEvidence, +): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filePath = path.join(OUTPUT_DIR, `diagnostic-experiment-${timestamp}.md`); + const lines = [ + "# Electron FindInPage Diagnostic Experiment", + "", + `Generated: ${new Date().toISOString()}`, + `Electron version: ${experiment.electronVersion}`, + "", + "## A. Main Window Control", + "", + `- Target: ${JSON.stringify(experiment.control.target)}`, + `- Request ID: ${experiment.control.requestId ?? ""}`, + `- Events: ${JSON.stringify(experiment.control.events)}`, + "", + "## B. Guest WebContents Sweep", + "", + `- Guest target: ${JSON.stringify(experiment.guest.target)}`, + `- Request ID: ${experiment.guest.requestId ?? ""}`, + `- WebContents at find-call time: ${JSON.stringify(experiment.guest.contentsAtFind)}`, + `- Events from any WebContents: ${JSON.stringify(experiment.guest.allEvents)}`, + "", + "## Conclusion", + "", + experiment.conclusion, + "", + ]; + await writeFile(filePath, lines.join("\n"), "utf8"); + await writeFile( + path.join(OUTPUT_DIR, "diagnostic-experiment-evidence.json"), + JSON.stringify(experiment, null, 2), + "utf8", + ); + return filePath; +} + +interface DiagnosticFocusExperimentEvidence { + electronVersion: string; + visibleWebview: { id: number; type: string; url: string } | null; + implLookup: { + browserId: string | null; + contentsId: number | null; + error: string | null; + }; + withoutFocus: { + requestId: number | null; + events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; + }; + withGuestFocus: { + requestId: number | null; + events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; + }; + conclusion: string; +} + +// eslint-disable-next-line no-unused-vars +async function runDiagnosticFocusExperiment( + electronApp: ElectronApplication, + browserWebviewsModulePath: string, +): Promise { + // eslint-disable-next-line complexity + return electronApp.evaluate(async ({ webContents }, modulePath) => { + interface ContentsSummary { + id: number; + type: string; + url: string; + } + interface FindEvent extends ContentsSummary { + sourceId: number; + result: unknown; + ts: number; + } + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const summarize = (contents: Electron.WebContents): ContentsSummary => ({ + id: contents.id, + type: contents.getType(), + url: contents.getURL(), + }); + const liveContents = () => + webContents.getAllWebContents().filter((contents) => !contents.isDestroyed()); + const owner = + liveContents().find((contents) => contents.getType() === "window") ?? + liveContents()[0] ?? + null; + const guest = + liveContents().find((contents) => contents.getType() === "webview") ?? + liveContents().find((contents) => contents.getURL().startsWith("http://127.0.0.1:")) ?? + null; + const addOwnerListener = (bucket: FindEvent[]) => { + if (!owner) return () => undefined; + const listener = (_event: Electron.Event, result: Electron.Result) => { + bucket.push({ + sourceId: owner.id, + ...summarize(owner), + result, + ts: Date.now(), + }); + }; + owner.on("found-in-page", listener); + return () => owner.removeListener("found-in-page", listener); + }; + + const implLookup: DiagnosticFocusExperimentEvidence["implLookup"] = { + browserId: null, + contentsId: null, + error: null, + }; + try { + const nodeRequire = process.mainModule?.require.bind(process.mainModule); + if (!nodeRequire) { + throw new Error("process.mainModule.require is unavailable"); + } + const browserWebviews = nodeRequire(modulePath) as { + getPaseoBrowserIdForWebContents?: (contents: Electron.WebContents | null) => string | null; + getPaseoBrowserWebContents?: (browserId: string) => Electron.WebContents | null; + }; + implLookup.browserId = + browserWebviews.getPaseoBrowserIdForWebContents?.(guest ?? null) ?? null; + const implContents = implLookup.browserId + ? browserWebviews.getPaseoBrowserWebContents?.(implLookup.browserId) + : null; + implLookup.contentsId = implContents?.id ?? null; + } catch (error) { + implLookup.error = error instanceof Error ? error.message : String(error); + } + + const withoutFocusEvents: FindEvent[] = []; + const cleanupWithoutFocus = addOwnerListener(withoutFocusEvents); + const withoutFocusRequestId = guest?.findInPage("focus_test_a", { findNext: false }) ?? null; + await sleep(1500); + cleanupWithoutFocus(); + guest?.stopFindInPage("clearSelection"); + owner?.stopFindInPage("clearSelection"); + + const withGuestFocusEvents: FindEvent[] = []; + const cleanupWithGuestFocus = addOwnerListener(withGuestFocusEvents); + guest?.focus(); + const withGuestFocusRequestId = guest?.findInPage("focus_test_b", { findNext: false }) ?? null; + await sleep(1500); + cleanupWithGuestFocus(); + guest?.stopFindInPage("clearSelection"); + owner?.stopFindInPage("clearSelection"); + + let conclusion = "Neither blurred nor explicitly focused guest find delivered owner events."; + if (withoutFocusEvents.length > 0 && withGuestFocusEvents.length > 0) { + conclusion = + "Focus is not required: both blurred and explicitly focused guest find delivered owner events."; + } else if (withoutFocusEvents.length === 0 && withGuestFocusEvents.length > 0) { + conclusion = + "Guest focus is required: blurred guest find produced no owner events, focused guest find did."; + } else if (withoutFocusEvents.length > 0) { + conclusion = "Blurred guest find delivered owner events, but focused guest find did not."; + } + + return { + electronVersion: process.versions.electron, + visibleWebview: guest ? summarize(guest) : null, + implLookup, + withoutFocus: { + requestId: withoutFocusRequestId, + events: withoutFocusEvents, + }, + withGuestFocus: { + requestId: withGuestFocusRequestId, + events: withGuestFocusEvents, + }, + conclusion, + }; + }, browserWebviewsModulePath); +} + +// eslint-disable-next-line no-unused-vars +async function writeDiagnosticFocusExperiment( + experiment: DiagnosticFocusExperimentEvidence, +): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filePath = path.join(OUTPUT_DIR, `diagnostic-focus-experiment-${timestamp}.md`); + const lines = [ + "# Electron Find Focus Diagnostic Experiment", + "", + `Generated: ${new Date().toISOString()}`, + `Electron version: ${experiment.electronVersion}`, + "", + "## WebContents Identity", + "", + `- Visible webview: ${JSON.stringify(experiment.visibleWebview)}`, + `- Impl lookup: ${JSON.stringify(experiment.implLookup)}`, + "", + "## A. Blurred Guest", + "", + `- Request ID: ${experiment.withoutFocus.requestId ?? ""}`, + `- Events: ${JSON.stringify(experiment.withoutFocus.events)}`, + `- Fired events: ${experiment.withoutFocus.events.length > 0}`, + "", + "## B. Explicit Guest Focus", + "", + `- Request ID: ${experiment.withGuestFocus.requestId ?? ""}`, + `- Events: ${JSON.stringify(experiment.withGuestFocus.events)}`, + `- Fired events: ${experiment.withGuestFocus.events.length > 0}`, + "", + "## Conclusion", + "", + experiment.conclusion, + "", + ]; + await writeFile(filePath, lines.join("\n"), "utf8"); + return filePath; +} + +interface DiagnosticControlExperimentEvidence { + electronVersion: string; + contentsAtStart: Array<{ + id: number; + type: string; + url: string; + isDestroyed: boolean; + foundInPageListenerCount: number; + }>; + guest: { id: number; type: string; url: string } | null; + owner: { id: number; type: string; url: string } | null; + repeatUnique: { + requestId: number | null; + events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; + }; + ownerControl: { + requestId: number | null; + events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; + }; + afterReload: { + requestId: number | null; + didStopLoading: boolean; + events: Array<{ sourceId: number; type: string; url: string; result: unknown; ts: number }>; + }; + conclusion: string; +} + +// eslint-disable-next-line no-unused-vars +async function runDiagnosticControlExperiment( + electronApp: ElectronApplication, + guestId: number | null, +): Promise { + // eslint-disable-next-line complexity + return electronApp.evaluate(async ({ webContents }, qaGuestId) => { + interface ContentsSummary { + id: number; + type: string; + url: string; + } + interface FindEvent extends ContentsSummary { + sourceId: number; + result: unknown; + ts: number; + } + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const liveContents = () => + webContents.getAllWebContents().filter((contents) => !contents.isDestroyed()); + const summarize = (contents: Electron.WebContents): ContentsSummary => ({ + id: contents.id, + type: contents.getType(), + url: contents.getURL(), + }); + const contentsAtStart = liveContents().map((contents) => { + const summary = summarize(contents); + return { + id: summary.id, + type: summary.type, + url: summary.url, + isDestroyed: contents.isDestroyed(), + foundInPageListenerCount: contents.listenerCount("found-in-page"), + }; + }); + const owner = + liveContents().find((contents) => contents.getType() === "window") ?? + liveContents()[0] ?? + null; + const guestFromId = typeof qaGuestId === "number" ? webContents.fromId(qaGuestId) : null; + const guest = + guestFromId && !guestFromId.isDestroyed() + ? guestFromId + : (liveContents().find((contents) => contents.getType() === "webview") ?? null); + + const collectFind = async ( + target: Electron.WebContents | null, + text: string, + timeoutMs: number, + ) => { + const events: FindEvent[] = []; + const cleanups = liveContents().map((contents) => { + const listener = (_event: Electron.Event, result: Electron.Result) => { + events.push({ + sourceId: contents.id, + ...summarize(contents), + result, + ts: Date.now(), + }); + }; + contents.on("found-in-page", listener); + return () => contents.removeListener("found-in-page", listener); + }); + const requestId = target?.findInPage(text, { findNext: false }) ?? null; + await sleep(timeoutMs); + for (const cleanup of cleanups) cleanup(); + target?.stopFindInPage("clearSelection"); + owner?.stopFindInPage("clearSelection"); + guest?.stopFindInPage("clearSelection"); + return { requestId, events }; + }; + + const repeatUnique = await collectFind(guest, "control_repeat_unique", 2000); + const ownerControl = await collectFind(owner, "Workspace", 2000); + + let didStopLoading = false; + if (guest) { + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + // eslint-disable-next-line promise/no-multiple-resolved + resolve(); + }; + const timer = setTimeout(finish, 5000); + const stopLoading = () => { + didStopLoading = true; + finish(); + }; + guest.once("did-stop-loading", stopLoading); + guest.reload(); + }); + } + const afterReload = await collectFind(guest, "control_after_reload", 2000); + + let conclusion = "Electron findInPage did not deliver events on guest or owner controls."; + if (repeatUnique.events.length > 0) { + conclusion = "Guest findInPage still delivers events at the failing moment."; + } else if (ownerControl.events.length > 0 && afterReload.events.length > 0) { + conclusion = + "Top-level find works and reloading revives guest find delivery; the guest find path is stale before reload."; + } else if (ownerControl.events.length > 0) { + conclusion = + "Top-level find works, but guest find delivery is broken at this point even after reload."; + } else if (afterReload.events.length > 0) { + conclusion = "Reload revives guest find delivery, but top-level owner control did not fire."; + } + + return { + electronVersion: process.versions.electron, + contentsAtStart, + guest: guest ? summarize(guest) : null, + owner: owner ? summarize(owner) : null, + repeatUnique, + ownerControl, + afterReload: { + requestId: afterReload.requestId, + didStopLoading, + events: afterReload.events, + }, + conclusion, + }; + }, guestId); +} + +// eslint-disable-next-line no-unused-vars +async function writeDiagnosticControlExperiment( + experiment: DiagnosticControlExperimentEvidence, +): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filePath = path.join(OUTPUT_DIR, `diagnostic-control-experiment-${timestamp}.md`); + const lines = [ + "# Electron Find Control Diagnostic Experiment", + "", + `Generated: ${new Date().toISOString()}`, + `Electron version: ${experiment.electronVersion}`, + "", + "## WebContents", + "", + `- Contents at start: ${JSON.stringify(experiment.contentsAtStart)}`, + `- Guest target: ${JSON.stringify(experiment.guest)}`, + `- Owner target: ${JSON.stringify(experiment.owner)}`, + "", + "## A. Guest Repeat Unique", + "", + `- Request ID: ${experiment.repeatUnique.requestId ?? ""}`, + `- Event count: ${experiment.repeatUnique.events.length}`, + `- Events: ${JSON.stringify(experiment.repeatUnique.events)}`, + "", + "## B. Owner Control", + "", + `- Request ID: ${experiment.ownerControl.requestId ?? ""}`, + `- Event count: ${experiment.ownerControl.events.length}`, + `- Events: ${JSON.stringify(experiment.ownerControl.events)}`, + "", + "## C. Guest After Reload", + "", + `- did-stop-loading observed: ${experiment.afterReload.didStopLoading}`, + `- Request ID: ${experiment.afterReload.requestId ?? ""}`, + `- Event count: ${experiment.afterReload.events.length}`, + `- Events: ${JSON.stringify(experiment.afterReload.events)}`, + "", + "## Conclusion", + "", + experiment.conclusion, + "", + ]; + await writeFile(filePath, lines.join("\n"), "utf8"); + return filePath; +} + +// eslint-disable-next-line complexity +async function writeHarnessBlocker(input: { + reason: string; + diagnosticPath: string | null; + experimentPath: string | null; + evidence: QaEvidence | null; +}): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filePath = path.join(OUTPUT_DIR, `harness-blocker-${timestamp}.md`); + const lines = [ + "# Electron Find Harness Blocker", + "", + `Generated: ${new Date().toISOString()}`, + `Reason: ${input.reason}`, + `Diagnostic: ${input.diagnosticPath ?? ""}`, + `Diagnostic experiment: ${input.experimentPath ?? ""}`, + "", + "## Focus State", + "", + `- Selected page: ${JSON.stringify(input.evidence?.selectedPage ?? null)}`, + `- Find bar text: ${JSON.stringify(input.evidence?.app.findBarText ?? null)}`, + `- Input value: ${JSON.stringify(input.evidence?.app.findInputValue ?? null)}`, + `- Counter text: ${JSON.stringify(input.evidence?.app.counterText ?? null)}`, + `- Webview URL: ${JSON.stringify(input.evidence?.webview.url ?? null)}`, + "", + "## Main Process", + "", + `- Find IPC calls: ${JSON.stringify(input.evidence?.mainProcess?.ipcFindCalls ?? [])}`, + `- Electron found-in-page events: ${JSON.stringify( + input.evidence?.mainProcess?.guestFoundInPageEvents ?? [], + )}`, + `- Owner forwards: ${JSON.stringify(input.evidence?.mainProcess?.ownerForwards ?? [])}`, + "", + ]; + await writeFile(filePath, lines.join("\n"), "utf8"); + return filePath; } async function collectEvidence( page: Page, + electronApp: ElectronApplication, processInfo: ElectronProcess, daemonListen: string | null, serverId: string | null, - listenerSnapshots: Record>, ): Promise { + const pages = await collectPageSnapshots(electronApp); + const mainProcess = await readMainProcessEvidence(electronApp).catch(() => null); + const selectedPage = await snapshotPage(page); const runtime = await page.evaluate(async () => { const webview = document.querySelector("webview") as | (HTMLElement & { @@ -333,13 +1189,16 @@ async function collectEvidence( const input = document.querySelector( "[data-testid='pane-find-input']", ) as HTMLInputElement | null; + const bodyText = document.body.innerText; return { app: { url: location.href, - bodyText: document.body.innerText, + title: document.title, + bodyText, logboxCount: document.querySelectorAll("[data-testid='logbox_title']").length, findBarText: document.querySelector("[data-testid='pane-find-bar']")?.textContent ?? null, findInputValue: input?.value ?? null, + counterText: bodyText.match(/\b\d+\s*\/\s*\d+\b/)?.[0] ?? null, }, webview: { url: webview?.getURL?.() ?? null, @@ -349,27 +1208,36 @@ async function collectEvidence( webContentsId: webview?.getWebContentsId?.() ?? null, findInPageType: typeof webview?.findInPage, stopFindInPageType: typeof webview?.stopFindInPage, - domReady: Boolean(window.__paseoElectronFindQa?.domReady), }, qa: window.__paseoElectronFindQa ?? { timestamps: {}, - domReadyEvents: [], - findCalls: [], - stopCalls: [], - foundEvents: [], - manualFoundEvents: [], + bridgeAvailable: false, + findInPageType: null, + stopFindInPageType: null, + onFoundInPageType: null, + bridgeFindCalls: [], + bridgeStopCalls: [], + bridgeListenerRegistrations: [], + bridgeFoundEvents: [], }, }; }); return { timestamps: runtime.qa.timestamps, - domReadyEvents: runtime.qa.domReadyEvents, - listenerSnapshots, - findCalls: runtime.qa.findCalls, - stopCalls: runtime.qa.stopCalls, - foundEvents: runtime.qa.foundEvents, - manualFoundEvents: runtime.qa.manualFoundEvents, + pages, + selectedPage, + bridge: { + available: runtime.qa.bridgeAvailable, + findInPageType: runtime.qa.findInPageType, + stopFindInPageType: runtime.qa.stopFindInPageType, + onFoundInPageType: runtime.qa.onFoundInPageType, + findCalls: runtime.qa.bridgeFindCalls, + stopCalls: runtime.qa.bridgeStopCalls, + listenerRegistrations: runtime.qa.bridgeListenerRegistrations, + foundEvents: runtime.qa.bridgeFoundEvents, + }, + mainProcess, webview: runtime.webview, app: runtime.app, process: { @@ -380,37 +1248,48 @@ async function collectEvidence( }, sources: { browserPaneElectron: [ - "packages/app/src/components/browser-pane.electron.tsx:450-460 calls renderer webview.findInPage and stores the numeric requestId.", - "packages/app/src/components/browser-pane.electron.tsx:602-607 marks dom-ready and retries a pending query.", - "packages/app/src/components/browser-pane.electron.tsx:622-623 attaches renderer dom-ready and found-in-page listeners.", + "packages/app/src/components/browser-pane.electron.tsx:464-485 prefers getDesktopHost().browser.findInPage before falling back to webview.findInPage.", + "packages/app/src/components/browser-pane.electron.tsx:681-701 subscribes through getDesktopHost().browser.onFoundInPage when the bridge exists.", ], preload: [ - "packages/desktop/src/preload.ts:60-62 exposes browser.setActivePane only; there is no find bridge.", + "packages/desktop/src/preload.ts:68-96 exposes browser.findInPage, stopFindInPage, and onFoundInPage on window.paseoDesktop.browser.", ], main: [ - "packages/desktop/src/main.ts:340-345 receives did-attach-webview and registers the WebContents.", - "packages/desktop/src/main.ts:345-365 forwards selected browser keyboard shortcuts, but does not forward find results.", + "packages/desktop/src/main.ts:223-242 handles paseo:browser:find-in-page by calling the guest WebContents findInPage.", + "packages/desktop/src/main.ts:245-260 handles paseo:browser:stop-find-in-page.", + "packages/desktop/src/main.ts:381-385 registers attached browser WebContents with the owner BrowserWindow WebContents.", ], browserWebviews: [ - "packages/desktop/src/features/browser-webviews.ts:6-20 maps browserId to WebContents.", - "packages/desktop/src/features/browser-webviews.ts:27-38 can return the active browser WebContents.", + "packages/desktop/src/features/browser-webviews.ts:20-34 forwards guest WebContents found-in-page results to the owner renderer.", + "packages/desktop/src/features/browser-webviews.ts:59-66 resolves WebContents by browserId for main-process find.", ], electronDocs: [ - "https://www.electronjs.org/docs/latest/api/webview-tag#event-found-in-page documents renderer found-in-page for webview.findInPage.", "https://www.electronjs.org/docs/latest/api/web-contents#event-found-in-page documents main-process webContents found-in-page for contents.findInPage.", ], }, hypothesis: - "The renderer wrapper exposes findInPage and returns request IDs, but this Electron 41 sandboxed webview path is not delivering renderer-side found-in-page events.", + "The main-process bridge should make renderer webview found-in-page delivery irrelevant; failure now means the app renderer did not receive or consume bridge results.", cheapestFixShape: - "Move browser find execution/result collection to the already-registered guest WebContents in the main process, then expose a narrow preload bridge that accepts browserId/query/navigation commands and emits found-in-page results back to the renderer; keep the existing FindBar state machine but stop depending on renderer found-in-page delivery.", + "Keep the app renderer as the only Playwright control target, assert the shared FindBar counter, and use window.paseoDesktop.browser instrumentation only as diagnostic evidence around bridge calls and result callbacks.", }; } +async function writeEvidence(evidence: QaEvidence): Promise { + await writeFile( + path.join(OUTPUT_DIR, "electron-find-evidence.json"), + JSON.stringify(evidence, null, 2), + "utf8", + ); +} + +// eslint-disable-next-line complexity async function writeDiagnostic(evidence: QaEvidence): Promise { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const filePath = path.join(OUTPUT_DIR, `diagnostic-${timestamp}.md`); - const foundListenerBeforeFind = evidence.listenerSnapshots.beforeFind?.["found-in-page"] ?? 0; + const pageLines = evidence.pages.map( + (page, index) => + `- page ${index + 1}: title=${JSON.stringify(page.title)} url=${JSON.stringify(page.url)}`, + ); const sourceReferenceLines: string[] = []; for (const [group, refs] of Object.entries(evidence.sources)) { sourceReferenceLines.push(`### ${group}`, ""); @@ -430,30 +1309,49 @@ async function writeDiagnostic(evidence: QaEvidence): Promise { `- CDP: http://127.0.0.1:${evidence.process.cdpPort}`, `- Daemon: ${evidence.process.daemonListen ?? ""}`, `- Server ID: ${evidence.process.serverId ?? ""}`, + `- Selected renderer title: ${JSON.stringify(evidence.selectedPage.title)}`, + `- Selected renderer URL: ${evidence.selectedPage.url}`, `- Webview URL: ${evidence.webview.url ?? ""}`, "", + "## Pages", + "", + ...pageLines, + "", "## Findings", "", - `- dom-ready before findInPage: ${evidence.timestamps.domReady && evidence.timestamps.firstFindCall ? evidence.timestamps.domReady <= evidence.timestamps.firstFindCall : false}`, - `- dom-ready timestamp: ${evidence.timestamps.domReady ?? ""}`, - `- first findInPage timestamp: ${evidence.timestamps.firstFindCall ?? ""}`, - `- renderer found-in-page listener count before find: ${foundListenerBeforeFind}`, - `- webview.findInPage typeof: ${evidence.webview.findInPageType}`, - `- webview.stopFindInPage typeof: ${evidence.webview.stopFindInPageType}`, + `- selected app renderer: ${isAppRendererSnapshot(evidence.selectedPage, evidence.process.metroPort)}`, + `- app body starts with guest page only: ${evidence.app.bodyText.trim() === "Find QA Page"}`, + `- bridge available: ${evidence.bridge.available}`, + `- bridge findInPage typeof: ${evidence.bridge.findInPageType}`, + `- bridge stopFindInPage typeof: ${evidence.bridge.stopFindInPageType}`, + `- bridge onFoundInPage typeof: ${evidence.bridge.onFoundInPageType}`, + `- first bridge find timestamp: ${evidence.timestamps.firstBridgeFindCall ?? ""}`, + `- bridge listener registrations: ${JSON.stringify(evidence.bridge.listenerRegistrations)}`, + `- bridge findInPage calls: ${JSON.stringify(evidence.bridge.findCalls)}`, + `- bridge found-in-page events observed in app renderer: ${JSON.stringify(evidence.bridge.foundEvents)}`, + `- bridge stopFindInPage calls: ${JSON.stringify(evidence.bridge.stopCalls)}`, + `- main-process instrumentation installed: ${evidence.mainProcess?.installed ?? false}`, + `- main-process ipc handler wraps: ${JSON.stringify(evidence.mainProcess?.ipcHandleWraps ?? [])}`, + `- main-process find IPC calls: ${JSON.stringify(evidence.mainProcess?.ipcFindCalls ?? [])}`, + `- main-process stop IPC calls: ${JSON.stringify(evidence.mainProcess?.ipcStopCalls ?? [])}`, + `- main-process Electron found-in-page events: ${JSON.stringify( + evidence.mainProcess?.guestFoundInPageEvents ?? [], + )}`, + `- main-process owner forwards: ${JSON.stringify(evidence.mainProcess?.ownerForwards ?? [])}`, + `- main-process webContents observed: ${JSON.stringify( + evidence.mainProcess?.webContentsCreated ?? [], + )}`, `- webview.getWebContentsId(): ${evidence.webview.webContentsId ?? ""}`, - `- findInPage calls: ${JSON.stringify(evidence.findCalls)}`, - `- found-in-page events observed by renderer listeners: ${JSON.stringify(evidence.foundEvents)}`, - `- manual renderer found-in-page events after direct second findInPage: ${JSON.stringify(evidence.manualFoundEvents)}`, + `- webview text matches fixture: ${evidence.webview.text === QA_PAGE_TEXT}`, `- UI find bar text: ${JSON.stringify(evidence.app.findBarText)}`, `- UI input value: ${JSON.stringify(evidence.app.findInputValue)}`, + `- UI counter text: ${JSON.stringify(evidence.app.counterText)}`, `- LogBox count: ${evidence.app.logboxCount}`, "", "## Event Pattern", "", - "- Electron documents both renderer `.addEventListener('found-in-page', ...)` for `webview.findInPage()` and main-process `webContents.on('found-in-page', ...)` for `contents.findInPage()`.", - "- This codebase currently uses the renderer `` pattern: `browser-pane.electron.tsx:450-460` calls `webview.findInPage`, and `browser-pane.electron.tsx:622-623` attaches `found-in-page` directly to the `` element.", - "- The preload bridge only exposes `browser.setActivePane` (`packages/desktop/src/preload.ts:60-62`); it does not expose a find command or result channel.", - "- The main process already registers the guest WebContents (`packages/desktop/src/main.ts:340-345`, `packages/desktop/src/features/browser-webviews.ts:6-20`) but does not listen for `found-in-page`.", + "- This harness drives only the Paseo app renderer page. Guest `` pages are listed as evidence but are not used for keyboard, screenshot, evaluate, or assertions.", + "- The current implementation uses the main-process bridge: the renderer calls `window.paseoDesktop.browser.findInPage`, main calls `WebContents.findInPage`, main receives `webContents.on('found-in-page')`, then preload forwards the result to the app renderer.", "", "## Source References", "", @@ -472,107 +1370,71 @@ async function writeDiagnostic(evidence: QaEvidence): Promise { } test.describe("Electron browser in-pane find", () => { - test("forwards Cmd+F find to the Electron webview and reports native found-in-page results", async () => { + // eslint-disable-next-line complexity + test("forwards Cmd+F find through the Electron app renderer bridge", async () => { test.setTimeout(150_000); let staticServer: Server | null = null; let electron: ElectronProcess | null = null; - let browser: Browser | null = null; + let appPage: Page | null = null; + let workspaceClient: WorkspaceSetupClient | null = null; + let repo: TempRepo | null = null; let evidence: QaEvidence | null = null; let diagnosticPath: string | null = null; - const listenerSnapshots: Record> = {}; + let experimentPath: string | null = null; + let focusExperimentPath: string | null = null; + let controlExperimentPath: string | null = null; try { const staticSite = await startStaticServer(); staticServer = staticSite.server; + paseoListen = `127.0.0.1:${await freePort()}`; + await startIsolatedDaemon(); electron = await startElectron(); + const serverId = await readServerId(); const daemonListen = await waitFor("isolated desktop daemon", readDaemonListen, 45_000); if (daemonListen === "127.0.0.1:6767" || daemonListen === "localhost:6767") { throw new Error("Refusing to run Electron find QA against port 6767."); } - const serverId = await readServerId(); - const agentId = await createMockAgent(); + const daemonPort = daemonListen.split(":").at(-1); + if (!daemonPort) { + throw new Error(`Could not parse daemon port from ${daemonListen}`); + } + process.env.E2E_DAEMON_PORT = daemonPort; + process.env.E2E_SERVER_ID = serverId; + workspaceClient = await connectWorkspaceSetupClient(); + repo = await createTempGitRepo("electron-find-pane-", { + files: [{ path: "README.find.md", content: "electron find pane bootstrap\n" }], + }); + const workspaceResult = await workspaceClient.openProject(repo.path); + if (!workspaceResult.workspace) { + throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); + } - browser = await chromium.connectOverCDP(`http://127.0.0.1:${electron.cdpPort}`); - const page = browser.contexts()[0]?.pages()[0]; - if (!page) throw new Error("Unable to find Electron renderer page over CDP."); + appPage = await findAppRendererPage(electron.app, electron.metroPort); const workspaceRoute = `http://localhost:${electron.metroPort}/h/${serverId}/workspace/${encodeWorkspaceId( - REPO_ROOT, - )}?open=${encodeURIComponent(`agent:${agentId}`)}`; - await page.goto(workspaceRoute); - await page + workspaceResult.workspace.id, + )}`; + await appPage.goto(workspaceRoute); + appPage = await findAppRendererPage(electron.app, electron.metroPort); + await appPage .getByTestId("workspace-new-browser") .waitFor({ state: "visible", timeout: 30_000 }); - await screenshot(page, "electron-find-01-workspace.png"); + await screenshot(appPage, "electron-find-01-workspace.png"); - await page.getByTestId("workspace-new-browser").click(); - await page.locator("webview").waitFor({ state: "attached", timeout: 15_000 }); - await page.waitForTimeout(1_000); - await screenshot(page, "electron-find-02-browser-opened.png"); - await expect(page.getByTestId("logbox_title")).toHaveCount(0); + await instrumentBridge(appPage); + await appPage.getByTestId("workspace-new-browser").click(); + const webviewLocator = appPage.locator("webview").first(); + await webviewLocator.waitFor({ state: "attached", timeout: 15_000 }); + await appPage.waitForTimeout(1_000); + await screenshot(appPage, "electron-find-02-browser-opened.png"); + await expect(appPage.getByTestId("logbox_title")).toHaveCount(0); - listenerSnapshots.afterBrowserOpen = await getListenerCounts(page); - - await page.evaluate(() => { - const webview = document.querySelector("webview") as - | (HTMLElement & { - findInPage?: (text: string, options?: Record) => number; - stopFindInPage?: (action: string) => void; - }) - | null; - if (!webview?.findInPage || !webview.stopFindInPage) { - throw new Error("webview find API is unavailable"); - } - const qa: NonNullable = { - timestamps: { instrumentationAttached: Date.now() }, - domReadyEvents: [], - findCalls: [], - stopCalls: [], - foundEvents: [], - manualFoundEvents: [], - domReady: false, - }; - window.__paseoElectronFindQa = qa; - const recordDomReady = () => { - qa.domReady = true; - qa.timestamps.domReady = Date.now(); - qa.domReadyEvents.push({ ts: Date.now() }); - }; - const recordFoundInPage = (event: Event & { result?: unknown }) => { - qa.foundEvents.push({ - result: event.result ?? null, - ts: Date.now(), - }); - }; - webview.addEventListener("dom-ready", recordDomReady); - webview.addEventListener("found-in-page", recordFoundInPage); - const originalFind = webview.findInPage.bind(webview); - const originalStop = webview.stopFindInPage.bind(webview); - webview.findInPage = (text: string, options?: Record) => { - const requestId = originalFind(text, options); - const ts = Date.now(); - qa.timestamps.firstFindCall ??= ts; - qa.findCalls.push({ - text, - options: { ...options }, - requestId: typeof requestId === "number" ? requestId : null, - ts, - }); - return requestId; - }; - webview.stopFindInPage = (action: string) => { - qa.stopCalls.push({ action, ts: Date.now() }); - return originalStop(action); - }; - }); - - listenerSnapshots.afterInstrumentation = await getListenerCounts(page); - - const urlInput = page.getByRole("textbox", { name: "Browser URL" }); + const urlInput = appPage.getByRole("textbox", { name: "Browser URL" }); await urlInput.fill(`http://127.0.0.1:${staticSite.port}/index.html`); await urlInput.press("Enter"); - await page.waitForFunction( + await appPage.waitForFunction( (port) => { const webview = document.querySelector("webview") as | (Element & { getURL?: () => string }) @@ -582,93 +1444,144 @@ test.describe("Electron browser in-pane find", () => { staticSite.port, { timeout: 15_000 }, ); - await page.waitForFunction(() => window.__paseoElectronFindQa?.domReady === true, null, { - timeout: 15_000, - }); - await screenshot(page, "electron-find-03-page-loaded.png"); + const loadedAppPage = appPage; + await expect + .poll(() => readWebviewBodyText(loadedAppPage), { timeout: 10_000 }) + .toContain("electronneedle"); + await screenshot(appPage, "electron-find-03-page-loaded.png"); + await installMainProcessInstrumentation(electron.app, `http://127.0.0.1:${staticSite.port}`); + await instrumentBridge(appPage); - listenerSnapshots.beforeFind = await getListenerCounts(page); + const selectedPageBeforeFind = await snapshotPage(appPage); + if (!isAppRendererSnapshot(selectedPageBeforeFind, electron.metroPort)) { + throw new Error( + `Refusing to dispatch keyboard to non-app page: ${selectedPageBeforeFind.title} ${selectedPageBeforeFind.url}`, + ); + } - const box = await page.locator("webview").boundingBox(); + const box = await webviewLocator.boundingBox(); if (!box) throw new Error("webview has no bounding box"); - await page.mouse.click( - box.x + Math.min(120, box.width / 2), - box.y + Math.min(120, box.height / 2), - ); - await page.keyboard.press(process.platform === "darwin" ? "Meta+f" : "Control+f"); - await page.getByTestId("pane-find-bar").waitFor({ state: "visible", timeout: 10_000 }); - await page.getByTestId("pane-find-input").fill("electronneedle"); + await appPage.evaluate(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + window.focus(); + }); + await appPage.mouse.click(box.x + Math.min(160, box.width / 2), Math.max(24, box.y - 110)); + await appPage.waitForTimeout(250); + const activeElementBeforeShortcut = await appPage.evaluate(() => ({ + tagName: document.activeElement?.tagName ?? null, + testId: document.activeElement?.getAttribute("data-testid") ?? null, + ariaLabel: document.activeElement?.getAttribute("aria-label") ?? null, + })); + if (activeElementBeforeShortcut.tagName === "WEBVIEW") { + throw new Error( + `Host focus failed before Cmd+F: ${JSON.stringify(activeElementBeforeShortcut)}`, + ); + } + await screenshot(appPage, "electron-find-04-before-keypress.png"); + const syntheticOpenResult = await appPage.evaluate(() => { + const event = new KeyboardEvent("keydown", { + key: "f", + code: "KeyF", + metaKey: navigator.platform.toLowerCase().includes("mac"), + ctrlKey: !navigator.platform.toLowerCase().includes("mac"), + bubbles: true, + cancelable: true, + }); + return window.dispatchEvent(event); + }); + await appPage.getByTestId("pane-find-bar").waitFor({ state: "visible", timeout: 10_000 }); + await appPage.evaluate((value) => { + const input = document.querySelector( + "[data-testid='pane-find-input']", + ) as HTMLInputElement | null; + if (!input) { + throw new Error("pane-find-input not found"); + } + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + valueSetter?.call(input, value); + input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }, "electronneedle"); + await appPage.evaluate((opened) => { + window.__paseoElectronFindQa = window.__paseoElectronFindQa ?? { + timestamps: {}, + bridgeAvailable: false, + findInPageType: null, + stopFindInPageType: null, + onFoundInPageType: null, + bridgeFindCalls: [], + bridgeStopCalls: [], + bridgeListenerRegistrations: [], + bridgeFoundEvents: [], + }; + window.__paseoElectronFindQa.timestamps.syntheticOpenDispatchReturned = opened ? 1 : 0; + }, syntheticOpenResult); + await expect(appPage.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); + await screenshot(appPage, "electron-find-04-query.png"); - await page.waitForFunction( - () => (window.__paseoElectronFindQa?.foundEvents?.length ?? 0) > 0, - null, - { timeout: 10_000 }, - ); - await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); - await screenshot(page, "electron-find-04-query.png"); + await appPage.getByTestId("pane-find-next").click(); + await expect(appPage.getByText("2 / 3")).toBeVisible({ timeout: 10_000 }); + await appPage.getByTestId("pane-find-prev").click(); + await expect(appPage.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); + await appPage.getByTestId("pane-find-close").click(); + await expect(appPage.getByTestId("pane-find-bar")).toHaveCount(0); - await page.getByTestId("pane-find-next").click(); - await expect(page.getByText("2 / 3")).toBeVisible({ timeout: 10_000 }); - await page.getByTestId("pane-find-prev").click(); - await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); - await page.getByTestId("pane-find-input").focus(); - await page.keyboard.press("Enter"); - await expect(page.getByText("2 / 3")).toBeVisible({ timeout: 10_000 }); - await page.keyboard.press("Shift+Enter"); - await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); - await page.keyboard.press("Escape"); - await expect(page.getByTestId("pane-find-bar")).toHaveCount(0); - - await page.keyboard.press(process.platform === "darwin" ? "Meta+f" : "Control+f"); - await page.getByTestId("pane-find-input").fill("electronneedle"); - await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); - await page.getByTestId("pane-find-close").click(); - await expect(page.getByTestId("pane-find-bar")).toHaveCount(0); - - evidence = await collectEvidence(page, electron, daemonListen, serverId, listenerSnapshots); - await writeFile( - path.join(OUTPUT_DIR, "electron-find-evidence.json"), - JSON.stringify(evidence, null, 2), - "utf8", - ); + evidence = await collectEvidence(appPage, electron.app, electron, daemonListen, serverId); + await writeEvidence(evidence); diagnosticPath = await writeDiagnostic(evidence); - await screenshot(page, "electron-find-05-closed.png"); + await screenshot(appPage, "electron-find-05-closed.png"); + expect(evidence.bridge.foundEvents.length).toBeGreaterThan(0); + expect(evidence.mainProcess?.ipcFindCalls.length ?? 0).toBeGreaterThan(0); + expect(evidence.mainProcess?.ownerForwards.length ?? 0).toBeGreaterThan(0); } catch (error) { - if (browser && electron) { - const page = browser.contexts()[0]?.pages()[0]; - if (page) { + if (electron) { + appPage = + appPage ?? + (await findAppRendererPage(electron.app, electron.metroPort).catch(() => null)); + if (appPage) { const daemonListen = await readDaemonListen().catch(() => null); const serverId = await readServerId().catch(() => null); - await screenshot(page, "electron-find-failure-state.png").catch(() => undefined); - evidence = await collectEvidence( - page, - electron, - daemonListen, - serverId, - listenerSnapshots, - ); - await writeFile( - path.join(OUTPUT_DIR, "electron-find-evidence.json"), - JSON.stringify(evidence, null, 2), - "utf8", - ); + await screenshot(appPage, "electron-find-failure-state.png").catch(() => undefined); + evidence = await collectEvidence(appPage, electron.app, electron, daemonListen, serverId); + await writeEvidence(evidence); diagnosticPath = await writeDiagnostic(evidence); + if ( + error instanceof Error && + (error.message.includes("pane-find-bar") || + error.message.includes("getByText('1 / 3')")) + ) { + await writeHarnessBlocker({ + reason: error.message, + diagnosticPath, + experimentPath, + evidence, + }); + } } } throw new Error( `${error instanceof Error ? error.message : String(error)}\nDiagnostic: ${ diagnosticPath ?? "" - }`, + }\nDiagnostic experiment: ${experimentPath ?? ""}\nFocus experiment: ${ + focusExperimentPath ?? "" + }\nControl experiment: ${controlExperimentPath ?? ""}`, { cause: error }, ); } finally { - await browser?.close().catch(() => undefined); + await workspaceClient?.close().catch(() => undefined); + await repo?.cleanup().catch(() => undefined); if (electron) await stopElectron(electron); if (staticServer) await stopServer(staticServer); } + expect(evidence?.selectedPage.title).not.toBe("Find QA Page"); expect(evidence?.webview.text).toBe(QA_PAGE_TEXT); - expect(evidence?.foundEvents.length).toBeGreaterThan(0); + expect(evidence?.bridge.foundEvents.length).toBeGreaterThan(0); }); }); @@ -676,17 +1589,20 @@ declare global { interface Window { __paseoElectronFindQa?: { timestamps: Record; - domReadyEvents: Array<{ ts: number }>; - findCalls: Array<{ + bridgeAvailable: boolean; + findInPageType: string | null; + stopFindInPageType: string | null; + onFoundInPageType: string | null; + bridgeFindCalls: Array<{ + browserId: string; text: string; options: Record; requestId: number | null; ts: number; }>; - stopCalls: Array<{ action: string; ts: number }>; - foundEvents: Array<{ result: unknown; ts: number }>; - manualFoundEvents: Array<{ result: unknown; ts: number }>; - domReady: boolean; + bridgeStopCalls: Array<{ browserId: string; action: string; ts: number }>; + bridgeListenerRegistrations: Array<{ browserId: string; ts: number }>; + bridgeFoundEvents: Array<{ browserId: string; result: unknown; ts: number }>; }; } } diff --git a/packages/app/e2e/find-in-pane.spec.ts b/packages/app/e2e/find-in-pane.spec.ts new file mode 100644 index 0000000000..12d8e1c0ef --- /dev/null +++ b/packages/app/e2e/find-in-pane.spec.ts @@ -0,0 +1,196 @@ +import { expect, test, type Page } from "./fixtures"; +import { waitForTabBar } from "./helpers/launcher"; +import { + connectTerminalClient, + setupDeterministicPrompt, + waitForTerminalContent, +} from "./helpers/terminal-perf"; +import { createTempGitRepo } from "./helpers/workspace"; +import { openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer"; +import { + connectWorkspaceSetupClient, + openHomeWithProject, + seedProjectForWorkspaceSetup, +} from "./helpers/workspace-setup"; +import { buildHostWorkspaceRoute } from "../src/utils/host-routes"; + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + return serverId; +} + +function findShortcut(): string { + return process.platform === "darwin" ? "Meta+f" : "Control+f"; +} + +async function navigateToWorkspaceViaSidebar(page: Page, workspaceId: string): Promise { + const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + await waitForTabBar(page); +} + +async function openFind(page: Page): Promise { + await page.keyboard.press(findShortcut()); + await expect(page.getByTestId("pane-find-bar")).toBeVisible({ timeout: 10_000 }); +} + +async function typeFindQuery(page: Page, query: string): Promise { + const input = page.getByTestId("pane-find-input"); + await expect(input).toBeVisible({ timeout: 10_000 }); + await input.fill(query); +} + +test.describe("in-pane find manual QA", () => { + test("walks chat, file, terminal, split-pane, and browser-web find flows in the running app", async ({ + page, + }, testInfo) => { + test.setTimeout(120_000); + + const client = await connectWorkspaceSetupClient(); + const agentClient = await connectTerminalClient(); + const repo = await createTempGitRepo("find-pane-qa-", { + files: [ + { + path: "src/find-target.txt", + content: [ + "alpha needle first", + "beta without match", + "gamma NEEDLE second", + "delta needle third", + "", + ].join("\n"), + }, + ], + }); + + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const workspaceResult = await client.openProject(repo.path); + if (!workspaceResult.workspace) { + throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); + } + + await openHomeWithProject(page, repo.path); + await navigateToWorkspaceViaSidebar(page, workspaceResult.workspace.id); + + await openFileExplorer(page); + await openFileFromExplorer(page, "src"); + await openFileFromExplorer(page, "find-target.txt"); + await expect(page.getByTestId("workspace-file-pane")).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("workspace-file-pane").click(); + + await openFind(page); + await typeFindQuery(page, "needle"); + await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("pane-find-next").click(); + await expect(page.getByText("2 / 3")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("pane-find-prev").click(); + await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("pane-find-input").focus(); + await page.keyboard.press("Enter"); + await expect(page.getByText("2 / 3")).toBeVisible({ timeout: 10_000 }); + await page.keyboard.press("Shift+Enter"); + await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); + await typeFindQuery(page, "missing-value"); + await expect(page.getByText("No matches")).toBeVisible({ timeout: 10_000 }); + await typeFindQuery(page, ""); + await expect(page.getByText("0 / 0")).toBeVisible({ timeout: 10_000 }); + await typeFindQuery(page, "needle"); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("pane-find-bar")).toHaveCount(0); + await testInfo.attach("file-find-walkthrough", { + body: await page.screenshot(), + contentType: "image/png", + }); + + await page.getByLabel("Split pane right").filter({ visible: true }).last().click(); + await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2); + await page.getByTestId("workspace-new-terminal").filter({ visible: true }).last().click(); + const splitTerminal = page.getByTestId("terminal-surface").last(); + await expect(splitTerminal).toBeVisible({ timeout: 20_000 }); + await splitTerminal.click(); + await setupDeterministicPrompt(page, `SPLIT_FIND_READY_${Date.now()}`); + await splitTerminal.pressSequentially("printf 'split needle one\\nsplit needle two\\n'\n", { + delay: 0, + }); + await waitForTerminalContent(page, (text) => text.includes("split needle two"), 10_000); + await openFind(page); + await typeFindQuery(page, "needle"); + await expect(page.getByText(/1 \/ 2|2 \/ 2|Searching\.\.\./)).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("workspace-file-pane").click(); + await expect(page.getByTestId("pane-find-bar")).toHaveCount(0); + await openFind(page); + await typeFindQuery(page, "needle"); + await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("pane-find-close").click(); + await expect(page.getByTestId("pane-find-bar")).toHaveCount(0); + await testInfo.attach("terminal-find-walkthrough", { + body: await page.screenshot(), + contentType: "image/png", + }); + await testInfo.attach("split-pane-focus-switching", { + body: await page.screenshot(), + contentType: "image/png", + }); + + const agent = await agentClient.createAgent({ + provider: "mock", + cwd: repo.path, + title: "Find pane QA", + modeId: "load-test", + model: "ten-second-stream", + initialPrompt: "chat needle alpha", + }); + await page.goto( + `${buildHostWorkspaceRoute(getServerId(), repo.path)}?open=${encodeURIComponent( + `agent:${agent.id}`, + )}`, + ); + await expect(page.getByText("chat needle alpha").first()).toBeVisible({ + timeout: 30_000, + }); + await page.getByText("chat needle alpha").first().click(); + await openFind(page); + await typeFindQuery(page, "needle"); + await expect(page.getByText(/[1-9] \/ [1-9]/)).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("pane-find-next").click(); + await page.getByTestId("pane-find-prev").click(); + await page.getByTestId("pane-find-close").click(); + await expect(page.getByTestId("pane-find-bar")).toHaveCount(0); + await testInfo.attach("chat-find-walkthrough", { + body: await page.screenshot(), + contentType: "image/png", + }); + + await page.getByTestId("workspace-header-menu-trigger").click(); + const browserMenuItem = page.getByTestId("workspace-header-new-browser"); + if (await browserMenuItem.isVisible().catch(() => false)) { + await browserMenuItem.click(); + await expect( + page.getByText("Open this workspace in Electron to use the built-in browser."), + ).toBeVisible({ timeout: 10_000 }); + await page.keyboard.press(findShortcut()); + await expect(page.getByTestId("pane-find-bar")).toHaveCount(0); + await testInfo.attach("browser-web-fallback", { + body: await page.screenshot(), + contentType: "image/png", + }); + } else { + await testInfo.attach("browser-web-fallback", { + body: "Browser tab creation is not exposed in this browser-web runtime.", + contentType: "text/plain", + }); + } + } finally { + await agentClient.close(); + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 5632c91298..0575c4f133 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -32,11 +32,25 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ autoscrollToTopThreshold: 0, }); const HISTORY_START_THRESHOLD_PX = 96; +const SCROLL_TO_INDEX_RETRY_DELAY_MS = 80; + +interface NativeScrollToIndexFailedInfo { + index: number; + highestMeasuredFrameIndex: number; + averageItemLength: number; +} function keyExtractor(item: { id: string }): string { return item.id; } +export function getNativeScrollToIndexFallbackOffset(input: NativeScrollToIndexFailedInfo) { + if (!Number.isFinite(input.averageItemLength) || input.averageItemLength <= 0) { + return 0; + } + return Math.max(0, input.averageItemLength * input.index); +} + function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) { const { agentId, @@ -69,6 +83,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const scrollOffsetYRef = useRef(0); const programmaticScrollEventBudgetRef = useRef(0); + const scrollToIndexRetryTimeoutRef = useRef | null>(null); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); @@ -87,6 +102,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } }, []); + const clearScrollToIndexRetry = useCallback(() => { + if (scrollToIndexRetryTimeoutRef.current !== null) { + clearTimeout(scrollToIndexRetryTimeoutRef.current); + scrollToIndexRetryTimeoutRef.current = null; + } + }, []); + const markNativeViewportSettling = useCallback(() => { clearNativeViewportSettling(); setIsNativeViewportSettling(true); @@ -149,6 +171,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat scrollToBottom, }); + const scrollToHistoryIndex = useCallback( + (index: number) => { + programmaticScrollEventBudgetRef.current = 3; + clearScrollToIndexRetry(); + flatListRef.current?.scrollToIndex({ + index, + animated: true, + viewPosition: 0.5, + }); + }, + [clearScrollToIndexRetry], + ); + + const handleScrollToIndexFailed = useStableEvent((info: NativeScrollToIndexFailedInfo) => { + programmaticScrollEventBudgetRef.current = 3; + flatListRef.current?.scrollToOffset({ + offset: getNativeScrollToIndexFallbackOffset(info), + animated: true, + }); + clearScrollToIndexRetry(); + scrollToIndexRetryTimeoutRef.current = setTimeout(() => { + scrollToIndexRetryTimeoutRef.current = null; + programmaticScrollEventBudgetRef.current = 3; + flatListRef.current?.scrollToIndex({ + index: info.index, + animated: true, + viewPosition: 0.5, + }); + }, SCROLL_TO_INDEX_RETRY_DELAY_MS); + }); + useEffect(() => { streamViewportMetricsRef.current = { containerKey: "native-virtualized", @@ -161,6 +214,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }; scrollOffsetYRef.current = 0; clearNativeViewportSettling(); + clearScrollToIndexRetry(); setIsNativeViewportSettling(false); historyStartReadyRef.current = false; const frame = requestAnimationFrame(() => { @@ -169,7 +223,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat return () => { cancelAnimationFrame(frame); }; - }, [agentId, clearNativeViewportSettling]); + }, [agentId, clearNativeViewportSettling, clearScrollToIndexRetry]); useEffect(() => { const keyboardEvents = [ @@ -190,8 +244,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat subscription.remove(); } clearNativeViewportSettling(); + clearScrollToIndexRetry(); }; - }, [clearNativeViewportSettling, markNativeViewportSettling]); + }, [clearNativeViewportSettling, clearScrollToIndexRetry, markNativeViewportSettling]); useEffect(() => { bottomAnchorController.prepareForStickyContentChange(); @@ -209,6 +264,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat bottomAnchorController.prepareForStickyViewportChange(); markNativeViewportSettling(); }, + scrollToStreamItem: (target) => { + programmaticScrollEventBudgetRef.current = 3; + if (target.source === "liveHead") { + flatListRef.current?.scrollToOffset({ + offset: 0, + animated: true, + }); + return; + } + scrollToHistoryIndex(target.index); + }, }; viewportRef.current = handle; return () => { @@ -216,7 +282,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat viewportRef.current = null; } }; - }, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]); + }, [ + agentId, + bottomAnchorController, + markNativeViewportSettling, + scrollToHistoryIndex, + viewportRef, + ]); const handleScroll = useStableEvent((event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; @@ -360,6 +432,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat onScroll={handleScroll} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} + onScrollToIndexFailed={handleScrollToIndexFailed} maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION} initialNumToRender={40} maxToRenderPerBatch={40} diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 7f5bea5962..603648c74b 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -1,5 +1,4 @@ import React, { - Fragment, type CSSProperties, useCallback, useEffect, @@ -67,6 +66,10 @@ function scrollElementToBottom( }); } +function getStreamItemElementId(itemId: string): string { + return `agent-stream-row-${itemId}`; +} + function syncNearBottom( scrollContainer: HTMLElement | null, onNearBottomChange: (value: boolean) => void, @@ -468,6 +471,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } scheduleStickToBottom(); }, + scrollToStreamItem: (target) => { + setFollowOutput(false); + cancelPendingStickToBottom(); + if (target.source === "historyVirtualized") { + rowVirtualizer.scrollToIndex(target.index, { align: "center" }); + return; + } + const row = document.getElementById(getStreamItemElementId(target.itemId)); + row?.scrollIntoView({ block: "center", behavior: "auto" }); + const scrollContainer = scrollContainerRef.current; + if (scrollContainer) { + lastKnownScrollTopRef.current = scrollContainer.scrollTop; + syncNearBottom(scrollContainer, onNearBottomChange); + } + }, }; viewportRef.current = handle; return () => { @@ -476,7 +494,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } cancelPendingStickToBottom(); }; - }, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]); + }, [ + cancelPendingStickToBottom, + forceStickToBottom, + onNearBottomChange, + rowVirtualizer, + scheduleStickToBottom, + viewportRef, + ]); const contentContainerStyle = useMemo((): CSSProperties => { return { @@ -520,14 +545,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); const mountedHistoryRows = useMemo(() => { return segments.historyMounted.map((item, index) => ( - +
{renderHistoryMountedRow(item, index, segments.historyMounted)} - +
)); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { return segments.liveHead.map((item, index) => ( - {renderLiveHeadRow(item, index, segments.liveHead)} +
+ {renderLiveHeadRow(item, index, segments.liveHead)} +
)); }, [renderLiveHeadRow, segments.liveHead]); const liveAuxiliary = useMemo(() => { @@ -570,6 +597,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index f8c272d28c..d5ca7aa4d9 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -42,6 +42,13 @@ export interface StreamEdgeSlotProps { export interface StreamViewportHandle { scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; + scrollToStreamItem: (target: StreamScrollTarget) => void; +} + +export interface StreamScrollTarget { + source: "historyVirtualized" | "historyMounted" | "liveHead"; + index: number; + itemId: string; } export interface StreamSegmentRenderers { diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 4e77405cc6..9686ba7b15 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -36,6 +36,7 @@ import { CompactionMarker, MessageOuterSpacingProvider, type InlinePathTarget, + type MessageFindHighlight, } from "@/components/message"; import { PlanCard } from "@/components/plan-card"; import type { StreamItem } from "@/types/stream"; @@ -58,6 +59,11 @@ import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer"; import { layoutStream, type StreamLayoutItem } from "./layout"; +import { + buildAgentStreamSearchModel, + findAgentStreamSearchMatches, + type AgentStreamSearchMatch, +} from "./agent-stream-search-model"; import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, @@ -75,6 +81,17 @@ import { import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { + FindBar, + type PaneFindMatchState, + type UsePaneFindResult, + usePaneFind, +} from "@/panels/pane-find"; +import { + getWorkingIndicatorDotStrength, + WORKING_INDICATOR_CYCLE_MS, + WORKING_INDICATOR_OFFSETS, +} from "@/utils/working-indicator"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; @@ -197,6 +214,69 @@ function renderLiveHeadStreamItem(input: { return input.renderStreamItem(layoutItem); } +function createAgentStreamFindMatchState(input: { + query: string; + matches: AgentStreamSearchMatch[]; + currentMatchId: string | null; +}): PaneFindMatchState { + if (input.query.length === 0) { + return { status: "empty" }; + } + if (input.matches.length === 0) { + return { status: "no-match" }; + } + const currentIndex = Math.max( + 0, + input.matches.findIndex((match) => match.id === input.currentMatchId), + ); + return { + status: "matched", + current: currentIndex + 1, + total: input.matches.length, + }; +} + +function createAgentStreamFindHighlightsByItemId(input: { + matches: AgentStreamSearchMatch[]; + currentMatchId: string | null; +}): Map { + const highlightsByItemId = new Map(); + + for (const match of input.matches) { + if (match.segmentKey !== "text") { + continue; + } + + const itemKind = match.entry.item.kind; + if (itemKind !== "user_message" && itemKind !== "assistant_message") { + continue; + } + + const highlights = highlightsByItemId.get(match.entry.item.id) ?? []; + highlights.push({ + id: match.id, + start: match.start, + end: match.end, + isCurrent: match.id === input.currentMatchId, + }); + highlightsByItemId.set(match.entry.item.id, highlights); + } + + return highlightsByItemId; +} + +interface AgentStreamFindState { + query: string; + matches: AgentStreamSearchMatch[]; + currentMatchId: string | null; +} + +const EMPTY_AGENT_STREAM_FIND_STATE: AgentStreamFindState = { + query: "", + matches: [], + currentMatchId: null, +}; + export interface AgentStreamViewHandle { scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; prepareForViewportChange(): void; @@ -373,6 +453,173 @@ const AgentStreamViewComponent = forwardRef + buildAgentStreamSearchModel({ + streamItems, + streamHead: streamHead ?? [], + platform: isWeb ? "web" : "native", + isMobileBreakpoint: isMobile, + cwd: agent.cwd, + }), + [agent.cwd, isMobile, streamHead, streamItems], + ); + const [findState, setFindState] = useState(EMPTY_AGENT_STREAM_FIND_STATE); + const findQuery = findState.query; + const findMatches = findState.matches; + const currentFindMatchId = findState.currentMatchId; + const currentFindMatchIndex = useMemo(() => { + if (findMatches.length === 0) { + return -1; + } + const existingIndex = findMatches.findIndex((match) => match.id === currentFindMatchId); + return existingIndex >= 0 ? existingIndex : 0; + }, [currentFindMatchId, findMatches]); + const findMatchState = useMemo( + () => + createAgentStreamFindMatchState({ + query: findQuery, + matches: findMatches, + currentMatchId: currentFindMatchId, + }), + [currentFindMatchId, findMatches, findQuery], + ); + const findHighlightsByItemId = useMemo( + () => + createAgentStreamFindHighlightsByItemId({ + matches: findMatches, + currentMatchId: currentFindMatchId, + }), + [currentFindMatchId, findMatches], + ); + const scrollFindMatchIntoView = useCallback((match: AgentStreamSearchMatch | undefined) => { + if (!match) { + return; + } + viewportRef.current?.scrollToStreamItem({ + source: match.entry.source, + index: match.entry.index, + itemId: match.entry.item.id, + }); + }, []); + const paneFind = usePaneFind({ + matchState: findMatchState, + onQuery: (query) => { + const nextMatches = findAgentStreamSearchMatches({ + model: searchModel, + query, + }); + const nextMatchId = nextMatches[0]?.id ?? null; + setFindState({ query, matches: nextMatches, currentMatchId: nextMatchId }); + scrollFindMatchIntoView(nextMatches[0]); + return createAgentStreamFindMatchState({ + query, + matches: nextMatches, + currentMatchId: nextMatchId, + }); + }, + onNext: () => { + if (findMatches.length === 0) { + return findMatchState; + } + const nextIndex = (currentFindMatchIndex + 1) % findMatches.length; + const nextMatch = findMatches[nextIndex]; + setFindState((current) => ({ + ...current, + currentMatchId: nextMatch?.id ?? null, + })); + scrollFindMatchIntoView(nextMatch); + return createAgentStreamFindMatchState({ + query: findQuery, + matches: findMatches, + currentMatchId: nextMatch?.id ?? null, + }); + }, + onPrev: () => { + if (findMatches.length === 0) { + return findMatchState; + } + const nextIndex = (currentFindMatchIndex - 1 + findMatches.length) % findMatches.length; + const nextMatch = findMatches[nextIndex]; + setFindState((current) => ({ + ...current, + currentMatchId: nextMatch?.id ?? null, + })); + scrollFindMatchIntoView(nextMatch); + return createAgentStreamFindMatchState({ + query: findQuery, + matches: findMatches, + currentMatchId: nextMatch?.id ?? null, + }); + }, + onClose: () => { + setFindState(EMPTY_AGENT_STREAM_FIND_STATE); + }, + }); + useEffect(() => { + setFindState((current) => { + if (current.query.length === 0) { + return current.currentMatchId === null && current.matches.length === 0 + ? current + : EMPTY_AGENT_STREAM_FIND_STATE; + } + + const nextMatches = findAgentStreamSearchMatches({ + model: searchModel, + query: current.query, + }); + let keepsCurrentMatch = false; + if (current.currentMatchId) { + for (const match of nextMatches) { + if (match.id === current.currentMatchId) { + keepsCurrentMatch = true; + break; + } + } + } + const nextMatchId = keepsCurrentMatch + ? current.currentMatchId + : (nextMatches[0]?.id ?? null); + + let matchesUnchanged = current.matches.length === nextMatches.length; + if (matchesUnchanged) { + for (let index = 0; index < current.matches.length; index += 1) { + if (current.matches[index]?.id !== nextMatches[index]?.id) { + matchesUnchanged = false; + break; + } + } + } + if (matchesUnchanged && current.currentMatchId === nextMatchId) { + return current; + } + + return { + query: current.query, + matches: nextMatches, + currentMatchId: nextMatchId, + }; + }); + }, [searchModel]); + const inlineWorkingIndicatorItemId = useMemo(() => { + if (agent.status !== "running") { + return null; + } + const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => { + if (item.kind !== "assistant_message") { + return false; + } + return ( + getStreamNeighborItem({ + strategy: streamRenderStrategy, + items, + index, + relation: "below", + }) === undefined + ); + }); + return footerItem?.id ?? null; + }, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]); useImperativeHandle( ref, () => ({ @@ -423,10 +670,11 @@ const AgentStreamViewComponent = forwardRef ); }, - [agent.capabilities, agentId, client, resolvedServerId], + [agent.capabilities, agentId, client, findHighlightsByItemId, resolvedServerId], ); const renderAssistantMessageItem = useCallback( @@ -446,11 +694,12 @@ const AgentStreamViewComponent = forwardRef ); }, - [client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], + [client, findHighlightsByItemId, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], ); const renderThoughtItem = useCallback( @@ -700,6 +949,7 @@ const AgentStreamViewComponent = forwardRef + {streamRenderStrategy.render({ agentId, @@ -748,6 +998,110 @@ const AgentStreamViewComponent = forwardRef { + progress.value = 0; + progress.value = withRepeat( + withTiming(1, { + duration: WORKING_INDICATOR_CYCLE_MS, + easing: Easing.linear, + }), + -1, + false, + ); + + return () => { + cancelAnimation(progress); + progress.value = 0; + }; + }, [progress]); + + const translateDistance = -2; + const dotOneStyle = useAnimatedStyle(() => { + const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]); + return { + opacity: 0.3 + strength * 0.7, + transform: [{ translateY: strength * translateDistance }], + }; + }); + + const dotTwoStyle = useAnimatedStyle(() => { + const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]); + return { + opacity: 0.3 + strength * 0.7, + transform: [{ translateY: strength * translateDistance }], + }; + }); + + const dotThreeStyle = useAnimatedStyle(() => { + const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]); + return { + opacity: 0.3 + strength * 0.7, + transform: [{ translateY: strength * translateDistance }], + }; + }); + + const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]); + const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]); + const dotThreeCombinedStyle = useMemo( + () => [stylesheet.workingDot, dotThreeStyle], + [dotThreeStyle], + ); + + const containerStyle = + variant === "inline" + ? stylesheet.inlineWorkingIndicatorFrame + : stylesheet.workingIndicatorBubble; + + return ( + + + + + + + + ); +} + +function InlineWorkingIndicatorSlot() { + return ( + + + + ); +} + +function AgentStreamFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) { + return paneFind.isOpen ? : null; +} + +// Permission Request Card Component +type TurnContentStrategy = Parameters< + typeof collectAssistantTurnContentForStreamRenderStrategy +>[0]["strategy"]; + +interface TurnCopyButtonSlotProps { + strategy: TurnContentStrategy; + items: StreamItem[]; + startIndex: number; +} + +function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotProps) { + const getContent = useCallback( + () => + collectAssistantTurnContentForStreamRenderStrategy({ + strategy, + items, + startIndex, + }), + [strategy, items, startIndex], + ); + return ; +} + interface ToolCallSlotProps extends Omit< ComponentProps, "onInlineDetailsExpandedChange" diff --git a/packages/app/src/components/agent-stream-search-model.test.ts b/packages/app/src/components/agent-stream-search-model.test.ts new file mode 100644 index 0000000000..3722c88fac --- /dev/null +++ b/packages/app/src/components/agent-stream-search-model.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import type { StreamItem } from "@/types/stream"; +import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; +import { + buildAgentStreamSearchModel, + findAgentStreamSearchMatches, + getAgentStreamItemSearchableText, +} from "./agent-stream-search-model"; + +function timestamp(seed: number): Date { + return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`); +} + +function userMessage(id: string, text: string, seed = 1): StreamItem { + return { + kind: "user_message", + id, + text, + timestamp: timestamp(seed), + }; +} + +function assistantMessage(id: string, text: string, seed = 1): StreamItem { + return { + kind: "assistant_message", + id, + text, + timestamp: timestamp(seed), + }; +} + +function thought(id: string, text: string, seed = 1): StreamItem { + return { + kind: "thought", + id, + text, + status: "ready", + timestamp: timestamp(seed), + }; +} + +function activityLog(id: string, message: string, seed = 1): StreamItem { + return { + kind: "activity_log", + id, + activityType: "info", + message, + metadata: { hidden: "metadata is not searched" }, + timestamp: timestamp(seed), + }; +} + +function todoList(id: string, seed = 1): StreamItem { + return { + kind: "todo_list", + id, + provider: "codex", + items: [ + { text: "Write the red test", completed: true }, + { text: "Make search green", completed: false }, + ], + timestamp: timestamp(seed), + }; +} + +function agentToolCall(id: string, detail: ToolCallDetail, seed = 1): StreamItem { + return { + kind: "tool_call", + id, + timestamp: timestamp(seed), + payload: { + source: "agent", + data: { + provider: "codex", + callId: `call-${id}`, + name: "exec_command", + status: "completed", + error: null, + detail, + }, + }, + }; +} + +describe("getAgentStreamItemSearchableText", () => { + it("extracts user, assistant, thought, activity, and todo text", () => { + expect(getAgentStreamItemSearchableText(userMessage("u1", "user text"))).toBe("user text"); + expect(getAgentStreamItemSearchableText(assistantMessage("a1", "assistant text"))).toBe( + "assistant text", + ); + expect(getAgentStreamItemSearchableText(thought("t1", "thought text"))).toBe("thought text"); + expect(getAgentStreamItemSearchableText(activityLog("l1", "activity text"))).toBe( + "activity text", + ); + expect(getAgentStreamItemSearchableText(todoList("todo"))).toBe( + "Write the red test\nMake search green", + ); + }); + + it("searches minimal visible tool-call text and skips raw hidden payloads", () => { + const shell = agentToolCall("shell", { + type: "shell", + command: "npm run typecheck", + output: "internal output should stay out", + }); + + expect(getAgentStreamItemSearchableText(shell)).toBe("Shell\nnpm run typecheck"); + }); + + it("includes special tool-call content branches that render as messages or cards", () => { + const speak: StreamItem = { + kind: "tool_call", + id: "speak", + timestamp: timestamp(1), + payload: { + source: "agent", + data: { + provider: "codex", + callId: "call-speak", + name: "speak", + status: "completed", + error: null, + detail: { + type: "unknown", + input: "spoken message", + output: null, + }, + }, + }, + }; + const plan = agentToolCall("plan", { + type: "plan", + text: "phase checklist", + }); + + expect(getAgentStreamItemSearchableText(speak)).toBe("spoken message"); + expect(getAgentStreamItemSearchableText(plan)).toBe("Plan\nphase checklist"); + }); +}); + +describe("buildAgentStreamSearchModel", () => { + it("orders virtualized history, mounted history, live head, and optimistic items deterministically", () => { + const committed: StreamItem[] = []; + for (let index = 0; index < 64; index += 1) { + committed.push(userMessage(`u${index}`, `history ${index}`, index)); + } + const optimistic = userMessage("optimistic", "draft message", 65); + const liveHead = [assistantMessage("live", "live answer", 66)]; + + const model = buildAgentStreamSearchModel({ + streamItems: committed, + optimisticItems: [optimistic], + streamHead: liveHead, + platform: "web", + isMobileBreakpoint: false, + }); + + expect(model.entries.at(0)?.item.id).toBe("optimistic"); + expect(model.entries.map((entry) => entry.source)).toEqual([ + ...Array.from( + { length: model.segments.historyVirtualized.length }, + () => "historyVirtualized", + ), + ...Array.from({ length: model.segments.historyMounted.length }, () => "historyMounted"), + "liveHead", + ]); + expect(model.entries.at(-1)?.item.id).toBe("live"); + }); + + it("does not duplicate an optimistic item once committed history has the same id", () => { + const committed = [userMessage("u1", "committed draft")]; + const model = buildAgentStreamSearchModel({ + streamItems: committed, + optimisticItems: [userMessage("u1", "optimistic draft")], + streamHead: [], + platform: "web", + isMobileBreakpoint: true, + }); + + expect(model.entries.map((entry) => entry.text)).toEqual(["committed draft"]); + }); +}); + +describe("findAgentStreamSearchMatches", () => { + it("returns stable match ids from item identity and local occurrence data", () => { + const model = buildAgentStreamSearchModel({ + streamItems: [assistantMessage("a1", "Alpha alpha beta")], + streamHead: [thought("h1", "alpha live")], + platform: "web", + isMobileBreakpoint: true, + }); + + const matches = findAgentStreamSearchMatches({ + model, + query: "alpha", + }); + + expect(matches.map((match) => match.id)).toEqual([ + "a1:text:0:0:5", + "a1:text:1:6:11", + "h1:text:0:0:5", + ]); + expect(matches.map((match) => match.entry.item.id)).toEqual(["a1", "a1", "h1"]); + }); +}); diff --git a/packages/app/src/components/agent-stream-search-model.ts b/packages/app/src/components/agent-stream-search-model.ts new file mode 100644 index 0000000000..12987b5dcd --- /dev/null +++ b/packages/app/src/components/agent-stream-search-model.ts @@ -0,0 +1,294 @@ +import type { StreamItem, ToolCallItem } from "@/types/stream"; +import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; +import { + findMountedWindowStart, + getWebMountedRecentStreamItems, + getWebPartialVirtualizationThreshold, +} from "./agent-stream-web-virtualization"; + +export type AgentStreamSearchSource = "historyVirtualized" | "historyMounted" | "liveHead"; + +export interface AgentStreamSearchTextSegment { + key: string; + text: string; +} + +export interface AgentStreamSearchEntry { + item: StreamItem; + source: AgentStreamSearchSource; + index: number; + text: string; + segments: AgentStreamSearchTextSegment[]; +} + +export interface AgentStreamSearchMatch { + id: string; + entry: AgentStreamSearchEntry; + segmentKey: string; + occurrenceIndex: number; + start: number; + end: number; +} + +export interface AgentStreamSearchModel { + entries: AgentStreamSearchEntry[]; + segments: { + historyVirtualized: AgentStreamSearchEntry[]; + historyMounted: AgentStreamSearchEntry[]; + liveHead: AgentStreamSearchEntry[]; + }; +} + +export interface BuildAgentStreamSearchModelInput { + platform: "web" | "native"; + isMobileBreakpoint: boolean; + streamItems: StreamItem[]; + streamHead: StreamItem[]; + optimisticItems?: StreamItem[]; + cwd?: string; +} + +export interface FindAgentStreamSearchMatchesInput { + model: AgentStreamSearchModel; + query: string; +} + +function compactText(parts: Array): string { + return parts + .map((part) => part?.trim()) + .filter((part): part is string => Boolean(part)) + .join("\n"); +} + +function getToolCallSearchableSegments( + item: ToolCallItem, + cwd: string | undefined, +): AgentStreamSearchTextSegment[] { + if (item.payload.source === "agent") { + const { data } = item.payload; + if ( + data.name === "speak" && + data.detail.type === "unknown" && + typeof data.detail.input === "string" && + data.detail.input.trim() + ) { + return [{ key: "text", text: data.detail.input }]; + } + + const display = buildToolCallDisplayModel({ + name: data.name, + status: data.status, + error: data.error, + detail: data.detail, + metadata: data.metadata, + cwd, + }); + const visibleText = compactText([ + display.displayName, + display.summary, + data.detail.type === "plan" ? data.detail.text : undefined, + display.errorText, + ]); + return visibleText ? [{ key: "tool", text: visibleText }] : []; + } + + const { data } = item.payload; + const display = buildToolCallDisplayModel({ + name: data.toolName, + status: data.status === "executing" ? "running" : data.status, + error: data.error, + detail: { + type: "unknown", + input: data.arguments, + output: data.result ?? null, + }, + cwd, + }); + const visibleText = compactText([display.displayName, display.summary, display.errorText]); + return visibleText ? [{ key: "tool", text: visibleText }] : []; +} + +export function getAgentStreamItemSearchableSegments( + item: StreamItem, + options: { cwd?: string } = {}, +): AgentStreamSearchTextSegment[] { + switch (item.kind) { + case "user_message": + case "assistant_message": + case "thought": + return item.text ? [{ key: "text", text: item.text }] : []; + case "activity_log": + return item.message ? [{ key: "text", text: item.message }] : []; + case "todo_list": + return item.items.map((todo, index) => ({ + key: `todo:${index}`, + text: todo.text, + })); + case "tool_call": + return getToolCallSearchableSegments(item, options.cwd); + case "compaction": + return []; + } +} + +export function getAgentStreamItemSearchableText( + item: StreamItem, + options: { cwd?: string } = {}, +): string { + return getAgentStreamItemSearchableSegments(item, options) + .map((segment) => segment.text) + .filter((text) => text.length > 0) + .join("\n"); +} + +function mergeOptimisticItems(input: { + streamItems: StreamItem[]; + optimisticItems: StreamItem[] | undefined; +}): StreamItem[] { + if (!input.optimisticItems || input.optimisticItems.length === 0) { + return input.streamItems; + } + const committedIds = new Set(input.streamItems.map((item) => item.id)); + const pendingOptimisticItems = input.optimisticItems.filter((item) => !committedIds.has(item.id)); + if (pendingOptimisticItems.length === 0) { + return input.streamItems; + } + return [...pendingOptimisticItems, ...input.streamItems]; +} + +function buildEntries(input: { + items: StreamItem[]; + source: AgentStreamSearchSource; + startIndex: number; + cwd: string | undefined; +}): AgentStreamSearchEntry[] { + return input.items.map((item, offset) => { + const segments = getAgentStreamItemSearchableSegments(item, { cwd: input.cwd }); + return { + item, + source: input.source, + index: input.startIndex + offset, + text: segments.map((segment) => segment.text).join("\n"), + segments, + }; + }); +} + +function orderStreamItems(input: { + items: StreamItem[]; + platform: "web" | "native"; +}): StreamItem[] { + return input.platform === "native" ? [...input.items].toReversed() : input.items; +} + +function splitOrderedHistory(input: { + orderedTail: StreamItem[]; + platform: "web" | "native"; + isMobileBreakpoint: boolean; +}): { + historyVirtualizedItems: StreamItem[]; + historyMountedItems: StreamItem[]; +} { + const shouldSplitHistory = + input.platform === "web" && + !input.isMobileBreakpoint && + input.orderedTail.length > getWebPartialVirtualizationThreshold(); + if (!shouldSplitHistory) { + return { + historyVirtualizedItems: [], + historyMountedItems: input.orderedTail, + }; + } + const mountedWindowStart = findMountedWindowStart({ + items: input.orderedTail, + minMountedCount: getWebMountedRecentStreamItems(), + }); + return { + historyVirtualizedItems: input.orderedTail.slice(0, mountedWindowStart), + historyMountedItems: input.orderedTail.slice(mountedWindowStart), + }; +} + +export function buildAgentStreamSearchModel( + input: BuildAgentStreamSearchModelInput, +): AgentStreamSearchModel { + const tail = mergeOptimisticItems({ + streamItems: input.streamItems, + optimisticItems: input.optimisticItems, + }); + const orderedTail = orderStreamItems({ + items: tail, + platform: input.platform, + }); + const orderedHead = orderStreamItems({ + items: input.streamHead, + platform: input.platform, + }); + const splitHistory = splitOrderedHistory({ + orderedTail, + platform: input.platform, + isMobileBreakpoint: input.isMobileBreakpoint, + }); + const historyVirtualized = buildEntries({ + items: splitHistory.historyVirtualizedItems, + source: "historyVirtualized", + startIndex: 0, + cwd: input.cwd, + }); + const historyMounted = buildEntries({ + items: splitHistory.historyMountedItems, + source: "historyMounted", + startIndex: historyVirtualized.length, + cwd: input.cwd, + }); + const liveHead = buildEntries({ + items: orderedHead, + source: "liveHead", + startIndex: historyVirtualized.length + historyMounted.length, + cwd: input.cwd, + }); + return { + entries: [...historyVirtualized, ...historyMounted, ...liveHead], + segments: { + historyVirtualized, + historyMounted, + liveHead, + }, + }; +} + +export function findAgentStreamSearchMatches( + input: FindAgentStreamSearchMatchesInput, +): AgentStreamSearchMatch[] { + const normalizedQuery = input.query.toLocaleLowerCase(); + if (!normalizedQuery) { + return []; + } + + const matches: AgentStreamSearchMatch[] = []; + for (const entry of input.model.entries) { + for (const segment of entry.segments) { + const normalizedText = segment.text.toLocaleLowerCase(); + let occurrenceIndex = 0; + let fromIndex = 0; + while (fromIndex <= normalizedText.length) { + const start = normalizedText.indexOf(normalizedQuery, fromIndex); + if (start < 0) { + break; + } + const end = start + input.query.length; + matches.push({ + id: `${entry.item.id}:${segment.key}:${occurrenceIndex}:${start}:${end}`, + entry, + segmentKey: segment.key, + occurrenceIndex, + start, + end, + }); + occurrenceIndex += 1; + fromIndex = end; + } + } + } + return matches; +} diff --git a/packages/app/src/components/file-pane-text-render-data.test.ts b/packages/app/src/components/file-pane-text-render-data.test.ts new file mode 100644 index 0000000000..529e7a1ac9 --- /dev/null +++ b/packages/app/src/components/file-pane-text-render-data.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + createFilePaneFindTokenSegments, + createFilePaneLineFindHighlightMap, + createFilePaneTextRenderData, + findFilePaneTextMatches, +} from "@/components/file-pane-text-render-data"; +import type { FilePaneTextLineRenderData } from "@/components/file-pane-text-render-data"; + +function tokenText(line: FilePaneTextLineRenderData): string { + return line.tokens.map(({ text }) => text).join(""); +} + +describe("createFilePaneTextRenderData", () => { + it("keeps code render lines in source order and reconstructs searchable text from tokens", () => { + const renderData = createFilePaneTextRenderData( + "const answer = 42;\nconsole.log(answer);", + "src/answer.ts", + ); + + expect(renderData.lines.map((line) => line.lineNumber)).toEqual([1, 2]); + expect(renderData.lines.map((line) => line.text)).toEqual([ + "const answer = 42;", + "console.log(answer);", + ]); + expect(renderData.searchableText).toBe("const answer = 42;\nconsole.log(answer);"); + const tokenTexts = renderData.lines.map(tokenText); + expect(tokenTexts).toEqual(renderData.lines.map((line) => line.text)); + }); + + it("preserves blank text lines for navigation and scrolling targets", () => { + const renderData = createFilePaneTextRenderData("alpha\n\nbeta", "notes.txt"); + + expect( + renderData.lines.map((line) => ({ lineNumber: line.lineNumber, text: line.text })), + ).toEqual([ + { lineNumber: 1, text: "alpha" }, + { lineNumber: 2, text: "" }, + { lineNumber: 3, text: "beta" }, + ]); + expect(renderData.searchableText).toBe("alpha\n\nbeta"); + }); +}); + +describe("findFilePaneTextMatches", () => { + it("indexes loaded text case-insensitively in source order", () => { + const renderData = createFilePaneTextRenderData( + "Alpha beta\nBETA gamma\nalphabet", + "notes.txt", + ); + + const matches = findFilePaneTextMatches(renderData, "beta"); + + expect( + matches.map((match) => ({ + index: match.index, + lineSpans: match.lineSpans, + })), + ).toEqual([ + { + index: 0, + lineSpans: [{ lineNumber: 1, startColumn: 6, endColumn: 10 }], + }, + { + index: 1, + lineSpans: [{ lineNumber: 2, startColumn: 0, endColumn: 4 }], + }, + ]); + }); + + it("maps pasted multi-line queries back to per-line spans", () => { + const renderData = createFilePaneTextRenderData("alpha\nbeta", "notes.txt"); + + const matches = findFilePaneTextMatches(renderData, "ha\nbe"); + + expect(matches).toHaveLength(1); + expect(matches[0]?.lineSpans).toEqual([ + { lineNumber: 1, startColumn: 3, endColumn: 5 }, + { lineNumber: 2, startColumn: 0, endColumn: 2 }, + ]); + }); +}); + +describe("createFilePaneFindTokenSegments", () => { + it("splits immutable token render data around match spans and marks the current match", () => { + const line: FilePaneTextLineRenderData = { + lineNumber: 1, + text: "const answer", + tokens: [ + { text: "const", style: "keyword" }, + { text: " answer", style: null }, + ], + }; + const highlights = createFilePaneLineFindHighlightMap( + [ + { + index: 0, + startOffset: 2, + endOffset: 8, + lineSpans: [{ lineNumber: 1, startColumn: 2, endColumn: 8 }], + }, + ], + 0, + ); + + const segments = createFilePaneFindTokenSegments(line, highlights.get(1) ?? []); + + expect(segments).toEqual([ + { text: "co", style: "keyword", isFindMatch: false, isCurrentFindMatch: false }, + { text: "nst", style: "keyword", isFindMatch: true, isCurrentFindMatch: true }, + { text: " an", style: null, isFindMatch: true, isCurrentFindMatch: true }, + { text: "swer", style: null, isFindMatch: false, isCurrentFindMatch: false }, + ]); + expect(line.tokens).toEqual([ + { text: "const", style: "keyword" }, + { text: " answer", style: null }, + ]); + }); +}); diff --git a/packages/app/src/components/file-pane-text-render-data.ts b/packages/app/src/components/file-pane-text-render-data.ts new file mode 100644 index 0000000000..2472f11d95 --- /dev/null +++ b/packages/app/src/components/file-pane-text-render-data.ts @@ -0,0 +1,198 @@ +import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; + +export interface FilePaneTextLineRenderData { + lineNumber: number; + text: string; + tokens: HighlightToken[]; +} + +export interface FilePaneTextRenderData { + lines: FilePaneTextLineRenderData[]; + searchableText: string; +} + +export interface FilePaneFindLineSpan { + lineNumber: number; + startColumn: number; + endColumn: number; +} + +export interface FilePaneFindMatch { + index: number; + startOffset: number; + endOffset: number; + lineSpans: FilePaneFindLineSpan[]; +} + +export interface FilePaneFindLineHighlight extends FilePaneFindLineSpan { + matchIndex: number; + isCurrent: boolean; +} + +export interface FilePaneFindTokenSegment { + text: string; + style: HighlightToken["style"]; + isFindMatch: boolean; + isCurrentFindMatch: boolean; +} + +export function createFilePaneTextRenderData( + content: string, + filePath: string, +): FilePaneTextRenderData { + const lines = highlightCode(content, filePath).map((tokens, index) => ({ + lineNumber: index + 1, + text: tokens.map((token) => token.text).join(""), + tokens, + })); + + return { + lines, + searchableText: lines.map((line) => line.text).join("\n"), + }; +} + +export function findFilePaneTextMatches( + renderData: FilePaneTextRenderData, + query: string, +): FilePaneFindMatch[] { + if (query.length === 0) { + return []; + } + + const searchableText = renderData.searchableText.toLocaleLowerCase(); + const normalizedQuery = query.toLocaleLowerCase(); + const matches: FilePaneFindMatch[] = []; + let nextOffset = searchableText.indexOf(normalizedQuery); + + while (nextOffset >= 0) { + const endOffset = nextOffset + normalizedQuery.length; + matches.push({ + index: matches.length, + startOffset: nextOffset, + endOffset, + lineSpans: mapFilePaneTextRangeToLineSpans(renderData, nextOffset, endOffset), + }); + nextOffset = searchableText.indexOf(normalizedQuery, endOffset); + } + + return matches; +} + +export function createFilePaneLineFindHighlightMap( + matches: FilePaneFindMatch[], + currentMatchIndex: number, +): Map { + const highlightsByLine = new Map(); + + for (const match of matches) { + for (const span of match.lineSpans) { + const highlights = highlightsByLine.get(span.lineNumber) ?? []; + highlights.push({ + ...span, + matchIndex: match.index, + isCurrent: match.index === currentMatchIndex, + }); + highlightsByLine.set(span.lineNumber, highlights); + } + } + + for (const highlights of highlightsByLine.values()) { + highlights.sort((left, right) => left.startColumn - right.startColumn); + } + + return highlightsByLine; +} + +export function createFilePaneFindTokenSegments( + line: FilePaneTextLineRenderData, + highlights: FilePaneFindLineHighlight[], +): FilePaneFindTokenSegment[] { + if (highlights.length === 0) { + return line.tokens.map((token) => ({ + ...token, + isFindMatch: false, + isCurrentFindMatch: false, + })); + } + + const segments: FilePaneFindTokenSegment[] = []; + let tokenStartColumn = 0; + + for (const token of line.tokens) { + let cursor = tokenStartColumn; + const tokenEndColumn = tokenStartColumn + token.text.length; + const tokenHighlights = highlights.filter( + (highlight) => + highlight.startColumn < tokenEndColumn && highlight.endColumn > tokenStartColumn, + ); + + for (const highlight of tokenHighlights) { + const highlightStart = Math.max(highlight.startColumn, tokenStartColumn); + const highlightEnd = Math.min(highlight.endColumn, tokenEndColumn); + + if (cursor < highlightStart) { + segments.push({ + text: token.text.slice(cursor - tokenStartColumn, highlightStart - tokenStartColumn), + style: token.style, + isFindMatch: false, + isCurrentFindMatch: false, + }); + } + + if (highlightStart < highlightEnd) { + segments.push({ + text: token.text.slice( + highlightStart - tokenStartColumn, + highlightEnd - tokenStartColumn, + ), + style: token.style, + isFindMatch: true, + isCurrentFindMatch: highlight.isCurrent, + }); + } + + cursor = highlightEnd; + } + + if (cursor < tokenEndColumn) { + segments.push({ + text: token.text.slice(cursor - tokenStartColumn), + style: token.style, + isFindMatch: false, + isCurrentFindMatch: false, + }); + } + + tokenStartColumn = tokenEndColumn; + } + + return segments; +} + +function mapFilePaneTextRangeToLineSpans( + renderData: FilePaneTextRenderData, + startOffset: number, + endOffset: number, +): FilePaneFindLineSpan[] { + const spans: FilePaneFindLineSpan[] = []; + let lineStartOffset = 0; + + for (const line of renderData.lines) { + const lineEndOffset = lineStartOffset + line.text.length; + const spanStartOffset = Math.max(startOffset, lineStartOffset); + const spanEndOffset = Math.min(endOffset, lineEndOffset); + + if (spanStartOffset < spanEndOffset) { + spans.push({ + lineNumber: line.lineNumber, + startColumn: spanStartOffset - lineStartOffset, + endColumn: spanEndOffset - lineStartOffset, + }); + } + + lineStartOffset = lineEndOffset + 1; + } + + return spans; +} diff --git a/packages/app/src/components/file-pane.test.tsx b/packages/app/src/components/file-pane.test.tsx new file mode 100644 index 0000000000..7160225046 --- /dev/null +++ b/packages/app/src/components/file-pane.test.tsx @@ -0,0 +1,426 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ExplorerFile } from "@/stores/session-store"; +import { FilePane } from "@/components/file-pane"; +import { + PaneFocusProvider, + PaneProvider, + createPaneFocusContextValue, + type PaneContextValue, +} from "@/panels/pane-context"; +import { + clearActivePaneFindPaneId, + handlePaneFindKeyboardAction, + setActivePaneFindPaneId, +} from "@/panels/pane-find-registry"; + +const { queryState, theme } = vi.hoisted(() => ({ + queryState: { + current: { + data: null as null | { + error: string | null; + file: ExplorerFile | null; + imageAttachment?: unknown; + }, + isFetching: false, + }, + }, + theme: { + spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, + fontSize: { sm: 13 }, + colors: { + destructive: "#f43f5e", + foreground: "#fff", + foregroundMuted: "#aaa", + primary: "#0a84ff", + surface0: "#111", + surface1: "#222", + surface2: "#333", + }, + colorScheme: "dark", + }, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => queryState.current, +})); + +vi.mock("react-native", () => { + const MockView = React.forwardRef< + HTMLDivElement, + { children?: React.ReactNode; testID?: string } + >(function View({ children, testID }, ref) { + return React.createElement("div", { "data-testid": testID, ref }, children); + }); + const MockScrollView = React.forwardRef< + HTMLDivElement, + { children?: React.ReactNode; horizontal?: boolean } + >(function ScrollView({ children, horizontal }, ref) { + return React.createElement( + "div", + { "data-horizontal": horizontal ? "true" : undefined, ref }, + children, + ); + }); + const MockText = ({ + children, + style, + }: { + children?: React.ReactNode; + style?: React.CSSProperties; + }) => React.createElement("span", { style }, children); + const MockPressable = ({ + children, + disabled, + onPress, + testID, + }: { + children?: React.ReactNode; + disabled?: boolean; + onPress?: () => void; + testID?: string; + }) => + React.createElement( + "button", + { + "data-testid": testID, + disabled, + onClick: () => { + if (!disabled) { + onPress?.(); + } + }, + type: "button", + }, + children, + ); + const MockTextInput = React.forwardRef< + HTMLInputElement, + { + value?: string; + onChangeText?: (text: string) => void; + onKeyPress?: (event: { + nativeEvent: { key: string; shiftKey?: boolean }; + preventDefault: () => void; + }) => void; + testID?: string; + placeholder?: string; + } + >(function TextInput({ value, onChangeText, onKeyPress, testID, placeholder }, ref) { + return React.createElement("input", { + "data-testid": testID, + onChange: (event: React.ChangeEvent) => + onChangeText?.(event.currentTarget.value), + onKeyDown: (event: React.KeyboardEvent) => + onKeyPress?.({ + nativeEvent: { key: event.key, shiftKey: event.shiftKey }, + preventDefault: () => event.preventDefault(), + }), + placeholder, + ref, + value: value ?? "", + }); + }); + const MockImage = ({ source }: { source?: { uri?: string } }) => + React.createElement("img", { alt: "", src: source?.uri ?? "" }); + + return { + ActivityIndicator: () => React.createElement("span", { "data-testid": "activity" }), + Image: MockImage, + Platform: { + OS: "web", + select: (options: Record) => options.web ?? options.default, + }, + Pressable: MockPressable, + ScrollView: MockScrollView, + Text: MockText, + TextInput: MockTextInput, + View: MockView, + }; +}); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => + function Icon() { + return React.createElement("span", { "data-icon": name }); + }; + return { + ChevronDown: createIcon("ChevronDown"), + ChevronUp: createIcon("ChevronUp"), + X: createIcon("X"), + }; +}); + +vi.mock("react-native-markdown-display", () => ({ + default: ({ children }: { children?: React.ReactNode }) => + React.createElement("article", { "data-testid": "markdown-preview" }, children), + MarkdownIt: () => ({}), +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/attachments/use-attachment-preview-url", () => ({ + useAttachmentPreviewUrl: (metadata: unknown) => (metadata ? "blob:preview" : null), +})); + +vi.mock("@/components/use-web-scrollbar", () => ({ + useWebScrollViewScrollbar: () => ({ + onContentSizeChange: vi.fn(), + onLayout: vi.fn(), + onScroll: vi.fn(), + overlay: null, + }), +})); + +vi.mock("@/hooks/use-web-scrollbar-style", () => ({ + useWebScrollbarStyle: () => ({}), +})); + +vi.mock("@/constants/layout", () => ({ + useIsCompactFormFactor: () => false, +})); + +vi.mock("@/constants/platform", () => ({ + isWeb: true, +})); + +vi.mock("@/styles/markdown-styles", () => ({ + createMarkdownStyles: () => ({}), +})); + +vi.mock("@/stores/session-store", () => ({ + useSessionStore: (selector: (state: unknown) => unknown) => selector({ sessions: {} }), +})); + +let root: Root | null = null; +let container: HTMLElement | null = null; +const paneInstanceId = "server:workspace:file"; +const paneContext: PaneContextValue = { + serverId: "server", + workspaceId: "workspace", + paneInstanceId, + tabId: "file", + target: { kind: "file", path: "src/example.ts" }, + openTab: () => {}, + closeCurrentTab: () => {}, + retargetCurrentTab: () => {}, + openFileInWorkspace: () => {}, +}; +const paneFocus = createPaneFocusContextValue({ + isPaneFocused: true, + isWorkspaceFocused: true, +}); + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + HTMLElement.prototype.scrollIntoView = vi.fn(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + clearActivePaneFindPaneId(paneInstanceId); + root = null; + container?.remove(); + container = null; + queryState.current = { data: null, isFetching: false }; + vi.unstubAllGlobals(); +}); + +function renderFilePane(filePath = "src/example.ts") { + act(() => { + root?.render( + + + + + , + ); + }); + setActivePaneFindPaneId(paneInstanceId); +} + +function inputElement(): HTMLInputElement { + const input = container?.querySelector('[data-testid="pane-find-input"]'); + expect(input).toBeInstanceOf(HTMLInputElement); + return input as HTMLInputElement; +} + +function changeInput(value: string): void { + const input = inputElement(); + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function click(testId: string): void { + const element = container?.querySelector(`[data-testid="${testId}"]`); + expect(element).toBeInstanceOf(HTMLElement); + act(() => { + element?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +function makeFile(file: Partial): ExplorerFile { + return { + content: "", + encoding: "utf-8", + kind: "text", + modifiedAt: "2026-05-02T00:00:00.000Z", + path: file.path ?? "src/example.ts", + size: file.size ?? 0, + ...file, + }; +} + +describe("FilePane preview rendering", () => { + it("renders code/text previews with line numbers and highlighted token text", () => { + queryState.current = { + data: { + error: null, + file: makeFile({ content: "const answer = 42;\nconsole.log(answer);" }), + }, + isFetching: false, + }; + + renderFilePane(); + + const text = container?.textContent ?? ""; + expect(text).toContain("1"); + expect(text).toContain("2"); + expect(text).toContain("const answer = 42;"); + expect(text).toContain("console.log(answer);"); + }); + + it("keeps markdown files on the markdown preview path", () => { + queryState.current = { + data: { + error: null, + file: makeFile({ content: "# Guide", path: "README.md" }), + }, + isFetching: false, + }; + + renderFilePane("README.md"); + + expect(container?.querySelector('[data-testid="markdown-preview"]')?.textContent).toBe( + "# Guide", + ); + }); + + it("renders image previews from the attachment preview URL", () => { + queryState.current = { + data: { + error: null, + file: makeFile({ content: undefined, encoding: "none", kind: "image", path: "logo.png" }), + imageAttachment: { id: "preview" }, + }, + isFetching: false, + }; + + renderFilePane("logo.png"); + + expect(container?.querySelector("img")?.getAttribute("src")).toBe("blob:preview"); + }); + + it("keeps binary previews on the unavailable state with file size", () => { + queryState.current = { + data: { + error: null, + file: makeFile({ + content: undefined, + encoding: "none", + kind: "binary", + path: "tool", + size: 2048, + }), + }, + isFetching: false, + }; + + renderFilePane("tool"); + + expect(container?.textContent).toContain("Binary preview unavailable"); + expect(container?.textContent).toContain("2.0 KB"); + }); + + it("searches code/text previews case-insensitively and navigates matches", () => { + queryState.current = { + data: { + error: null, + file: makeFile({ content: "const answer = 42;\nconsole.log(ANSWER);" }), + }, + isFetching: false, + }; + renderFilePane(); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + changeInput("answer"); + + expect(container?.textContent).toContain("1 / 2"); + expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(1); + expect(container?.querySelectorAll('span[style*="background-color"]').length).toBeGreaterThan( + 0, + ); + + click("pane-find-next"); + expect(container?.textContent).toContain("2 / 2"); + expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(2); + + click("pane-find-prev"); + expect(container?.textContent).toContain("1 / 2"); + expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(3); + }); + + it("clears file highlights on empty query and close", () => { + queryState.current = { + data: { + error: null, + file: makeFile({ content: "needle\nneedle" }), + }, + isFetching: false, + }; + renderFilePane(); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + changeInput("needle"); + expect(container?.textContent).toContain("1 / 2"); + + changeInput(""); + expect(container?.textContent).toContain("0 / 0"); + expect(container?.querySelectorAll('span[style*="background-color"]').length).toBe(0); + + changeInput("needle"); + click("pane-find-close"); + expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull(); + expect(container?.querySelectorAll('span[style*="background-color"]').length).toBe(0); + }); +}); diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 3f6e5e1044..10a5a8f3c6 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; import Markdown, { MarkdownIt } from "react-native-markdown-display"; @@ -15,7 +15,7 @@ import { Fonts } from "@/constants/theme"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; -import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; +import type { HighlightToken } from "@getpaseo/highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { lineNumberGutterWidth } from "@/components/code-insets"; @@ -26,15 +26,33 @@ import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { persistAttachmentFromBytes } from "@/attachments/service"; import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils"; +import { + createFilePaneFindTokenSegments, + createFilePaneLineFindHighlightMap, + createFilePaneTextRenderData, + findFilePaneTextMatches, + type FilePaneFindLineHighlight, + type FilePaneFindMatch, + type FilePaneFindTokenSegment, +} from "@/components/file-pane-text-render-data"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; +import { + FindBar, + type PaneFindMatchState, + type UsePaneFindResult, + usePaneFind, +} from "@/panels/pane-find"; interface CodeLineProps { - tokens: HighlightToken[]; + segments: FilePaneFindTokenSegment[]; lineNumber: number; gutterWidth: number; highlighted: boolean; + matchBackgroundColor: string; + currentMatchBackgroundColor: string; + onLineRef?: (lineNumber: number, node: View | null) => void; } interface FilePreviewBodyProps { @@ -46,6 +64,43 @@ interface FilePreviewBodyProps { imagePreviewUri: string | null; } +interface FilePaneTextScrollRefs { + lineRefs: React.MutableRefObject>; + previewScrollRef: React.RefObject; + registerLineRef: (lineNumber: number, node: View | null) => void; +} + +interface FilePaneCenterStateProps { + children: React.ReactNode; +} + +interface FilePaneTextPreviewProps { + currentMatchBackgroundColor: string; + findHighlightsByLine: Map; + gutterWidth: number; + isMarkdownFile: boolean; + isMobile: boolean; + lineSelection: FileLineSelection | null; + markdownParser: ReturnType; + markdownStyles: ReturnType; + matchBackgroundColor: string; + preview: ExplorerFile; + previewScrollRef: React.RefObject; + scrollbar: ReturnType; + showDesktopWebScrollbar: boolean; + textRenderData: ReturnType | null; + textScrollRefs: FilePaneTextScrollRefs; + webScrollbarStyle: object; +} + +interface FilePaneImagePreviewProps { + imagePreviewUri: string | null; + imageSource: { uri: string } | null; + previewScrollRef: React.RefObject; + scrollbar: ReturnType; + showDesktopWebScrollbar: boolean; +} + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -117,11 +172,20 @@ function clampLineSelection(input: { } const CodeLine = React.memo(function CodeLine({ - tokens, + segments, lineNumber, gutterWidth, highlighted, + matchBackgroundColor, + currentMatchBackgroundColor, + onLineRef, }: CodeLineProps) { + const setLineRef = useCallback( + (node: View | null) => { + onLineRef?.(lineNumber, node); + }, + [lineNumber, onLineRef], + ); const gutterStyle = useMemo( () => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })], [gutterWidth], @@ -131,19 +195,28 @@ const CodeLine = React.memo(function CodeLine({ [highlighted], ); const keyedTokens = useMemo( - () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), - [tokens], + () => segments.map((segment, index) => ({ key: `${index}-${segment.text}`, segment })), + [segments], ); return ( - + {String(lineNumber)} - {keyedTokens.map(({ key, token }) => ( - + {keyedTokens.map(({ key, segment }) => ( + ))} @@ -151,11 +224,234 @@ const CodeLine = React.memo(function CodeLine({ }); interface CodeLineTokenProps { - token: HighlightToken; + backgroundColor?: string; + style: HighlightToken["style"]; + text: string; } -function CodeLineToken({ token }: CodeLineTokenProps) { - return {token.text}; +function CodeLineToken({ backgroundColor, style, text }: CodeLineTokenProps) { + const tokenStyle = useMemo( + () => [style ? syntaxTokenStyleFor(style) : undefined, backgroundColor ? { backgroundColor } : null], + [backgroundColor, style], + ); + return {text}; +} + +function getFindSegmentBackgroundColor(input: { + segment: FilePaneFindTokenSegment; + matchBackgroundColor: string; + currentMatchBackgroundColor: string; +}) { + if (input.segment.isCurrentFindMatch) { + return input.currentMatchBackgroundColor; + } + if (input.segment.isFindMatch) { + return input.matchBackgroundColor; + } + return undefined; +} + +function useFilePaneTextScrollRefs(lineNumbers: number[] | null): FilePaneTextScrollRefs { + const previewScrollRef = useRef(null); + const lineRefs = useRef(new Map()); + + const registerLineRef = useCallback((lineNumber: number, node: View | null) => { + if (node) { + lineRefs.current.set(lineNumber, node); + return; + } + lineRefs.current.delete(lineNumber); + }, []); + + useEffect(() => { + if (!lineNumbers) { + lineRefs.current.clear(); + return; + } + + const visibleLineNumbers = new Set(lineNumbers); + for (const lineNumber of lineRefs.current.keys()) { + if (!visibleLineNumbers.has(lineNumber)) { + lineRefs.current.delete(lineNumber); + } + } + }, [lineNumbers]); + + return { + lineRefs, + previewScrollRef, + registerLineRef, + }; +} + +function createFilePaneMatchState( + query: string, + matches: FilePaneFindMatch[], + currentMatchIndex: number, +): PaneFindMatchState { + if (query.length === 0) { + return { status: "empty" }; + } + if (matches.length === 0) { + return { status: "no-match" }; + } + return { + status: "matched", + current: Math.max(0, currentMatchIndex) + 1, + total: matches.length, + }; +} + +function scrollFilePaneLineIntoView(input: { + lineRefs: React.MutableRefObject>; + previewScrollRef: React.RefObject; + lineNumber: number; +}) { + const lineNode = input.lineRefs.current.get(input.lineNumber); + const scrollNode = input.previewScrollRef.current; + if (!lineNode || !scrollNode) { + return; + } + + if (isWeb && "scrollIntoView" in lineNode) { + ( + lineNode as unknown as { scrollIntoView(options?: ScrollIntoViewOptions): void } + ).scrollIntoView({ + block: "center", + inline: "nearest", + }); + return; + } + + const measurableLineNode = lineNode as View & { + measureLayout?: ( + relativeToNativeNode: unknown, + onSuccess: (x: number, y: number) => void, + onFail?: () => void, + ) => void; + }; + measurableLineNode.measureLayout?.(scrollNode, (_x, y) => { + scrollNode.scrollTo({ y: Math.max(0, y - 48), animated: true }); + }); +} + +function scrollFilePaneLineIntoViewSoon(input: { + lineRefs: React.MutableRefObject>; + previewScrollRef: React.RefObject; + lineNumber: number; +}) { + const schedule = + globalThis.requestAnimationFrame ?? + ((callback: FrameRequestCallback) => { + setTimeout(() => callback(Date.now()), 0); + return 0; + }); + schedule(() => { + scrollFilePaneLineIntoView(input); + }); +} + +interface FilePaneFindState { + query: string; + matches: FilePaneFindMatch[]; + currentMatchIndex: number; +} + +const EMPTY_FILE_PANE_FIND_STATE: FilePaneFindState = { + query: "", + matches: [], + currentMatchIndex: 0, +}; + +function useFilePaneFindAdapter(input: { + textRenderData: ReturnType | null; + textScrollRefs: FilePaneTextScrollRefs; +}) { + const [findState, setFindState] = useState(EMPTY_FILE_PANE_FIND_STATE); + const findQuery = findState.query; + const findMatches = findState.matches; + const currentMatchIndex = findState.currentMatchIndex; + const findHighlightsByLine = useMemo( + () => createFilePaneLineFindHighlightMap(findMatches, currentMatchIndex), + [currentMatchIndex, findMatches], + ); + const findMatchState = useMemo( + () => createFilePaneMatchState(findQuery, findMatches, currentMatchIndex), + [currentMatchIndex, findMatches, findQuery], + ); + const scrollMatchIntoView = useCallback( + (matches: FilePaneFindMatch[], matchIndex: number) => { + const lineNumber = matches[matchIndex]?.lineSpans[0]?.lineNumber; + if (!lineNumber) { + return; + } + scrollFilePaneLineIntoViewSoon({ + lineRefs: input.textScrollRefs.lineRefs, + lineNumber, + previewScrollRef: input.textScrollRefs.previewScrollRef, + }); + }, + [input.textScrollRefs.lineRefs, input.textScrollRefs.previewScrollRef], + ); + const paneFind = usePaneFind({ + matchState: findMatchState, + onQuery: (query) => { + const nextMatches = input.textRenderData + ? findFilePaneTextMatches(input.textRenderData, query) + : []; + setFindState({ query, matches: nextMatches, currentMatchIndex: 0 }); + scrollMatchIntoView(nextMatches, 0); + return createFilePaneMatchState(query, nextMatches, 0); + }, + onNext: () => { + if (findMatches.length === 0) { + return createFilePaneMatchState(findQuery, findMatches, currentMatchIndex); + } + const nextIndex = (currentMatchIndex + 1) % findMatches.length; + setFindState((current) => ({ ...current, currentMatchIndex: nextIndex })); + scrollMatchIntoView(findMatches, nextIndex); + return createFilePaneMatchState(findQuery, findMatches, nextIndex); + }, + onPrev: () => { + if (findMatches.length === 0) { + return createFilePaneMatchState(findQuery, findMatches, currentMatchIndex); + } + const nextIndex = (currentMatchIndex - 1 + findMatches.length) % findMatches.length; + setFindState((current) => ({ ...current, currentMatchIndex: nextIndex })); + scrollMatchIntoView(findMatches, nextIndex); + return createFilePaneMatchState(findQuery, findMatches, nextIndex); + }, + onClose: () => { + setFindState(EMPTY_FILE_PANE_FIND_STATE); + }, + }); + + useEffect(() => { + setFindState((current) => { + if (!input.textRenderData || current.query.length === 0) { + return current.query.length === 0 && + current.matches.length === 0 && + current.currentMatchIndex === 0 + ? current + : EMPTY_FILE_PANE_FIND_STATE; + } + + const nextMatches = findFilePaneTextMatches(input.textRenderData, current.query); + const nextMatchIndex = + nextMatches.length === 0 ? 0 : Math.min(current.currentMatchIndex, nextMatches.length - 1); + + return { + query: current.query, + matches: nextMatches, + currentMatchIndex: nextMatchIndex, + }; + }); + }, [input.textRenderData]); + + return { + findHighlightsByLine, + paneFind, + }; } const codeLineStyles = StyleSheet.create((theme) => ({ @@ -186,6 +482,161 @@ const codeLineStyles = StyleSheet.create((theme) => ({ }, })); +function FilePaneFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) { + return paneFind.isOpen ? : null; +} + +function FilePaneCenterState({ children }: FilePaneCenterStateProps) { + return {children}; +} + +function isLineSelected(lineSelection: FileLineSelection | null, lineNumber: number): boolean { + if (!lineSelection) { + return false; + } + return lineNumber >= lineSelection.lineStart && lineNumber <= lineSelection.lineEnd; +} + +function FilePaneTextPreview({ + currentMatchBackgroundColor, + findHighlightsByLine, + gutterWidth, + isMarkdownFile, + isMobile, + lineSelection, + markdownParser, + markdownStyles, + matchBackgroundColor, + preview, + previewScrollRef, + scrollbar, + showDesktopWebScrollbar, + textRenderData, + textScrollRefs, + webScrollbarStyle, +}: FilePaneTextPreviewProps) { + if (isMarkdownFile) { + return ( + <> + + + {preview.content ?? ""} + + + {scrollbar.overlay} + + ); + } + + const lines = textRenderData?.lines ?? [ + { + lineNumber: 1, + text: preview.content ?? "", + tokens: [{ text: preview.content ?? "", style: null }], + }, + ]; + const keyedLines = lines.map((line) => ({ + key: `line-${line.lineNumber}`, + line, + })); + const codeLines = ( + + {keyedLines.map(({ key, line }) => ( + + ))} + + ); + + return ( + <> + + {isMobile ? ( + {codeLines} + ) : ( + + {codeLines} + + )} + + {scrollbar.overlay} + + ); +} + +function FilePaneImagePreview({ + imagePreviewUri, + imageSource, + previewScrollRef, + scrollbar, + showDesktopWebScrollbar, +}: FilePaneImagePreviewProps) { + if (!imagePreviewUri) { + return ( + + + Loading file… + + ); + } + + return ( + <> + + + + {scrollbar.overlay} + + ); +} + function FilePreviewBody({ preview, isLoading, @@ -196,40 +647,55 @@ function FilePreviewBody({ }: FilePreviewBodyProps) { const { theme } = useUnistyles(); const filePath = location.path; + const isDark = theme.colorScheme === "dark"; + const matchBackgroundColor = isDark ? "rgba(250, 204, 21, 0.32)" : "rgba(250, 204, 21, 0.38)"; + const currentMatchBackgroundColor = isDark + ? "rgba(251, 146, 60, 0.58)" + : "rgba(251, 146, 60, 0.48)"; const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart; - const previewScrollRef = useRef(null); + const fallbackScrollRef = useRef(null); const webScrollbarStyle = useWebScrollbarStyle(); - const scrollbar = useWebScrollViewScrollbar(previewScrollRef, { - enabled: showDesktopWebScrollbar, - }); - const highlightedLines = useMemo(() => { + const textRenderData = useMemo(() => { if (!preview || preview.kind !== "text" || isMarkdownFile) { return null; } - return highlightCode(preview.content ?? "", filePath); + return createFilePaneTextRenderData(preview.content ?? "", filePath); }, [isMarkdownFile, preview, filePath]); + const textLineNumbers = useMemo( + () => textRenderData?.lines.map((line) => line.lineNumber) ?? null, + [textRenderData], + ); + const textScrollRefs = useFilePaneTextScrollRefs(textLineNumbers); + const previewScrollRef = textRenderData ? textScrollRefs.previewScrollRef : fallbackScrollRef; + const scrollbar = useWebScrollViewScrollbar(previewScrollRef, { + enabled: showDesktopWebScrollbar, + }); + const { findHighlightsByLine, paneFind } = useFilePaneFindAdapter({ + textRenderData, + textScrollRefs, + }); const gutterWidth = useMemo(() => { - if (!highlightedLines) return 0; - return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code); - }, [highlightedLines, theme.fontSize.code]); + if (!textRenderData) return 0; + return lineNumberGutterWidth(textRenderData.lines.length, theme.fontSize.code); + }, [textRenderData, theme.fontSize.code]); const lineHeight = theme.fontSize.code * 1.45; const lineSelection = useMemo(() => { - if (!highlightedLines) { + if (!textRenderData) { return null; } return clampLineSelection({ lineStart: location.lineStart, lineEnd: location.lineEnd, - lineCount: highlightedLines.length, + lineCount: textRenderData.lines.length, }); - }, [highlightedLines, location.lineEnd, location.lineStart]); + }, [textRenderData, location.lineEnd, location.lineStart]); const imageSource = useMemo( () => (imagePreviewUri ? { uri: imagePreviewUri } : null), @@ -247,139 +713,66 @@ function FilePreviewBody({ }); }, 0); return () => clearTimeout(timeout); - }, [lineHeight, lineSelection]); + }, [lineHeight, lineSelection, previewScrollRef]); + let content: React.ReactNode; if (isLoading && !preview) { - return ( - + content = ( + Loading file… - + ); - } - - if (!preview) { - return ( - + } else if (!preview) { + content = ( + No preview available - + ); - } - - if (preview.kind === "text") { - if (isMarkdownFile) { - return ( - - - - {preview.content ?? ""} - - - {scrollbar.overlay} - - ); - } - - const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]]; - const keyedLines = lines.map((tokens, index) => ({ - key: `line-${index}`, - tokens, - lineNumber: index + 1, - })); - const codeLines = ( - - {keyedLines.map(({ key, tokens, lineNumber }) => ( - = (lineSelection?.lineStart ?? 0) && - lineNumber <= (lineSelection?.lineEnd ?? 0) - } - /> - ))} - + } else if (preview.kind === "text") { + content = ( + ); - - return ( - - - {isMobile ? ( - {codeLines} - ) : ( - - {codeLines} - - )} - - {scrollbar.overlay} - + } else if (preview.kind === "image") { + content = ( + ); - } - - if (preview.kind === "image") { - if (!imagePreviewUri) { - return ( - - - Loading file… - - ); - } - - return ( - - - - - {scrollbar.overlay} - + } else { + content = ( + + Binary preview unavailable + {formatFileSize({ size: preview.size })} + ); } return ( - - Binary preview unavailable - {formatFileSize({ size: preview.size })} + + + {content} ); } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index f4e73aa821..88245ec22e 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -124,6 +124,7 @@ interface UserMessageProps { isFirstInGroup?: boolean; isLastInGroup?: boolean; disableOuterSpacing?: boolean; + findHighlights?: MessageFindHighlight[]; } const MessageOuterSpacingContext = createContext(false); @@ -158,6 +159,19 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [ ] as const; const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = ...; +export interface MessageFindHighlight { + id: string; + start: number; + end: number; + isCurrent: boolean; +} + +interface MessageFindTextSegment { + key: string; + text: string; + highlight?: MessageFindHighlight; +} + interface MarkdownWithStableRendererProps { children: ReactNode; style: ReturnType; @@ -209,6 +223,95 @@ const SCROLL_EDGE_EPSILON = 0.5; export const STREAM_METADATA_FONT_SIZE = 13; type ScrollAxis = "x" | "y"; +function normalizeFindHighlights( + textLength: number, + highlights: MessageFindHighlight[] | undefined, +): MessageFindHighlight[] { + if (!highlights || highlights.length === 0) { + return []; + } + + return highlights + .map((highlight) => ({ + ...highlight, + start: Math.max(0, Math.min(textLength, highlight.start)), + end: Math.max(0, Math.min(textLength, highlight.end)), + })) + .filter((highlight) => highlight.start < highlight.end) + .sort((left, right) => left.start - right.start || left.end - right.end); +} + +export function createMessageFindTextSegments( + text: string, + highlights: MessageFindHighlight[] | undefined, +): MessageFindTextSegment[] { + const normalizedHighlights = normalizeFindHighlights(text.length, highlights); + if (normalizedHighlights.length === 0) { + return [{ key: "text", text }]; + } + + const segments: MessageFindTextSegment[] = []; + let cursor = 0; + for (const highlight of normalizedHighlights) { + if (cursor < highlight.start) { + segments.push({ + key: `plain:${cursor}:${highlight.start}`, + text: text.slice(cursor, highlight.start), + }); + } + segments.push({ + key: highlight.id, + text: text.slice(highlight.start, highlight.end), + highlight, + }); + cursor = highlight.end; + } + + if (cursor < text.length) { + segments.push({ + key: `plain:${cursor}:${text.length}`, + text: text.slice(cursor), + }); + } + + return segments; +} + +interface MarkdownBlockWithOffset { + key: string; + block: string; + startOffset: number; +} + +export function createMarkdownBlocksWithOffsets(message: string): MarkdownBlockWithOffset[] { + let cursor = 0; + return splitMarkdownBlocks(message).map((block, index) => { + const startOffset = Math.max(cursor, message.indexOf(block, cursor)); + cursor = startOffset + block.length; + return { + key: `${index}:${block.slice(0, 32)}`, + block, + startOffset, + }; + }); +} + +function createBlockFindHighlights( + blockStartOffset: number, + blockLength: number, + highlights: MessageFindHighlight[] | undefined, +): MessageFindHighlight[] { + const blockEndOffset = blockStartOffset + blockLength; + return normalizeFindHighlights(blockEndOffset, highlights) + .filter((highlight) => highlight.start < blockEndOffset && highlight.end > blockStartOffset) + .map((highlight) => ({ + id: highlight.id, + start: Math.max(0, highlight.start - blockStartOffset), + end: Math.min(blockLength, highlight.end - blockStartOffset), + isCurrent: highlight.isCurrent, + })); +} + function ensureWebToolCallShimmerKeyframes() { if (isNative) { return; @@ -369,6 +472,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ lineHeight: 22, overflowWrap: "anywhere", }, + findMatchText: { + backgroundColor: + theme.colorScheme === "dark" ? "rgba(250, 204, 21, 0.32)" : "rgba(250, 204, 21, 0.38)", + }, + findCurrentMatchText: { + backgroundColor: + theme.colorScheme === "dark" ? "rgba(251, 146, 60, 0.58)" : "rgba(251, 146, 60, 0.48)", + }, imagePreviewContainer: { flexDirection: "row", gap: theme.spacing[2], @@ -436,6 +547,28 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ }, })); +function MessageFindHighlightedText({ segments }: { segments: MessageFindTextSegment[] }) { + return ( + <> + {segments.map((segment) => ( + + {segment.text} + + ))} + + ); +} + +function getMessageFindHighlightStyle(highlight: MessageFindHighlight | undefined) { + if (highlight?.isCurrent) { + return userMessageStylesheet.findCurrentMatchText; + } + if (highlight) { + return userMessageStylesheet.findMatchText; + } + return undefined; +} + function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) { const uri = useAttachmentPreviewUrl(image); const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]); @@ -475,6 +608,7 @@ export const UserMessage = memo(function UserMessage({ isFirstInGroup = true, isLastInGroup = true, disableOuterSpacing, + findHighlights, }: UserMessageProps) { const isCompact = useIsCompactFormFactor(); const [isHovered, setIsHovered] = useState(false); @@ -498,6 +632,10 @@ export const UserMessage = memo(function UserMessage({ }, [rewindMutation], ); + const highlightedMessageSegments = useMemo( + () => createMessageFindTextSegments(message, findHighlights), + [findHighlights, message], + ); const containerStyle = useMemo( () => [ @@ -567,7 +705,7 @@ export const UserMessage = memo(function UserMessage({ ) : null} {hasText ? ( - {message} + ) : null} @@ -752,6 +890,7 @@ interface AssistantMessageProps { serverId?: string; client?: DaemonClient | null; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; + findHighlights?: MessageFindHighlight[]; } export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ @@ -1487,6 +1626,7 @@ interface MemoizedMarkdownBlockProps { rules: RenderRules; parser: MarkdownIt; onLinkPress: (url: string) => boolean; + findHighlights?: MessageFindHighlight[]; } const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({ @@ -1494,26 +1634,61 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({ rules, parser, onLinkPress, + findHighlights, }: MemoizedMarkdownBlockProps) { + const textCursorRef = useRef(0); + textCursorRef.current = 0; + const renderFindHighlightedText = useCallback( + (content: string) => { + if (!findHighlights || findHighlights.length === 0 || content.length === 0) { + return content; + } + + const start = text.indexOf(content, textCursorRef.current); + if (start < 0) { + return content; + } + + textCursorRef.current = start + content.length; + const end = start + content.length; + const contentHighlights = findHighlights + .filter((highlight) => highlight.start < end && highlight.end > start) + .map((highlight) => ({ + id: highlight.id, + start: Math.max(0, highlight.start - start), + end: Math.min(content.length, highlight.end - start), + isCurrent: highlight.isCurrent, + })); + const segments = createMessageFindTextSegments(content, contentHighlights); + return ; + }, + [findHighlights, text], + ); + return ( - - {text} - + + + {text} + + ); }); +const MarkdownFindHighlightContext = createContext<((content: string) => ReactNode) | null>(null); + interface MarkdownInheritedTextProps { inheritedStyles: TextStyle; textStyle: TextStyle; style?: StyleProp; children: ReactNode; + disableFindHighlight?: boolean; } function MarkdownInheritedText({ @@ -1521,12 +1696,18 @@ function MarkdownInheritedText({ textStyle, style: overrideStyle, children, + disableFindHighlight = false, }: MarkdownInheritedTextProps) { + const renderFindHighlightedText = useContext(MarkdownFindHighlightContext); const style = useMemo( () => [inheritedStyles, textStyle, overrideStyle], [inheritedStyles, textStyle, overrideStyle], ); - return {children}; + const renderedChildren = + !disableFindHighlight && typeof children === "string" && renderFindHighlightedText + ? renderFindHighlightedText(children) + : children; + return {renderedChildren}; } interface MarkdownListItemContentProps { @@ -1559,6 +1740,7 @@ export const AssistantMessage = memo(function AssistantMessage({ serverId, client, spacing = "default", + findHighlights, }: AssistantMessageProps) { const markdownParser = useMemo(() => { const parser = MarkdownIt({ typographer: true, linkify: true }); @@ -1696,6 +1878,7 @@ export const AssistantMessage = memo(function AssistantMessage({ key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.code_inline} + disableFindHighlight > {content} @@ -1803,11 +1986,7 @@ export const AssistantMessage = memo(function AssistantMessage({ }; }, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]); - const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); - const keyedBlocks = useMemo( - () => blocks.map((block, index) => ({ key: `${index}:${block.slice(0, 32)}`, block })), - [blocks], - ); + const keyedBlocks = useMemo(() => createMarkdownBlocksWithOffsets(message), [message]); const assistantContainerStyle = useMemo( () => [ @@ -1822,7 +2001,7 @@ export const AssistantMessage = memo(function AssistantMessage({ return ( - {keyedBlocks.map(({ key, block }, index) => ( + {keyedBlocks.map(({ key, block, startOffset }, index) => ( ))} diff --git a/packages/app/src/components/stream-strategy-native.test.ts b/packages/app/src/components/stream-strategy-native.test.ts new file mode 100644 index 0000000000..b52f5f6add --- /dev/null +++ b/packages/app/src/components/stream-strategy-native.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { getNativeScrollToIndexFallbackOffset } from "./stream-strategy-native"; + +describe("getNativeScrollToIndexFallbackOffset", () => { + it("approximates the offset for an unmeasured native FlatList row", () => { + expect( + getNativeScrollToIndexFallbackOffset({ + index: 25, + highestMeasuredFrameIndex: 4, + averageItemLength: 72, + }), + ).toBe(1800); + }); + + it("falls back to the start when the average item length is not useful", () => { + expect( + getNativeScrollToIndexFallbackOffset({ + index: 25, + highestMeasuredFrameIndex: 4, + averageItemLength: 0, + }), + ).toBe(0); + expect( + getNativeScrollToIndexFallbackOffset({ + index: 25, + highestMeasuredFrameIndex: 4, + averageItemLength: Number.NaN, + }), + ).toBe(0); + }); +}); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 9526199454..acd75f212e 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -22,6 +22,7 @@ import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-m import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { TerminalEmulatorRuntime, + type TerminalFindResultChangeEvent, type TerminalOutputData, } from "../terminal/runtime/terminal-emulator-runtime"; import type { @@ -49,6 +50,10 @@ export interface TerminalEmulatorHandle { renderSnapshot: (state: TerminalState | null) => void; clear: () => void; blur: () => void; + findNext: (input: { query: string }) => boolean; + findPrevious: (input: { query: string }) => boolean; + clearFindDecorations: () => void; + onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => () => void; } const SCROLLBAR_HANDLE_WIDTH_IDLE = 6; @@ -307,6 +312,18 @@ export default function TerminalEmulator({ blur: () => { runtimeRef.current?.blur(); }, + findNext: (input: { query: string }) => { + return runtimeRef.current?.findNext(input) ?? false; + }, + findPrevious: (input: { query: string }) => { + return runtimeRef.current?.findPrevious(input) ?? false; + }, + clearFindDecorations: () => { + runtimeRef.current?.clearFindDecorations(); + }, + onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => { + return runtimeRef.current?.onFindResultsChanged(listener) ?? (() => {}); + }, }), [], ); @@ -328,6 +345,18 @@ export default function TerminalEmulator({ blur: () => { runtimeRef.current?.blur(); }, + findNext: (input: { query: string }) => { + return runtimeRef.current?.findNext(input) ?? false; + }, + findPrevious: (input: { query: string }) => { + return runtimeRef.current?.findPrevious(input) ?? false; + }, + clearFindDecorations: () => { + runtimeRef.current?.clearFindDecorations(); + }, + onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => { + return runtimeRef.current?.onFindResultsChanged(listener) ?? (() => {}); + }, }), [], ); diff --git a/packages/app/src/components/terminal-pane.test.tsx b/packages/app/src/components/terminal-pane.test.tsx new file mode 100644 index 0000000000..1b2845aafc --- /dev/null +++ b/packages/app/src/components/terminal-pane.test.tsx @@ -0,0 +1,433 @@ +/** + * @vitest-environment jsdom + */ +import React, { act, useImperativeHandle } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TerminalPane } from "@/components/terminal-pane"; +import type { TerminalEmulatorHandle } from "@/components/terminal-emulator"; +import { + PaneFocusProvider, + PaneProvider, + createPaneFocusContextValue, + type PaneContextValue, +} from "@/panels/pane-context"; +import { + clearActivePaneFindPaneId, + handlePaneFindKeyboardAction, + setActivePaneFindPaneId, +} from "@/panels/pane-find-registry"; + +interface FindResultChange { + resultIndex: number; + resultCount: number; +} + +const { client, findListeners, terminalProps, theme, terminalHandle, resetTerminalHandle } = + vi.hoisted(() => { + const listeners: Array<(event: FindResultChange) => void> = []; + const handle = { + writeOutput: vi.fn(), + renderSnapshot: vi.fn(), + clear: vi.fn(), + findNext: vi.fn(() => true), + findPrevious: vi.fn(() => true), + clearFindDecorations: vi.fn(), + onFindResultsChanged: vi.fn((listener: (event: FindResultChange) => void) => { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index >= 0) { + listeners.splice(index, 1); + } + }; + }), + }; + return { + client: { + on: vi.fn(() => () => {}), + sendTerminalInput: vi.fn(), + }, + findListeners: listeners, + terminalProps: { + current: null as null | { + onTerminalKey?: (input: { + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + }) => Promise | void; + }, + }, + terminalHandle: handle, + resetTerminalHandle: () => { + handle.writeOutput.mockClear(); + handle.renderSnapshot.mockClear(); + handle.clear.mockClear(); + handle.findNext.mockClear(); + handle.findPrevious.mockClear(); + handle.clearFindDecorations.mockClear(); + handle.onFindResultsChanged.mockClear(); + listeners.splice(0); + }, + theme: { + spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, + fontSize: { xs: 11, sm: 13 }, + fontWeight: { medium: "500" as const }, + borderRadius: { md: 6 }, + colors: { + background: "#000", + border: "#333", + destructive: "#f43f5e", + foreground: "#fff", + foregroundMuted: "#aaa", + primary: "#0a84ff", + surface0: "#111", + surface1: "#222", + surface2: "#333", + terminal: { + background: "#000", + foreground: "#fff", + cursor: "#fff", + cursorAccent: "#000", + selectionBackground: "#444", + selectionForeground: "#fff", + black: "#000", + red: "#f00", + green: "#0f0", + yellow: "#ff0", + blue: "#00f", + magenta: "#f0f", + cyan: "#0ff", + white: "#fff", + brightBlack: "#555", + brightRed: "#f55", + brightGreen: "#5f5", + brightYellow: "#ff5", + brightBlue: "#55f", + brightMagenta: "#f5f", + brightCyan: "#5ff", + brightWhite: "#fff", + }, + }, + }, + }; + }); + +vi.mock("react-native", () => { + const MockView = ({ children, testID }: { children?: React.ReactNode; testID?: string }) => + React.createElement("div", { "data-testid": testID }, children); + const MockText = ({ children }: { children?: React.ReactNode }) => + React.createElement("span", null, children); + const MockPressable = ({ + children, + disabled, + onPress, + testID, + }: { + children?: React.ReactNode; + disabled?: boolean; + onPress?: () => void; + testID?: string; + }) => + React.createElement( + "button", + { + "data-testid": testID, + disabled, + onClick: () => { + if (!disabled) { + onPress?.(); + } + }, + type: "button", + }, + children, + ); + const MockTextInput = React.forwardRef< + HTMLInputElement, + { + value?: string; + onChangeText?: (text: string) => void; + onKeyPress?: (event: { + nativeEvent: { key: string; shiftKey?: boolean }; + preventDefault: () => void; + }) => void; + testID?: string; + placeholder?: string; + } + >(function TextInput({ value, onChangeText, onKeyPress, testID, placeholder }, ref) { + return React.createElement("input", { + "data-testid": testID, + onChange: (event: React.ChangeEvent) => + onChangeText?.(event.currentTarget.value), + onKeyDown: (event: React.KeyboardEvent) => + onKeyPress?.({ + nativeEvent: { key: event.key, shiftKey: event.shiftKey }, + preventDefault: () => event.preventDefault(), + }), + placeholder, + ref, + value: value ?? "", + }); + }); + + return { + ActivityIndicator: () => React.createElement("span", { "data-testid": "activity" }), + Pressable: MockPressable, + ScrollView: MockView, + Text: MockText, + TextInput: MockTextInput, + View: MockView, + }; +}); + +vi.mock("react-native-reanimated", () => ({ + default: { + View: ({ children }: { children?: React.ReactNode }) => + React.createElement("div", null, children), + }, + runOnJS: (fn: () => void) => fn, + useAnimatedReaction: vi.fn(), +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + absoluteFillObject: {}, + hairlineWidth: 1, + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => + function Icon() { + return React.createElement("span", { "data-icon": name }); + }; + return { + ChevronDown: createIcon("ChevronDown"), + ChevronUp: createIcon("ChevronUp"), + X: createIcon("X"), + }; +}); + +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeClient: () => client, + useHostRuntimeIsConnected: () => true, +})); + +vi.mock("@/hooks/use-app-visible", () => ({ + useAppVisible: () => true, +})); + +vi.mock("@/hooks/use-keyboard-shift-style", () => ({ + useKeyboardShiftStyle: () => ({ + shift: { value: 0 }, + style: null, + }), +})); + +vi.mock("@/constants/layout", () => ({ + useIsCompactFormFactor: () => false, +})); + +vi.mock("@/stores/panel-store", () => ({ + usePanelStore: ( + selector: (state: { mobileView: string; showMobileAgentList: () => void }) => unknown, + ) => selector({ mobileView: "agent", showMobileAgentList: vi.fn() }), +})); + +vi.mock("@/terminal/runtime/workspace-terminal-session", () => ({ + getWorkspaceTerminalSession: () => ({ + snapshots: { + clear: vi.fn(), + get: vi.fn(() => null), + set: vi.fn(), + }, + }), +})); + +vi.mock("@/terminal/runtime/terminal-stream-controller", () => ({ + TerminalStreamController: class { + dispose() {} + setTerminal() {} + }, +})); + +vi.mock("@/components/terminal-emulator", () => ({ + default: React.forwardRef( + function TerminalEmulator(props, ref) { + terminalProps.current = props as typeof terminalProps.current; + useImperativeHandle(ref, () => terminalHandle); + return React.createElement("div", { "data-testid": props.testId ?? "terminal-surface" }); + }, + ), +})); + +const paneInstanceId = "server-a:workspace-a:left"; +const paneContext: PaneContextValue = { + serverId: "server-a", + workspaceId: "workspace-a", + paneInstanceId, + tabId: "terminal", + target: { kind: "terminal", terminalId: "terminal-a" }, + openTab: () => {}, + closeCurrentTab: () => {}, + retargetCurrentTab: () => {}, + openFileInWorkspace: () => {}, +}; +const paneFocus = createPaneFocusContextValue({ + isPaneFocused: true, + isWorkspaceFocused: true, +}); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + resetTerminalHandle(); + client.sendTerminalInput.mockClear(); + client.on.mockClear(); + terminalProps.current = null; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + clearActivePaneFindPaneId(paneInstanceId); + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); +}); + +function renderTerminalPane(): void { + act(() => { + root?.render( + + + + + , + ); + }); + setActivePaneFindPaneId(paneInstanceId); +} + +function inputElement(): HTMLInputElement { + const input = container?.querySelector('[data-testid="pane-find-input"]'); + expect(input).toBeInstanceOf(HTMLInputElement); + return input as HTMLInputElement; +} + +function changeInput(value: string): void { + const input = inputElement(); + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function pressFindKey(key: string, shiftKey = false): void { + act(() => { + inputElement().dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true })); + }); +} + +function click(testId: string): void { + const element = container?.querySelector(`[data-testid="${testId}"]`); + expect(element).toBeInstanceOf(HTMLElement); + act(() => { + element?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +function emitFindResults(event: FindResultChange): void { + act(() => { + for (const listener of findListeners) { + listener(event); + } + }); +} + +describe("TerminalPane find", () => { + it("searches through xterm, navigates matches, and clears decorations", () => { + renderTerminalPane(); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + changeInput("needle"); + expect(container?.textContent).toContain("Searching..."); + emitFindResults({ resultIndex: 2, resultCount: 7 }); + + expect(terminalHandle.findNext).toHaveBeenCalledWith({ query: "needle" }); + expect(container?.textContent).toContain("3 / 7"); + + click("pane-find-next"); + expect(terminalHandle.findNext).toHaveBeenLastCalledWith({ query: "needle" }); + expect(container?.textContent).toContain("Searching..."); + emitFindResults({ resultIndex: 3, resultCount: 7 }); + + click("pane-find-prev"); + expect(terminalHandle.findPrevious).toHaveBeenLastCalledWith({ query: "needle" }); + emitFindResults({ resultIndex: 2, resultCount: 7 }); + + pressFindKey("Enter"); + expect(terminalHandle.findNext).toHaveBeenLastCalledWith({ query: "needle" }); + emitFindResults({ resultIndex: 3, resultCount: 7 }); + + pressFindKey("Enter", true); + expect(terminalHandle.findPrevious).toHaveBeenLastCalledWith({ query: "needle" }); + + changeInput(""); + expect(terminalHandle.clearFindDecorations).toHaveBeenCalledTimes(1); + expect(container?.textContent).toContain("0 / 0"); + + changeInput("needle"); + click("pane-find-close"); + expect(terminalHandle.clearFindDecorations).toHaveBeenCalledTimes(2); + expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull(); + }); + + it("keeps terminal key input flowing while the find bar is open", async () => { + renderTerminalPane(); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + await act(async () => { + await terminalProps.current?.onTerminalKey?.({ + key: "c", + ctrl: true, + shift: false, + alt: false, + meta: false, + }); + }); + + expect(client.sendTerminalInput).toHaveBeenCalledWith("terminal-a", { + type: "input", + data: "\u0003", + }); + }); +}); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 7522c33fe7..6b2a20b9ae 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Pressable, @@ -47,6 +47,8 @@ import { type OpenFileDisposition, type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; +import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find"; +import type { TerminalFindResultChangeEvent } from "@/terminal/runtime/terminal-emulator-runtime"; interface TerminalPaneProps { serverId: string; @@ -106,10 +108,25 @@ const EMPTY_MODIFIERS: ModifierState = { alt: false, }; +const EMPTY_FIND_MATCH_STATE: PaneFindMatchState = { status: "empty" }; +const NO_FIND_MATCH_STATE: PaneFindMatchState = { status: "no-match" }; +const PENDING_FIND_MATCH_STATE: PaneFindMatchState = { status: "pending" }; + function terminalScopeKey(input: { serverId: string; cwd: string }): string { return `${input.serverId}:${input.cwd}`; } +function terminalFindStateFromResult(event: TerminalFindResultChangeEvent): PaneFindMatchState { + if (event.resultCount <= 0 || event.resultIndex < 0) { + return NO_FIND_MATCH_STATE; + } + return { + status: "matched", + current: event.resultIndex + 1, + total: event.resultCount, + }; +} + interface ModifierButtonProps { modifier: keyof ModifierState; active: boolean; @@ -212,9 +229,16 @@ export function TerminalPane({ win32InputMode: false, }); const pendingTerminalInputRef = useRef([]); + const terminalFindQueryRef = useRef(""); const keyboardRefitTimeoutsRef = useRef>>([]); const lastAutoFocusKeyRef = useRef(null); const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId }); + const [terminalFindMatchState, setTerminalFindMatchStateValue] = + useState(EMPTY_FIND_MATCH_STATE); + + const setTerminalFindMatchState = useCallback((nextState: PaneFindMatchState) => { + setTerminalFindMatchStateValue(nextState); + }, []); useEffect(() => { terminalIdRef.current = terminalId; @@ -224,6 +248,19 @@ export function TerminalPane({ }; }, [terminalId]); + useEffect(() => { + if (!isWorkspaceFocused || !terminalId) { + return; + } + + return emulatorRef.current?.onFindResultsChanged((event) => { + if (terminalFindQueryRef.current.length === 0) { + return; + } + setTerminalFindMatchState(terminalFindStateFromResult(event)); + }); + }, [isWorkspaceFocused, setTerminalFindMatchState, terminalId]); + const requestTerminalFocus = useCallback(() => { setFocusRequestToken((current) => current + 1); }, []); @@ -251,6 +288,43 @@ export function TerminalPane({ [terminalId, terminalStreamKey, workspaceTerminalSession.snapshots], ); + const clearTerminalFind = useCallback(() => { + terminalFindQueryRef.current = ""; + emulatorRef.current?.clearFindDecorations(); + setTerminalFindMatchState(EMPTY_FIND_MATCH_STATE); + }, [setTerminalFindMatchState]); + + const runTerminalFind = useCallback( + (query: string, direction: "next" | "previous"): PaneFindMatchState => { + terminalFindQueryRef.current = query; + if (query.length === 0) { + clearTerminalFind(); + return EMPTY_FIND_MATCH_STATE; + } + + const found = + direction === "previous" + ? (emulatorRef.current?.findPrevious({ query }) ?? false) + : (emulatorRef.current?.findNext({ query }) ?? false); + if (!found) { + setTerminalFindMatchState(NO_FIND_MATCH_STATE); + return NO_FIND_MATCH_STATE; + } + + setTerminalFindMatchState(PENDING_FIND_MATCH_STATE); + return PENDING_FIND_MATCH_STATE; + }, + [clearTerminalFind, setTerminalFindMatchState], + ); + + const paneFind = usePaneFind({ + matchState: terminalFindMatchState, + onQuery: (query) => runTerminalFind(query, "next"), + onNext: () => runTerminalFind(terminalFindQueryRef.current, "next"), + onPrev: () => runTerminalFind(terminalFindQueryRef.current, "previous"), + onClose: clearTerminalFind, + }); + useEffect(() => { if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) { lastAutoFocusKeyRef.current = null; @@ -748,6 +822,7 @@ export function TerminalPane({ return ( + {paneFind.isOpen ? : null} {isWorkspaceFocused ? ( diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index f2bdc894b4..9bde9ed074 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -27,6 +27,25 @@ import { navigateToLastWorkspace, useActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; +import { handlePaneFindKeyboardAction } from "@/panels/pane-find-registry"; + +type KeyboardShortcutResult = ReturnType; + +function applyPendingChordEventHandling(event: KeyboardEvent, result: KeyboardShortcutResult) { + if (result.preventDefault && !result.match) { + event.preventDefault(); + event.stopPropagation(); + } +} + +function applyHandledShortcutEventHandling(event: KeyboardEvent, result: KeyboardShortcutResult) { + if (result.preventDefault || result.match?.preventDefault) { + event.preventDefault(); + } + if (result.preventDefault || result.match?.stopPropagation) { + event.stopPropagation(); + } +} export function useKeyboardShortcuts({ enabled, @@ -94,6 +113,8 @@ export function useKeyboardShortcuts({ return false; case "dispatch": return keyboardActionDispatcher.dispatch(action.action); + case "pane-find-open": + return handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); case "navigate-workspace": navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname }); return true; @@ -182,10 +203,7 @@ export function useKeyboardShortcuts({ chordStateRef.current = result.nextChordState; - if (result.preventDefault) { - event.preventDefault(); - event.stopPropagation(); - } + applyPendingChordEventHandling(event, result); if (!result.match) { return; @@ -208,12 +226,7 @@ export function useKeyboardShortcuts({ return; } - if (result.match.preventDefault) { - event.preventDefault(); - } - if (result.match.stopPropagation) { - event.stopPropagation(); - } + applyHandledShortcutEventHandling(event, result); }; const handleKeyUp = (event: KeyboardEvent) => { diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 09bd73e8fd..d998bd4ed2 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -20,6 +20,7 @@ export type KeyboardActionId = | "agent.new" | "workspace.tab.new" | "workspace.tab.close.current" + | "workspace.find.open" | "workspace.tab.navigate.index" | "workspace.tab.navigate.relative" | "workspace.pane.split.right" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 0adc733e46..aa43d41fc4 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -11,6 +11,7 @@ export type KeyboardActionId = | "message-input.voice-mute-toggle" | "workspace.tab.new" | "workspace.tab.close-current" + | "workspace.find.open" | "workspace.tab.navigate-index" | "workspace.tab.navigate-relative" | "workspace.pane.split.right" @@ -40,6 +41,7 @@ export type KeyboardActionDefinition = | { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope } | { id: "workspace.tab.new"; scope: KeyboardActionScope } | { id: "workspace.tab.close-current"; scope: KeyboardActionScope } + | { id: "workspace.find.open"; scope: KeyboardActionScope } | { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number } | { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 } | { id: "workspace.pane.split.right"; scope: KeyboardActionScope } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 8508fbda5a..9413766476 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -206,6 +206,18 @@ describe("keyboard-shortcuts", () => { context: { isMac: true, isDesktop: true }, action: "workspace.tab.close.current", }, + { + name: "matches Cmd+F to open find in the focused pane", + event: { key: "f", code: "KeyF", metaKey: true }, + context: { isMac: true, focusScope: "terminal" }, + action: "workspace.find.open", + }, + { + name: "matches Ctrl+F to open find in the focused pane on non-mac", + event: { key: "f", code: "KeyF", ctrlKey: true }, + context: { isMac: false, focusScope: "terminal" }, + action: "workspace.find.open", + }, { name: "matches Ctrl+W to close current tab on non-mac desktop", event: { key: "w", code: "KeyW", ctrlKey: true }, @@ -540,6 +552,7 @@ describe("keyboard-shortcut help sections", () => { "workspace-jump-index": ["alt", "1-9"], "workspace-tab-jump-index": ["alt", "shift", "1-9"], "workspace-tab-close-current": ["alt", "shift", "W"], + "workspace-find-open": ["mod", "F"], "workspace-pane-split-right": ["mod", "\\"], "workspace-pane-close": ["mod", "shift", "W"], }, @@ -553,6 +566,7 @@ describe("keyboard-shortcut help sections", () => { "workspace-jump-index": ["mod", "1-9"], "workspace-tab-jump-index": ["mod", "alt", "1-9"], "workspace-tab-close-current": ["meta", "W"], + "workspace-find-open": ["mod", "F"], "workspace-pane-split-right": ["mod", "\\"], "workspace-pane-close": ["mod", "shift", "W"], }, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index a67945aae8..c25e2e7128 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -250,6 +250,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + // --- Find in pane --- + { + id: "workspace-find-open-cmd-f-mac", + action: "workspace.find.open", + combo: "Cmd+F", + when: { mac: true, commandCenter: false }, + help: { + id: "workspace-find-open", + section: "tabs-panes", + label: "Find in pane", + keys: ["mod", "F"], + }, + }, + { + id: "workspace-find-open-ctrl-f-non-mac", + action: "workspace.find.open", + combo: "Ctrl+F", + when: { mac: false, commandCenter: false }, + help: { + id: "workspace-find-open", + section: "tabs-panes", + label: "Find in pane", + keys: ["mod", "F"], + }, + }, + // --- Workspace index jump --- { id: "workspace-navigate-index-cmd-digit-mac", diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index 4100092208..0706a33096 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -37,7 +37,8 @@ export type ShortcutAction = | { kind: "open-project-picker" } | { kind: "callback"; name: ShortcutCallbackName } | { kind: "command-center-toggle"; nextOpen: boolean } - | { kind: "shortcuts-dialog-toggle"; nextOpen: boolean }; + | { kind: "shortcuts-dialog-toggle"; nextOpen: boolean } + | { kind: "pane-find-open" }; const NONE: ShortcutAction = { kind: "none" }; @@ -201,6 +202,8 @@ export function routeKeyboardShortcut( return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen }; case "shortcuts.dialog.toggle": return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen }; + case "workspace.find.open": + return { kind: "pane-find-open" }; default: return NONE; } diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 6ec608db38..1a206a13c6 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -6,6 +6,7 @@ import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; export interface PaneContextValue { serverId: string; workspaceId: string; + paneInstanceId: string | null; tabId: string; target: WorkspaceTabTarget; openTab: (target: WorkspaceTabTarget) => void; diff --git a/packages/app/src/panels/pane-find-registry.test.ts b/packages/app/src/panels/pane-find-registry.test.ts new file mode 100644 index 0000000000..69d70f8578 --- /dev/null +++ b/packages/app/src/panels/pane-find-registry.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + clearActivePaneFindPaneId, + createPaneFindRegistry, + handlePaneFindKeyboardAction, + paneFindRegistry, + setActivePaneFindPaneId, + type PaneFindController, +} from "@/panels/pane-find-registry"; + +function createController(): PaneFindController { + return { + openFind: vi.fn(() => true), + closeFind: vi.fn(() => true), + }; +} + +describe("pane find registry", () => { + it("routes open find to the active pane instance", () => { + const activePaneId = { current: "server:workspace:left" }; + const registry = createPaneFindRegistry({ + getActivePaneId: () => activePaneId.current, + }); + const left = createController(); + const right = createController(); + + registry.register({ + paneId: "server:workspace:left", + controller: left, + }); + registry.register({ + paneId: "server:workspace:right", + controller: right, + }); + + expect(registry.openFindInActivePane()).toBe(true); + expect(left.openFind).toHaveBeenCalledTimes(1); + expect(right.openFind).not.toHaveBeenCalled(); + }); + + it("stops routing to a pane after it unregisters", () => { + const registry = createPaneFindRegistry({ + getActivePaneId: () => "server:workspace:left", + }); + const controller = createController(); + + const unregister = registry.register({ + paneId: "server:workspace:left", + controller, + }); + unregister(); + + expect(registry.openFindInActivePane()).toBe(false); + expect(controller.openFind).not.toHaveBeenCalled(); + expect(controller.closeFind).toHaveBeenCalledTimes(1); + }); + + it("keeps split panes with the same target distinct by pane instance", () => { + const activePaneId = { current: "server:workspace:right" }; + const registry = createPaneFindRegistry({ + getActivePaneId: () => activePaneId.current, + }); + const left = createController(); + const right = createController(); + + registry.register({ + paneId: "server:workspace:left", + controller: left, + }); + registry.register({ + paneId: "server:workspace:right", + controller: right, + }); + + expect(registry.openFindInActivePane()).toBe(true); + expect(left.openFind).not.toHaveBeenCalled(); + expect(right.openFind).toHaveBeenCalledTimes(1); + }); + + it("handles the keyboard find action through the active pane", () => { + const controller = createController(); + const unregister = paneFindRegistry.register({ + paneId: "server:workspace:left", + controller, + }); + setActivePaneFindPaneId("server:workspace:left"); + + expect(handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" })).toBe( + true, + ); + expect(controller.openFind).toHaveBeenCalledTimes(1); + + clearActivePaneFindPaneId("server:workspace:left"); + expect(controller.closeFind).toHaveBeenCalledTimes(1); + unregister(); + }); + + it("closes the previous active pane when pane focus changes", () => { + const left = createController(); + const right = createController(); + const unregisterLeft = paneFindRegistry.register({ + paneId: "server:workspace:left", + controller: left, + }); + const unregisterRight = paneFindRegistry.register({ + paneId: "server:workspace:right", + controller: right, + }); + + setActivePaneFindPaneId("server:workspace:left"); + setActivePaneFindPaneId("server:workspace:right"); + + expect(left.closeFind).toHaveBeenCalledTimes(1); + expect(right.closeFind).not.toHaveBeenCalled(); + + clearActivePaneFindPaneId("server:workspace:right"); + unregisterLeft(); + unregisterRight(); + }); +}); diff --git a/packages/app/src/panels/pane-find-registry.ts b/packages/app/src/panels/pane-find-registry.ts new file mode 100644 index 0000000000..3703d5c9f6 --- /dev/null +++ b/packages/app/src/panels/pane-find-registry.ts @@ -0,0 +1,81 @@ +import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; + +export interface PaneFindController { + openFind(): boolean; + closeFind(): boolean; +} + +export interface RegisterPaneFindInput { + paneId: string; + controller: PaneFindController; +} + +export interface PaneFindRegistry { + register(input: RegisterPaneFindInput): () => void; + openFindInActivePane(): boolean; + closeFindInPane(paneId: string): boolean; +} + +export function createPaneFindRegistry(input: { getActivePaneId: () => string | null }) { + const controllers = new Map(); + + return { + register({ paneId, controller }: RegisterPaneFindInput) { + controllers.set(paneId, controller); + + return () => { + if (controllers.get(paneId) === controller) { + controller.closeFind(); + controllers.delete(paneId); + } + }; + }, + + openFindInActivePane(): boolean { + const activePaneId = input.getActivePaneId(); + if (!activePaneId) { + return false; + } + return controllers.get(activePaneId)?.openFind() ?? false; + }, + + closeFindInPane(paneId: string): boolean { + return controllers.get(paneId)?.closeFind() ?? false; + }, + } satisfies PaneFindRegistry; +} + +let activePaneId: string | null = null; + +export const paneFindRegistry = createPaneFindRegistry({ + getActivePaneId: () => activePaneId, +}); + +export function createPaneFindPaneId(input: { + serverId: string; + workspaceId: string; + paneId: string; +}): string { + return `${input.serverId}:${input.workspaceId}:${input.paneId}`; +} + +export function setActivePaneFindPaneId(paneId: string | null) { + if (activePaneId && activePaneId !== paneId) { + paneFindRegistry.closeFindInPane(activePaneId); + } + activePaneId = paneId; +} + +export function clearActivePaneFindPaneId(paneId: string) { + if (activePaneId === paneId) { + paneFindRegistry.closeFindInPane(paneId); + activePaneId = null; + } +} + +export function handlePaneFindKeyboardAction(action: KeyboardActionDefinition): boolean { + if (action.id !== "workspace.find.open") { + return false; + } + return paneFindRegistry.openFindInActivePane(); +} diff --git a/packages/app/src/panels/pane-find.test.tsx b/packages/app/src/panels/pane-find.test.tsx new file mode 100644 index 0000000000..56e2bf2103 --- /dev/null +++ b/packages/app/src/panels/pane-find.test.tsx @@ -0,0 +1,496 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Text, View } from "react-native"; +import { + FindBar, + usePaneFind, + type PaneFindCommandResult, + type PaneFindMatchState, +} from "@/panels/pane-find"; +import { + PaneFocusProvider, + PaneProvider, + createPaneFocusContextValue, + usePaneContext, + type PaneContextValue, +} from "@/panels/pane-context"; +import { + clearActivePaneFindPaneId, + createPaneFindPaneId, + handlePaneFindKeyboardAction, + setActivePaneFindPaneId, +} from "@/panels/pane-find-registry"; +import { + buildWorkspacePaneContentModel, + WorkspacePaneContent, +} from "@/screens/workspace/workspace-pane-content"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +const { theme } = vi.hoisted(() => ({ + theme: { + colors: { + border: "#333", + foreground: "#fff", + foregroundMuted: "#aaa", + surface0: "#111", + surface1: "#222", + }, + }, +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => + function Icon() { + return React.createElement("span", { "data-icon": name }); + }; + + return { + ChevronDown: createIcon("ChevronDown"), + ChevronUp: createIcon("ChevronUp"), + X: createIcon("X"), + }; +}); + +vi.mock("react-native", () => { + const MockView = ({ children, testID }: { children?: React.ReactNode; testID?: string }) => + React.createElement("div", { "data-testid": testID }, children); + const MockText = ({ children }: { children?: React.ReactNode }) => + React.createElement("span", null, children); + const MockPressable = ({ + children, + disabled, + onPress, + testID, + }: { + children?: React.ReactNode; + disabled?: boolean; + onPress?: () => void; + testID?: string; + }) => + React.createElement( + "button", + { + "data-testid": testID, + disabled, + onClick: () => { + if (!disabled) onPress?.(); + }, + type: "button", + }, + children, + ); + const MockTextInput = React.forwardRef< + HTMLInputElement, + { + value?: string; + onChangeText?: (text: string) => void; + onKeyPress?: (event: { + nativeEvent: { key: string; shiftKey?: boolean }; + preventDefault: () => void; + }) => void; + testID?: string; + placeholder?: string; + } + >(function TextInput({ value, onChangeText, onKeyPress, testID, placeholder }, ref) { + return React.createElement("input", { + "data-testid": testID, + onChange: (event: React.ChangeEvent) => + onChangeText?.(event.currentTarget.value), + onKeyDown: (event: React.KeyboardEvent) => + onKeyPress?.({ + nativeEvent: { key: event.key, shiftKey: event.shiftKey }, + preventDefault: () => event.preventDefault(), + }), + placeholder, + ref, + value: value ?? "", + }); + }); + + return { Pressable: MockPressable, Text: MockText, TextInput: MockTextInput, View: MockView }; +}); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + hairlineWidth: 1, + create: (factory: unknown) => + typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/panels/register-panels", () => ({ + ensurePanelsRegistered: vi.fn(), +})); + +vi.mock("@/panels/panel-registry", () => ({ + getPanelRegistration: () => ({ + kind: "agent", + component: FakeFindPanel, + useDescriptor: vi.fn(), + }), +})); + +interface FakeSearchController { + query: ReturnType PaneFindCommandResult>>; + next: ReturnType PaneFindCommandResult>>; + prev: ReturnType PaneFindCommandResult>>; + close: ReturnType void>>; +} + +const controllers = new Map(); + +function createController(): FakeSearchController { + return { + query: vi.fn((query: string) => + query === "missing" ? { status: "no-match" } : { status: "matched", current: 1, total: 3 }, + ), + next: vi.fn(() => ({ status: "matched", current: 2, total: 3 })), + prev: vi.fn(() => ({ status: "matched", current: 3, total: 3 })), + close: vi.fn(), + }; +} + +function FakeFindPanel() { + const paneContext = usePaneContext(); + const controller = controllers.get(paneContext.paneInstanceId ?? ""); + const paneFind = usePaneFind({ + onQuery: controller?.query ?? createController().query, + onNext: controller?.next ?? createController().next, + onPrev: controller?.prev ?? createController().prev, + onClose: controller?.close ?? createController().close, + }); + + return ( + + {paneFind.isOpen ? : null} + Pane body + + ); +} + +function AsyncFindPanel({ + matchState, + onQuery, +}: { + matchState: PaneFindMatchState; + onQuery: (query: string) => PaneFindCommandResult; +}) { + const paneFind = usePaneFind({ + matchState, + onQuery, + onNext: () => undefined, + onPrev: () => undefined, + onClose: vi.fn(), + }); + + return ( + + {paneFind.isOpen ? : null} + Pane body + + ); +} + +const tab: WorkspaceTabDescriptor = { + key: "agent_agent-a", + tabId: "agent_agent-a", + kind: "agent", + target: { kind: "agent", agentId: "agent-a" }, +}; +const leftPaneInstanceId = createPaneFindPaneId({ + serverId: "server-a", + workspaceId: "workspace-a", + paneId: "left", +}); +const harnessPaneContext: PaneContextValue = { + serverId: "server-a", + workspaceId: "workspace-a", + paneInstanceId: leftPaneInstanceId, + tabId: "agent_agent-a", + target: tab.target, + openTab: () => {}, + closeCurrentTab: () => {}, + retargetCurrentTab: () => {}, + openFileInWorkspace: () => {}, +}; +const harnessPaneFocus = createPaneFocusContextValue({ + isPaneFocused: true, + isWorkspaceFocused: true, + onFocusPane: () => {}, +}); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + clearActivePaneFindPaneId("server-a:workspace-a:left"); + clearActivePaneFindPaneId("server-a:workspace-a:right"); + controllers.clear(); + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); +}); + +function renderFindHarness(controller: FakeSearchController = createController()) { + controllers.set(leftPaneInstanceId, controller); + + act(() => { + root?.render( + + + + + , + ); + }); + + setActivePaneFindPaneId(leftPaneInstanceId); +} + +function inputElement(): HTMLInputElement { + const input = container?.querySelector('[data-testid="pane-find-input"]'); + expect(input).toBeInstanceOf(HTMLInputElement); + return input as HTMLInputElement; +} + +function button(testId: string): HTMLElement { + const element = container?.querySelector(`[data-testid="${testId}"]`); + expect(element).toBeInstanceOf(HTMLElement); + return element as HTMLElement; +} + +function changeInput(value: string): void { + const input = inputElement(); + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function pressKey(key: string, shiftKey = false): void { + const input = inputElement(); + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true })); + }); +} + +describe("FindBar", () => { + it("opens through pane registration and focuses the query input", () => { + renderFindHarness(); + + expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull(); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + + expect(inputElement()).toBe(document.activeElement); + expect(container?.textContent).toContain("Find"); + }); + + it("dispatches query changes and renders match, empty, and no-match states", () => { + const controller = createController(); + renderFindHarness(controller); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + + expect(container?.textContent).toContain("0 / 0"); + + changeInput("abc"); + expect(controller.query).toHaveBeenLastCalledWith("abc"); + expect(container?.textContent).toContain("1 / 3"); + + changeInput("missing"); + expect(container?.textContent).toContain("No matches"); + + changeInput(""); + expect(container?.textContent).toContain("0 / 0"); + }); + + it("handles next, previous, Escape, Enter, Shift+Enter, and close", () => { + const controller = createController(); + renderFindHarness(controller); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + changeInput("abc"); + + pressKey("Enter"); + expect(controller.next).toHaveBeenCalledTimes(1); + expect(container?.textContent).toContain("2 / 3"); + + pressKey("Enter", true); + expect(controller.prev).toHaveBeenCalledTimes(1); + expect(container?.textContent).toContain("3 / 3"); + + act(() => { + button("pane-find-next").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(controller.next).toHaveBeenCalledTimes(2); + + act(() => { + button("pane-find-prev").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(controller.prev).toHaveBeenCalledTimes(2); + + pressKey("Escape"); + expect(controller.close).toHaveBeenCalledTimes(1); + expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull(); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + act(() => { + button("pane-find-close").dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(controller.close).toHaveBeenCalledTimes(2); + }); + + it("cleans up the active find adapter on pane deactivation and unmount", () => { + const controller = createController(); + renderFindHarness(controller); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + changeInput("abc"); + + act(() => { + clearActivePaneFindPaneId(leftPaneInstanceId); + }); + expect(controller.close).toHaveBeenCalledTimes(1); + expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull(); + + act(() => { + setActivePaneFindPaneId(leftPaneInstanceId); + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + changeInput("abc"); + act(() => { + root?.unmount(); + }); + expect(controller.close).toHaveBeenCalledTimes(2); + root = null; + }); + + it("shows pending match metadata until an adapter-owned async result arrives", () => { + const onQuery = vi.fn(() => undefined); + let setExternalMatchState: ((matchState: PaneFindMatchState) => void) | null = null; + + function Harness() { + const [externalMatchState, setMatchState] = React.useState({ + status: "empty", + }); + setExternalMatchState = setMatchState; + return ( + + + + + + ); + } + + act(() => { + root?.render(); + }); + setActivePaneFindPaneId(leftPaneInstanceId); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + changeInput("needle"); + + expect(onQuery).toHaveBeenCalledWith("needle"); + expect(container?.textContent).toContain("Searching..."); + + act(() => { + setExternalMatchState?.({ status: "matched", current: 2, total: 4 }); + }); + + expect(container?.textContent).toContain("2 / 4"); + }); + + it("routes open find through the focused workspace pane without replacing pane focus", () => { + const left = createController(); + const right = createController(); + const leftContent = buildWorkspacePaneContentModel({ + tab, + paneId: "left", + normalizedServerId: "server-a", + normalizedWorkspaceId: "workspace-a", + onOpenTab: vi.fn(), + onCloseCurrentTab: vi.fn(), + onRetargetCurrentTab: vi.fn(), + onOpenWorkspaceFile: vi.fn(), + }); + + const rightContent = buildWorkspacePaneContentModel({ + tab, + paneId: "right", + normalizedServerId: "server-a", + normalizedWorkspaceId: "workspace-a", + onOpenTab: vi.fn(), + onCloseCurrentTab: vi.fn(), + onRetargetCurrentTab: vi.fn(), + onOpenWorkspaceFile: vi.fn(), + }); + const focusLeft = vi.fn(); + const focusRight = vi.fn(); + controllers.set("server-a:workspace-a:left", left); + controllers.set("server-a:workspace-a:right", right); + + act(() => { + root?.render( + + + + , + ); + }); + + act(() => { + handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" }); + }); + + changeInput("abc"); + expect(left.query).not.toHaveBeenCalled(); + expect(right.query).toHaveBeenCalledWith("abc"); + expect(focusLeft).not.toHaveBeenCalled(); + expect(focusRight).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/panels/pane-find.tsx b/packages/app/src/panels/pane-find.tsx new file mode 100644 index 0000000000..cc9c4aab17 --- /dev/null +++ b/packages/app/src/panels/pane-find.tsx @@ -0,0 +1,310 @@ +import { ChevronDown, ChevronUp, X } from "lucide-react-native"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Pressable, + Text, + TextInput, + View, + type NativeSyntheticEvent, + type TextInputKeyPressEventData, +} from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { usePaneContext } from "@/panels/pane-context"; +import { paneFindRegistry } from "@/panels/pane-find-registry"; + +export type PaneFindMatchState = + | { status: "empty" } + | { status: "pending" } + | { status: "no-match" } + | { status: "matched"; current: number; total: number }; + +export type PaneFindCommandResult = PaneFindMatchState | void; + +export interface UsePaneFindInput { + matchState?: PaneFindMatchState; + onQuery(query: string): PaneFindCommandResult; + onNext(): PaneFindCommandResult; + onPrev(): PaneFindCommandResult; + onClose(): void; +} + +export interface FindBarProps { + query: string; + matchState: PaneFindMatchState; + focusToken: number; + onQueryChange(query: string): void; + onNext(): void; + onPrev(): void; + onClose(): void; +} + +export interface UsePaneFindResult { + isOpen: boolean; + findBarProps: FindBarProps; +} + +export function usePaneFind(input: UsePaneFindInput): UsePaneFindResult { + const { paneInstanceId } = usePaneContext(); + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [matchState, setMatchState] = useState({ status: "empty" }); + const [focusToken, setFocusToken] = useState(0); + const onQuery = useStableEvent(input.onQuery); + const onNextInput = useStableEvent(input.onNext); + const onPrevInput = useStableEvent(input.onPrev); + const onCloseInput = useStableEvent(input.onClose); + const isOpenRef = useRef(false); + const queryRef = useRef(""); + + useEffect(() => { + isOpenRef.current = isOpen; + }, [isOpen]); + + useEffect(() => { + queryRef.current = query; + }, [query]); + + useEffect(() => { + if (!isOpen || !input.matchState) { + return; + } + setMatchState(input.matchState); + }, [input.matchState, isOpen]); + + const openFind = useCallback(() => { + isOpenRef.current = true; + setIsOpen(true); + setFocusToken((current) => current + 1); + return true; + }, []); + + const closeFind = useCallback(() => { + if (!isOpenRef.current && queryRef.current.length === 0) { + return false; + } + isOpenRef.current = false; + queryRef.current = ""; + setIsOpen(false); + setQuery(""); + setMatchState({ status: "empty" }); + onCloseInput(); + return true; + }, [onCloseInput]); + + useEffect(() => { + if (!paneInstanceId) { + return; + } + return paneFindRegistry.register({ + paneId: paneInstanceId, + controller: { openFind, closeFind }, + }); + }, [closeFind, openFind, paneInstanceId]); + + useEffect(() => { + return () => { + closeFind(); + }; + }, [closeFind]); + + const handleQueryChange = useCallback( + (nextQuery: string) => { + queryRef.current = nextQuery; + setQuery(nextQuery); + const nextState = onQuery(nextQuery); + setMatchState( + nextQuery.length === 0 ? { status: "empty" } : (nextState ?? { status: "pending" }), + ); + }, + [onQuery], + ); + + const handleNext = useCallback(() => { + if (queryRef.current.length === 0) { + return; + } + setMatchState(onNextInput() ?? { status: "pending" }); + }, [onNextInput]); + + const handlePrev = useCallback(() => { + if (queryRef.current.length === 0) { + return; + } + setMatchState(onPrevInput() ?? { status: "pending" }); + }, [onPrevInput]); + + const handleClose = useCallback(() => { + closeFind(); + }, [closeFind]); + + const findBarProps = useMemo( + () => ({ + query, + matchState, + focusToken, + onQueryChange: handleQueryChange, + onNext: handleNext, + onPrev: handlePrev, + onClose: handleClose, + }), + [focusToken, handleClose, handleNext, handlePrev, handleQueryChange, matchState, query], + ); + + return { isOpen, findBarProps }; +} + +export function FindBar({ + query, + matchState, + focusToken, + onQueryChange, + onNext, + onPrev, + onClose, +}: FindBarProps) { + const inputRef = useRef(null); + const { theme } = useUnistyles(); + const iconColor = theme.colors?.foregroundMuted ?? "#71717a"; + + useEffect(() => { + inputRef.current?.focus(); + }, [focusToken]); + + const canNavigate = matchState.status === "matched"; + const handleKeyPress = useCallback( + (event: NativeSyntheticEvent) => { + if (event.nativeEvent.key === "Escape") { + event.preventDefault?.(); + onClose(); + return; + } + if (event.nativeEvent.key !== "Enter") { + return; + } + event.preventDefault?.(); + if (!canNavigate) { + return; + } + if (event.nativeEvent.shiftKey) { + onPrev(); + return; + } + onNext(); + }, + [canNavigate, onClose, onNext, onPrev], + ); + + const counterText = formatMatchState(matchState); + const controlDisabled = !canNavigate; + const matchButtonStyle = useMemo( + () => [styles.iconButton, controlDisabled && styles.iconButtonDisabled], + [controlDisabled], + ); + + return ( + + Find + + {counterText} + + + + + + + + + + + ); +} + +function formatMatchState(matchState: PaneFindMatchState): string { + if (matchState.status === "matched") { + return `${matchState.current} / ${matchState.total}`; + } + if (matchState.status === "no-match") { + return "No matches"; + } + if (matchState.status === "pending") { + return "Searching..."; + } + return "0 / 0"; +} + +const styles = StyleSheet.create((theme) => ({ + container: { + minHeight: 36, + flexDirection: "row", + alignItems: "center", + gap: 6, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + paddingHorizontal: 8, + paddingVertical: 4, + }, + label: { + color: theme.colors.foregroundMuted, + fontSize: 12, + fontWeight: "600", + }, + input: { + flex: 1, + minWidth: 120, + height: 26, + borderWidth: StyleSheet.hairlineWidth, + borderColor: theme.colors.border, + borderRadius: 6, + color: theme.colors.foreground, + backgroundColor: theme.colors.surface0, + fontSize: 13, + paddingHorizontal: 8, + paddingVertical: 3, + }, + counter: { + minWidth: 64, + color: theme.colors.foregroundMuted, + fontSize: 12, + textAlign: "right", + }, + iconButton: { + width: 26, + height: 26, + alignItems: "center", + justifyContent: "center", + borderRadius: 6, + }, + iconButtonDisabled: { + opacity: 0.4, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx index fa353542f1..ca4f3808ee 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx @@ -113,6 +113,7 @@ describe("WorkspacePaneContent", () => { isInteractive: false, focusPane: expect.any(Function), }); + expect(snapshots[0]?.paneContextValue.paneInstanceId).toBeNull(); expect(snapshots[1]?.focus).toEqual({ isWorkspaceFocused: true, isPaneFocused: true, diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 3987980fd2..c07a0150cc 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, type ComponentType } from "react"; +import React, { useEffect, useMemo, type ComponentType } from "react"; import invariant from "tiny-invariant"; import { createPaneFocusContextValue, @@ -10,6 +10,11 @@ import { getPanelRegistration } from "@/panels/panel-registry"; import { ensurePanelsRegistered } from "@/panels/register-panels"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; +import { + clearActivePaneFindPaneId, + createPaneFindPaneId, + setActivePaneFindPaneId, +} from "@/panels/pane-find-registry"; export interface WorkspacePaneContentModel { key: string; @@ -19,6 +24,7 @@ export interface WorkspacePaneContentModel { export interface BuildWorkspacePaneContentModelInput { tab: WorkspaceTabDescriptor; + paneId?: string | null; normalizedServerId: string; normalizedWorkspaceId: string; onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; @@ -30,6 +36,7 @@ export interface BuildWorkspacePaneContentModelInput { export function buildWorkspacePaneContentModel({ tab, + paneId, normalizedServerId, normalizedWorkspaceId, onOpenTab, @@ -41,12 +48,20 @@ export function buildWorkspacePaneContentModel({ ensurePanelsRegistered(); const registration = getPanelRegistration(tab.kind); invariant(registration, `No panel registration for kind: ${tab.kind}`); + const paneInstanceId = paneId + ? createPaneFindPaneId({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + paneId, + }) + : null; return { key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`, Component: registration.component, paneContextValue: { serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, + paneInstanceId, tabId: tab.tabId, target: tab.target, openTab: onOpenTab, @@ -81,6 +96,16 @@ export function WorkspacePaneContent({ }), [isPaneFocused, isWorkspaceFocused, onFocusPane], ); + useEffect(() => { + if (!paneContextValue.paneInstanceId || !isWorkspaceFocused || !isPaneFocused) { + return; + } + const paneInstanceId = paneContextValue.paneInstanceId; + setActivePaneFindPaneId(paneInstanceId); + return () => { + clearActivePaneFindPaneId(paneInstanceId); + }; + }, [isPaneFocused, isWorkspaceFocused, paneContextValue.paneInstanceId]); return ( diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a32931b615..5cc2f50475 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -2827,6 +2827,7 @@ function WorkspaceScreenContent({ }) => buildWorkspacePaneContentModel({ tab: input.tab, + paneId: input.paneId, normalizedServerId, normalizedWorkspaceId, onOpenTab: (target) => { diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 6a6748b71c..ce72b05a6e 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -26,6 +26,16 @@ vi.mock("@xterm/addon-ligatures/lib/addon-ligatures.mjs", () => ({ vi.mock("@xterm/addon-search", () => ({ SearchAddon: class SearchAddon { + findNext(): boolean { + return false; + } + findPrevious(): boolean { + return false; + } + clearDecorations(): void {} + onDidChangeResults(): { dispose: () => void } { + return { dispose: () => {} }; + } dispose(): void {} }, })); @@ -91,6 +101,15 @@ interface StubTerminal { cols?: number; } +interface StubSearchAddon { + findNext: (term: string, options?: unknown) => boolean; + findPrevious: (term: string, options?: unknown) => boolean; + clearDecorations: () => void; + onDidChangeResults: (listener: (event: { resultIndex: number; resultCount: number }) => void) => { + dispose: () => void; + }; +} + function createRuntimeWithTerminal(): { runtime: TerminalEmulatorRuntime; terminal: StubTerminal & { @@ -145,6 +164,55 @@ function decodeTerminalOutput(data: string | Uint8Array): string { return new TextDecoder().decode(data); } +function createRuntimeWithSearchAddon(input?: { + findNextResult?: boolean; + findPreviousResult?: boolean; +}): { + runtime: TerminalEmulatorRuntime; + searchAddon: StubSearchAddon; + calls: { + findNext: Array<{ term: string; options: unknown }>; + findPrevious: Array<{ term: string; options: unknown }>; + clearDecorations: number; + subscribedListeners: Array<(event: { resultIndex: number; resultCount: number }) => void>; + disposeSubscription: number; + }; +} { + const runtime = new TerminalEmulatorRuntime(); + const calls = { + findNext: [] as Array<{ term: string; options: unknown }>, + findPrevious: [] as Array<{ term: string; options: unknown }>, + clearDecorations: 0, + subscribedListeners: [] as Array<(event: { resultIndex: number; resultCount: number }) => void>, + disposeSubscription: 0, + }; + const searchAddon: StubSearchAddon = { + findNext: (term, options) => { + calls.findNext.push({ term, options }); + return input?.findNextResult ?? true; + }, + findPrevious: (term, options) => { + calls.findPrevious.push({ term, options }); + return input?.findPreviousResult ?? true; + }, + clearDecorations: () => { + calls.clearDecorations += 1; + }, + onDidChangeResults: (listener) => { + calls.subscribedListeners.push(listener); + return { + dispose: () => { + calls.disposeSubscription += 1; + }, + }; + }, + }; + + (runtime as unknown as { searchAddon: StubSearchAddon }).searchAddon = searchAddon; + + return { runtime, searchAddon, calls }; +} + describe("terminal-emulator-runtime", () => { const originalWindow = (globalThis as { window?: unknown }).window; @@ -418,4 +486,71 @@ describe("terminal-emulator-runtime", () => { expect(fitAndEmitResize).not.toHaveBeenCalled(); }); + + it("delegates find navigation and clearing to the stored xterm search addon", () => { + const { runtime, calls } = createRuntimeWithSearchAddon({ + findNextResult: true, + findPreviousResult: false, + }); + + expect(runtime.findNext({ query: "needle" })).toBe(true); + expect(runtime.findPrevious({ query: "needle" })).toBe(false); + runtime.clearFindDecorations(); + + expect(calls.findNext).toEqual([ + { + term: "needle", + options: { + caseSensitive: false, + decorations: { + matchBackground: "#facc15", + matchOverviewRuler: "#facc15", + activeMatchBackground: "#38bdf8", + activeMatchColorOverviewRuler: "#38bdf8", + }, + }, + }, + ]); + expect(calls.findPrevious).toEqual([ + { + term: "needle", + options: { + caseSensitive: false, + decorations: { + matchBackground: "#facc15", + matchOverviewRuler: "#facc15", + activeMatchBackground: "#38bdf8", + activeMatchColorOverviewRuler: "#38bdf8", + }, + }, + }, + ]); + expect(calls.clearDecorations).toBe(1); + }); + + it("forwards xterm search result changes and disposes the subscription", () => { + const { runtime, calls } = createRuntimeWithSearchAddon(); + const events: Array<{ resultIndex: number; resultCount: number }> = []; + + const unsubscribe = runtime.onFindResultsChanged((event) => { + events.push(event); + }); + calls.subscribedListeners[0]?.({ resultIndex: 2, resultCount: 5 }); + unsubscribe(); + + expect(events).toEqual([{ resultIndex: 2, resultCount: 5 }]); + expect(calls.disposeSubscription).toBe(1); + }); + + it("keeps find methods inert before the search addon is mounted", () => { + const runtime = new TerminalEmulatorRuntime(); + const listener = vi.fn(); + + expect(runtime.findNext({ query: "needle" })).toBe(false); + expect(runtime.findPrevious({ query: "needle" })).toBe(false); + runtime.clearFindDecorations(); + runtime.onFindResultsChanged(listener)(); + + expect(listener).not.toHaveBeenCalled(); + }); }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index c23e801d59..4f6c9d2fa9 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -2,11 +2,12 @@ import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; import { ImageAddon } from "@xterm/addon-image"; import { SearchAddon } from "@xterm/addon-search"; +import type { ISearchOptions, ISearchResultChangeEvent } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs"; -import { Terminal, type ITheme } from "@xterm/xterm"; +import { Terminal, type IDisposable, type ITheme } from "@xterm/xterm"; import type { TerminalState } from "@getpaseo/protocol/messages"; import { type TerminalInputModeState, @@ -61,6 +62,12 @@ export interface TerminalEmulatorRuntimeCallbacks { onInputModeChange?: (state: TerminalInputModeState) => Promise | void; } +export type TerminalFindResultChangeEvent = ISearchResultChangeEvent; + +export interface TerminalFindQueryInput { + query: string; +} + interface TerminalEmulatorRuntimeDisposables { disposeInput: () => void; disconnectResizeObserver: () => void; @@ -128,6 +135,16 @@ function prependTerminalOutput( return output; } +const TERMINAL_FIND_SEARCH_OPTIONS: ISearchOptions = { + caseSensitive: false, + decorations: { + matchBackground: "#facc15", + matchOverviewRuler: "#facc15", + activeMatchBackground: "#38bdf8", + activeMatchColorOverviewRuler: "#38bdf8", + }, +}; + const DEFAULT_TERMINAL_FONT_FAMILY = [ // Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs. "JetBrains Mono", @@ -164,6 +181,7 @@ export class TerminalEmulatorRuntime { }; private terminal: Terminal | null = null; private fitAddon: FitAddon | null = null; + private searchAddon: SearchAddon | null = null; private fitAndEmitResize: ((force: boolean) => void) | null = null; private lastSize: { rows: number; cols: number } | null = null; private cleanup: (() => void) | null = null; @@ -223,6 +241,7 @@ export class TerminalEmulatorRuntime { theme: withOverviewRulerBorderHidden(input.theme), }); const fitAddon = new FitAddon(); + const searchAddon = new SearchAddon({ highlightLimit: 20_000 }); const unicode11Addon = new Unicode11Addon(); let webglAddon: WebglAddon | null = null; let imageAddon: ImageAddon | null = null; @@ -245,7 +264,7 @@ export class TerminalEmulatorRuntime { }, }), ); - terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 })); + terminal.loadAddon(searchAddon); terminal.loadAddon(new ClipboardAddon()); try { terminal.loadAddon(new LigaturesAddon()); @@ -334,6 +353,7 @@ export class TerminalEmulatorRuntime { this.terminal = terminal; this.fitAddon = fitAddon; + this.searchAddon = searchAddon; window.__paseoTerminal = terminal; const fitAndEmitResize = (force: boolean): void => { @@ -687,6 +707,25 @@ export class TerminalEmulatorRuntime { this.terminal?.blur(); } + findNext(input: TerminalFindQueryInput): boolean { + return this.searchAddon?.findNext(input.query, TERMINAL_FIND_SEARCH_OPTIONS) ?? false; + } + + findPrevious(input: TerminalFindQueryInput): boolean { + return this.searchAddon?.findPrevious(input.query, TERMINAL_FIND_SEARCH_OPTIONS) ?? false; + } + + clearFindDecorations(): void { + this.searchAddon?.clearDecorations(); + } + + onFindResultsChanged(listener: (event: TerminalFindResultChangeEvent) => void): () => void { + const disposable: IDisposable | undefined = this.searchAddon?.onDidChangeResults(listener); + return () => { + disposable?.dispose(); + }; + } + private refreshVisibleRows(): void { const terminal = this.terminal; if (!terminal || terminal.rows <= 0) { @@ -739,6 +778,7 @@ export class TerminalEmulatorRuntime { } this.terminal = null; this.fitAddon = null; + this.searchAddon = null; this.fitAndEmitResize = null; this.lastSize = null; this.themeBackgroundElements = [];