Compare commits

..
Author SHA1 Message Date
Mohamed Boudra bcb3157e75 test(app): isolate terminal pane settings mock 2026-05-31 12:45:29 +07:00
Mohamed Boudra de162b98d4 test(app): fix pane test mocks 2026-05-31 12:45:29 +07:00
Mohamed Boudra 0a960f9ae0 fix(app): reconcile find-in-pane with main after rebase
Resolve drift where main evolved the same areas this feature touches:

- view.tsx: keep main's agent-stream/ layout refactor, drop the
  working-indicator/turn-copy helpers main relocated, fix moved import paths
- terminal-emulator.tsx: expose terminal find on the typed ref handle only
  (main's DOMImperativeFactory is JSON-serializable-only)
- file-pane.tsx: render syntax highlighting and find highlighting together,
  pass lineSelection through the searchable preview
- tests/e2e: track main's PaneContextValue.openImportSheet,
  TerminalEmulatorHandle (restoreOutput/blur), and the seed-client helper
2026-05-31 12:45:29 +07:00
Mohamed Boudra 7c4f8580e9 test(app): broaden terminal find count assertion in find-in-pane spec
The terminal find pane matches the echoed printf command line in addition
to the printed output, so the actual count is 4 / 4. Match any non-zero
count, mirroring the chat assertion below it — this step only verifies
that find found something before clicking away.
2026-05-31 12:45:29 +07:00
Mohamed Boudra 33674e0b10 refactor(app): clean up find-in-pane slop
- A bridge: remove renderer webview find fallback and require the Electron bridge path

- B core: tighten pane-find assertions and hide internal search/render types

- C adapters: align chat/file search registration with highlightable content

- D harness: delete flaky Electron QA harness and simplify keyboard dispatcher scope
2026-05-31 12:45:29 +07:00
Mohamed Boudra 0ecc51bd99 feat(app): add find in pane across workspace panes 2026-05-31 12:45:29 +07:00
Mohamed Boudra 526e6070f8 feat(app): route Electron browser find through main-process owner WebContents 2026-05-31 12:45:29 +07:00
Mohamed Boudra 40d6a9127a Add Electron find QA harness 2026-05-31 12:45:29 +07:00
Mohamed Boudra 3d96ea6354 Fix chat history paging around tool updates (#1247)
* Fix projected timeline paging

* Avoid full timeline fetches for plain pages
2026-05-31 05:38:29 +00:00
Mohamed Boudra 0e3eb9cf75 Flatten settings sidebar with a host picker (#1241)
* Flatten settings sidebar with a host picker

Settings had two implicit tiers: app-level sections as flat sidebar rows,
and per-host settings hidden behind a drill-in to one monolithic host page.
This flattens both into a single sidebar with an "App" group and a "Host"
group, where the host's settings are top-level rows scoped by a host picker.

- Split the monolithic HostPage into four section pages: Connections,
  Orchestration (was the unnamed inject-tools + system-prompt block),
  Providers, and Daemon (status, updates, restart, remove).
- Host picker reuses the canonical <Combobox> sidebar host switcher; defaults
  to the local host so a single-local-host user never thinks about "the daemon".
- Host sections become real sub-routes (/settings/hosts/[id]/[section]); the
  old /settings/hosts/[id] redirects to the connections section, so existing
  deep links (pairing, workspace, open-project) keep working.
- "Open providers" from the project screen now lands on the Providers section.

* Fix e2e host-settings navigation for the flat sidebar

The flat-settings refactor moved host settings onto separate section pages,
but three e2e helpers still expected the old monolithic host page:

- openDesktopSettings: the daemon-lifecycle card now lives on the Daemon
  section — navigate there before asserting it.
- acp catalog: "Add provider" now lives on the Providers section — open that
  section before opening the add-provider modal.
- openAddHostFlow: "Add host" is now an item inside the host picker (Combobox),
  not a standalone button — open the picker first.

Test-only; no product changes. Fixes the 8 playwright failures on this branch.
2026-05-31 12:55:08 +08:00
Mohamed Boudra c8ca503cc3 Show agent terminals created in workspace subdirectories (#1245)
Terminals were bucketed and looked up by exact cwd, so a terminal an
agent opened in a subdirectory of the workspace never appeared in the
workspace panel and its live updates were dropped — the panel only ever
queried and subscribed the exact root.

getTerminals(root) now aggregates every bucket at or below the root, and
the session controller delivers a subdirectory change to each subscribed
root above it, keyed by that root and carrying the full aggregated list.
Keying by the root with the complete set is what keeps the client's
cache replacement from dropping the terminals that live at the root.
2026-05-31 12:29:05 +08:00
96 changed files with 7216 additions and 1967 deletions
+4 -2
View File
@@ -2,8 +2,8 @@
Agent chat delivery has two paths: Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. 1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. 2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The invariant is: The invariant is:
@@ -24,6 +24,8 @@ Heartbeat is used for notification routing. It must not be used as a correctness
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial. Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`. When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward. The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
@@ -6,6 +6,7 @@ import {
installAcpCatalogProvider, installAcpCatalogProvider,
openAddProviderModal, openAddProviderModal,
openSettingsHost, openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings"; } from "./helpers/settings";
const ACP_PROVIDER = { const ACP_PROVIDER = {
@@ -18,6 +19,8 @@ test.describe("ACP provider catalog", () => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await openSettingsHost(page, getServerId()); await openSettingsHost(page, getServerId());
// Providers moved to their own host section; add-provider lives there now.
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderModal(page); await openAddProviderModal(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name); await installAcpCatalogProvider(page, ACP_PROVIDER.name);
+193
View File
@@ -0,0 +1,193 @@
import { expect, test, type Page } from "./fixtures";
import { waitForTabBar } from "./helpers/launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
import { connectSeedClient } from "./helpers/seed-client";
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<void> {
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<void> {
await page.keyboard.press(findShortcut());
await expect(page.getByTestId("pane-find-bar")).toBeVisible({ timeout: 10_000 });
}
async function typeFindQuery(page: Page, query: string): Promise<void> {
const input = page.getByTestId("pane-find-input");
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill(query);
}
test.describe("in-pane find", () => {
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 connectSeedClient();
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-9] \/ [1-9]/)).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();
}
});
});
+4 -1
View File
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app"; import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port"; import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost } from "./settings"; import { openSettingsHost, openSettingsHostSection } from "./settings";
interface DaemonApiStatus { interface DaemonApiStatus {
version: string; version: string;
@@ -208,6 +208,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> { export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page); await openSettings(page);
await openSettingsHost(page, serverId); await openSettingsHost(page, serverId);
// The daemon-lifecycle card moved to the Daemon section in the flat-settings
// layout; navigate there before asserting it.
await openSettingsHostSection(page, serverId, "daemon");
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({ await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
timeout: 15_000, timeout: 15_000,
}); });
+2 -2
View File
@@ -320,12 +320,12 @@ async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefin
const client = handle.client as SeedDaemonClient & { const client = handle.client as SeedDaemonClient & {
fetchAgentTimeline: ( fetchAgentTimeline: (
agentId: string, agentId: string,
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number }, options?: { direction?: "head" | "tail"; projection?: "projected"; limit?: number },
) => Promise<{ epoch?: string }>; ) => Promise<{ epoch?: string }>;
}; };
const timeline = await client.fetchAgentTimeline(handle.agentId, { const timeline = await client.fetchAgentTimeline(handle.agentId, {
direction: "tail", direction: "tail",
projection: "canonical", projection: "projected",
limit: 0, limit: 0,
}); });
return timeline.epoch; return timeline.epoch;
+68 -21
View File
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { TEST_HOST_LABEL } from "./daemon-registry";
import { escapeRegex } from "./regex"; import { escapeRegex } from "./regex";
import { getServerId } from "./server-id"; import { getServerId } from "./server-id";
@@ -13,6 +14,8 @@ const SECTION_LABELS = {
export type SettingsSection = keyof typeof SECTION_LABELS | "projects"; export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
type HostSection = "connections" | "orchestration" | "providers" | "daemon";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> { export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar"); const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible(); await expect(sidebar).toBeVisible();
@@ -28,8 +31,22 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
} }
export async function openSettingsHost(page: Page, serverId: string): Promise<void> { export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId(`settings-host-entry-${serverId}`).click(); // Host sections are now flat top-level rows under the Host group. Navigate by
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); // clicking the Connections section row; the picker only matters when >1 host.
await page.getByTestId("settings-host-section-connections").click();
await expectHostSettingsUrl(page, serverId);
await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
}
export async function openSettingsHostSection(
page: Page,
serverId: string,
section: HostSection,
): Promise<void> {
await page.getByTestId(`settings-host-section-${section}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`),
);
} }
export async function expectSettingsHeader(page: Page, title: string): Promise<void> { export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
@@ -37,6 +54,9 @@ export async function expectSettingsHeader(page: Page, title: string): Promise<v
} }
export async function openAddHostFlow(page: Page): Promise<void> { export async function openAddHostFlow(page: Page): Promise<void> {
// "Add host" is now an item inside the host picker (a Combobox); open the
// picker first, then pick it. The picker renders whenever a host exists.
await page.getByTestId("settings-host-picker").click();
await page.getByTestId("settings-add-host").click(); await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible(); await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
} }
@@ -68,7 +88,7 @@ export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page.getByTestId("settings-sidebar")).toBeVisible(); await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0); await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0); await expect(page.getByTestId("host-page-connections-card")).toHaveCount(0);
} }
export async function expectSettingsSidebarVisible(page: Page): Promise<void> { export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
@@ -105,7 +125,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> { export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expect(page).toHaveURL( await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`), new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
); );
} }
@@ -181,7 +201,11 @@ export async function expectHostLabelEditMode(page: Page, expectedLabel: string)
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> { export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
const card = page.getByTestId("host-page-connections-card"); const card = page.getByTestId("host-page-connections-card");
await expect(card).toBeVisible(); await expect(card).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible(); // "Connections" appears three times on this page: the sidebar section row, the
// detail header title, and the SettingsSection heading above the card. Match
// the first to keep the heading assertion without tripping Playwright strict
// mode.
await expect(page.getByText("Connections", { exact: true }).first()).toBeVisible();
await expect( await expect(
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)), card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible(); ).toBeVisible();
@@ -193,14 +217,29 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible(); await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
} }
export async function expectHostActionCards(page: Page): Promise<void> { export async function openHostSection(
page: Page,
serverId: string,
section: HostSection,
): Promise<void> {
await openSettingsHostSection(page, serverId, section);
}
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
// Restart + remove cards live on the Daemon section; providers moved to its
// own Providers section (asserted via expectHostProvidersCard).
await openSettingsHostSection(page, serverId, "daemon");
await expect(page.getByTestId("host-page-restart-card")).toBeVisible(); await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible(); await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible(); await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
} }
export async function expectHostProvidersCard(page: Page, serverId: string): Promise<void> {
await openSettingsHostSection(page, serverId, "providers");
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
}
export async function serveJson(page: Page, url: string, body: unknown): Promise<void> { export async function serveJson(page: Page, url: string, body: unknown): Promise<void> {
await page.route(url, async (route) => { await page.route(url, async (route) => {
await route.fulfill({ await route.fulfill({
@@ -244,27 +283,35 @@ export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> { export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar"); const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible(); await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0); // App group rows remain top-level.
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible(); await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible(); await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible(); await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
// Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-orchestration")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-daemon")).toBeVisible();
// The old per-host entry rows are replaced by the host picker.
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
} }
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> { export async function expectHostPageVisible(page: Page, _serverId: string): Promise<void> {
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
} }
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> { export async function expectLocalHostEntryFirst(page: Page, _serverId: string): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar"); const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15_000 }); await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
"data-testid", // Single-host fixture: the picker is a non-interactive chip (no dropdown to
`settings-host-entry-${serverId}`, // open) that surfaces the local host by its label. The "Local" marker only
); // appears on dropdown rows in the multi-host case, which this fixture does not
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`); // exercise.
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible(); const picker = sidebar.getByTestId("settings-host-picker");
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible(); await expect(picker).toBeVisible();
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
} }
+47 -11
View File
@@ -6,12 +6,14 @@ import { getServerId } from "./helpers/server-id";
import { import {
expectSettingsHeader, expectSettingsHeader,
openSettingsHost, openSettingsHost,
openHostSection,
expectHostLabelDisplayed, expectHostLabelDisplayed,
clickEditHostLabel, clickEditHostLabel,
expectHostLabelEditMode, expectHostLabelEditMode,
expectHostConnectionsCard, expectHostConnectionsCard,
expectHostInjectMcpCard, expectHostInjectMcpCard,
expectHostActionCards, expectHostActionCards,
expectHostProvidersCard,
expectHostNoLocalOnlyRows, expectHostNoLocalOnlyRows,
expectRetiredSidebarSectionsAbsent, expectRetiredSidebarSectionsAbsent,
expectHostPageVisible, expectHostPageVisible,
@@ -19,9 +21,7 @@ import {
} from "./helpers/settings"; } from "./helpers/settings";
test.describe("Settings host page", () => { test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({ test("connections section shows the seeded connection endpoint", async ({ page }) => {
page,
}) => {
const serverId = getServerId(); const serverId = getServerId();
const port = getE2EDaemonPort(); const port = getE2EDaemonPort();
@@ -29,11 +29,44 @@ test.describe("Settings host page", () => {
await openSettings(page); await openSettings(page);
await openSettingsHost(page, serverId); await openSettingsHost(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL); await expectSettingsHeader(page, "Connections");
await expectHostLabelDisplayed(page);
await expectHostConnectionsCard(page, port); await expectHostConnectionsCard(page, port);
});
test("orchestration section shows the inject MCP toggle", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "orchestration");
await expectSettingsHeader(page, "Orchestration");
await expectHostInjectMcpCard(page); await expectHostInjectMcpCard(page);
await expectHostActionCards(page); });
test("providers section shows the providers card", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await expectHostProvidersCard(page, serverId);
await expectSettingsHeader(page, "Providers");
});
test("daemon section shows the host label and restart/remove action cards", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
await expectSettingsHeader(page, "Daemon");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
}); });
test("clicking the label pencil reveals the inline editor", async ({ page }) => { test("clicking the label pencil reveals the inline editor", async ({ page }) => {
@@ -42,13 +75,14 @@ test.describe("Settings host page", () => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await openSettingsHost(page, serverId); await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
await expectHostLabelDisplayed(page); await expectHostLabelDisplayed(page);
await clickEditHostLabel(page); await clickEditHostLabel(page);
await expectHostLabelEditMode(page, TEST_HOST_LABEL); await expectHostLabelEditMode(page, TEST_HOST_LABEL);
}); });
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({ test("daemon section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
page, page,
}) => { }) => {
const serverId = getServerId(); const serverId = getServerId();
@@ -56,19 +90,20 @@ test.describe("Settings host page", () => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await openSettingsHost(page, serverId); await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
// TODO: add local-daemon fixture for positive Pair/Daemon coverage. // TODO: add local-daemon fixture for positive Pair/Daemon coverage.
await expectHostNoLocalOnlyRows(page); await expectHostNoLocalOnlyRows(page);
}); });
test("settings sidebar does not expose retired top-level sections", async ({ page }) => { test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await expectRetiredSidebarSectionsAbsent(page); await expectRetiredSidebarSectionsAbsent(page);
}); });
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({ test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page, page,
}) => { }) => {
const serverId = getServerId(); const serverId = getServerId();
@@ -77,9 +112,10 @@ test.describe("Settings host page", () => {
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`); await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expectHostPageVisible(page, serverId); await expectHostPageVisible(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL); await expectSettingsHeader(page, "Connections");
await openHostSection(page, serverId, "daemon");
await expectHostLabelDisplayed(page); await expectHostLabelDisplayed(page);
await expectHostActionCards(page); await expectHostActionCards(page, serverId);
}); });
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => { test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
+4 -2
View File
@@ -45,7 +45,7 @@ test.describe("Settings sidebar navigation", () => {
await expectGeneralContent(page); await expectGeneralContent(page);
}); });
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => { test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page); await verifyLegacyHostSettingsRedirect(page);
}); });
@@ -132,7 +132,9 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsBackButton(page); await expectSettingsBackButton(page);
}); });
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => { test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({
page,
}) => {
await gotoAppShell(page); await gotoAppShell(page);
await openCompactSettings(page); await openCompactSettings(page);
@@ -32,11 +32,30 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
autoscrollToTopThreshold: 0, autoscrollToTopThreshold: 0,
}); });
const HISTORY_START_THRESHOLD_PX = 96; const HISTORY_START_THRESHOLD_PX = 96;
const SCROLL_TO_INDEX_RETRY_DELAY_MS = 80;
interface NativeScrollToIndexFailedInfo {
index: number;
highestMeasuredFrameIndex: number;
averageItemLength: number;
}
interface NativeScrollIndexFallbackInput {
index: number;
averageItemLength: number;
}
function keyExtractor(item: { id: string }): string { function keyExtractor(item: { id: string }): string {
return item.id; return item.id;
} }
export function getNativeScrollToIndexFallbackOffset(input: NativeScrollIndexFallbackInput) {
if (!Number.isFinite(input.averageItemLength) || input.averageItemLength <= 0) {
return 0;
}
return Math.max(0, input.averageItemLength * input.index);
}
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) { function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
const { const {
agentId, agentId,
@@ -69,6 +88,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}); });
const scrollOffsetYRef = useRef(0); const scrollOffsetYRef = useRef(0);
const programmaticScrollEventBudgetRef = useRef(0); const programmaticScrollEventBudgetRef = useRef(0);
const scrollToIndexRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null); const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false); const historyStartReadyRef = useRef(false);
@@ -87,6 +107,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
} }
}, []); }, []);
const clearScrollToIndexRetry = useCallback(() => {
if (scrollToIndexRetryTimeoutRef.current !== null) {
clearTimeout(scrollToIndexRetryTimeoutRef.current);
scrollToIndexRetryTimeoutRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => { const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling(); clearNativeViewportSettling();
setIsNativeViewportSettling(true); setIsNativeViewportSettling(true);
@@ -149,6 +176,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollToBottom, 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(() => { useEffect(() => {
streamViewportMetricsRef.current = { streamViewportMetricsRef.current = {
containerKey: "native-virtualized", containerKey: "native-virtualized",
@@ -161,6 +219,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}; };
scrollOffsetYRef.current = 0; scrollOffsetYRef.current = 0;
clearNativeViewportSettling(); clearNativeViewportSettling();
clearScrollToIndexRetry();
setIsNativeViewportSettling(false); setIsNativeViewportSettling(false);
historyStartReadyRef.current = false; historyStartReadyRef.current = false;
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
@@ -169,7 +228,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => { return () => {
cancelAnimationFrame(frame); cancelAnimationFrame(frame);
}; };
}, [agentId, clearNativeViewportSettling]); }, [agentId, clearNativeViewportSettling, clearScrollToIndexRetry]);
useEffect(() => { useEffect(() => {
const keyboardEvents = [ const keyboardEvents = [
@@ -190,8 +249,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
subscription.remove(); subscription.remove();
} }
clearNativeViewportSettling(); clearNativeViewportSettling();
clearScrollToIndexRetry();
}; };
}, [clearNativeViewportSettling, markNativeViewportSettling]); }, [clearNativeViewportSettling, clearScrollToIndexRetry, markNativeViewportSettling]);
useEffect(() => { useEffect(() => {
bottomAnchorController.prepareForStickyContentChange(); bottomAnchorController.prepareForStickyContentChange();
@@ -209,6 +269,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
bottomAnchorController.prepareForStickyViewportChange(); bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling(); markNativeViewportSettling();
}, },
scrollToStreamItem: (target) => {
programmaticScrollEventBudgetRef.current = 3;
if (target.source === "liveHead") {
flatListRef.current?.scrollToOffset({
offset: 0,
animated: true,
});
return;
}
scrollToHistoryIndex(target.index);
},
}; };
viewportRef.current = handle; viewportRef.current = handle;
return () => { return () => {
@@ -216,7 +287,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef.current = null; viewportRef.current = null;
} }
}; };
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]); }, [
agentId,
bottomAnchorController,
markNativeViewportSettling,
scrollToHistoryIndex,
viewportRef,
]);
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => { const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
@@ -360,6 +437,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onScroll={handleScroll} onScroll={handleScroll}
scrollEventThrottle={16} scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
onScrollToIndexFailed={handleScrollToIndexFailed}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION} maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={40} initialNumToRender={40}
maxToRenderPerBatch={40} maxToRenderPerBatch={40}
+33 -5
View File
@@ -1,5 +1,4 @@
import React, { import React, {
Fragment,
type CSSProperties, type CSSProperties,
useCallback, useCallback,
useEffect, useEffect,
@@ -67,6 +66,10 @@ function scrollElementToBottom(
}); });
} }
function getStreamItemElementId(itemId: string): string {
return `agent-stream-row-${itemId}`;
}
function syncNearBottom( function syncNearBottom(
scrollContainer: HTMLElement | null, scrollContainer: HTMLElement | null,
onNearBottomChange: (value: boolean) => void, onNearBottomChange: (value: boolean) => void,
@@ -468,6 +471,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
} }
scheduleStickToBottom(); 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; viewportRef.current = handle;
return () => { return () => {
@@ -476,7 +494,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
} }
cancelPendingStickToBottom(); cancelPendingStickToBottom();
}; };
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]); }, [
cancelPendingStickToBottom,
forceStickToBottom,
onNearBottomChange,
rowVirtualizer,
scheduleStickToBottom,
viewportRef,
]);
const contentContainerStyle = useMemo((): CSSProperties => { const contentContainerStyle = useMemo((): CSSProperties => {
return { return {
@@ -520,14 +545,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
); );
const mountedHistoryRows = useMemo(() => { const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => ( return segments.historyMounted.map((item, index) => (
<Fragment key={item.id}> <div id={getStreamItemElementId(item.id)} key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)} {renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment> </div>
)); ));
}, [renderHistoryMountedRow, segments.historyMounted]); }, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => { const liveHeadRows = useMemo(() => {
return segments.liveHead.map((item, index) => ( return segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment> <div id={getStreamItemElementId(item.id)} key={item.id}>
{renderLiveHeadRow(item, index, segments.liveHead)}
</div>
)); ));
}, [renderLiveHeadRow, segments.liveHead]); }, [renderLiveHeadRow, segments.liveHead]);
const liveAuxiliary = useMemo(() => { const liveAuxiliary = useMemo(() => {
@@ -570,6 +597,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
<div <div
key={virtualRow.key} key={virtualRow.key}
data-index={virtualRow.index} data-index={virtualRow.index}
id={getStreamItemElementId(item.id)}
ref={measureVirtualizedRowElement} ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)} style={renderVirtualRowStyle(virtualRow.start)}
> >
@@ -42,6 +42,13 @@ export interface StreamEdgeSlotProps {
export interface StreamViewportHandle { export interface StreamViewportHandle {
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void; prepareForViewportChange: () => void;
scrollToStreamItem: (target: StreamScrollTarget) => void;
}
export interface StreamScrollTarget {
source: "historyVirtualized" | "historyMounted" | "liveHead";
index: number;
itemId: string;
} }
export interface StreamSegmentRenderers { export interface StreamSegmentRenderers {
+240 -94
View File
@@ -36,12 +36,12 @@ import {
CompactionMarker, CompactionMarker,
MessageOuterSpacingProvider, MessageOuterSpacingProvider,
type InlinePathTarget, type InlinePathTarget,
type MessageFindHighlight,
} from "@/components/message"; } from "@/components/message";
import { PlanCard } from "@/components/plan-card"; import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { import type {
AgentPlanAction,
AgentPermissionAction, AgentPermissionAction,
AgentPermissionResponse, AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types"; } from "@getpaseo/protocol/agent-types";
@@ -59,6 +59,11 @@ import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer"; import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
import { layoutStream, type StreamLayoutItem } from "./layout"; import { layoutStream, type StreamLayoutItem } from "./layout";
import {
buildAgentStreamSearchModel,
findAgentStreamSearchMatches,
type AgentStreamSearchMatch,
} from "@/components/agent-stream-search-model";
import { import {
type BottomAnchorLocalRequest, type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest, type BottomAnchorRouteRequest,
@@ -76,6 +81,12 @@ import {
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import {
FindBar,
type PaneFindMatchState,
type UsePaneFindResult,
usePaneFind,
} from "@/panels/pane-find";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -114,86 +125,6 @@ function renderPendingPermissionsNode(input: {
); );
} }
function PlanTimelineCard({
item,
agentId,
client,
}: {
item: Extract<StreamItem, { kind: "plan" }>;
agentId: string;
client: DaemonClient | null;
}) {
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
const respondToPlan = useMutation({
mutationFn: async (action: AgentPlanAction) => {
if (!client) {
throw new Error("No daemon connection");
}
setRespondingActionId(action.id);
const result = await client.respondToPlan(agentId, item.planId, { actionId: action.id });
if (!result.ok) {
throw new Error(result.error ?? "Failed to respond to plan");
}
},
onSettled: () => setRespondingActionId(null),
});
const actions = item.actions ?? [];
return (
<View>
<PlanCard title="Plan" text={item.text} testID="timeline-plan-card" />
{actions.length > 0 ? (
<View style={permissionStyles.optionsContainer}>
{actions.map((action) => (
<PlanActionButton
key={action.id}
action={action}
respondingActionId={respondingActionId}
isResponding={respondToPlan.isPending}
onRespond={respondToPlan.mutate}
/>
))}
</View>
) : null}
</View>
);
}
function PlanActionButton({
action,
respondingActionId,
isResponding,
onRespond,
}: {
action: AgentPlanAction;
respondingActionId: string | null;
isResponding: boolean;
onRespond: (action: AgentPlanAction) => void;
}) {
const Icon = action.variant === "danger" ? ThemedXIcon : ThemedCheckIcon;
const permissionAction = useMemo<AgentPermissionAction>(
() => ({
...action,
behavior: action.variant === "danger" ? "deny" : "allow",
}),
[action],
);
const handlePress = useCallback(() => onRespond(action), [action, onRespond]);
return (
<PermissionActionButton
action={permissionAction}
isRespondingAction={respondingActionId === action.id}
isResponding={isResponding}
isPrimary={action.variant === "primary"}
Icon={Icon}
testID={`plan-action-${action.id}`}
onPress={handlePress}
/>
);
}
function renderStreamItemWithTurnFooter(input: { function renderStreamItemWithTurnFooter(input: {
content: ReactNode; content: ReactNode;
layoutItem: StreamLayoutItem; layoutItem: StreamLayoutItem;
@@ -278,6 +209,69 @@ function renderLiveHeadStreamItem(input: {
return input.renderStreamItem(layoutItem); 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<string, MessageFindHighlight[]> {
const highlightsByItemId = new Map<string, MessageFindHighlight[]>();
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 { export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void; prepareForViewportChange(): void;
@@ -454,6 +448,154 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
streamRenderStrategy, streamRenderStrategy,
], ],
); );
const searchModel = useMemo(
() =>
buildAgentStreamSearchModel({
streamItems,
streamHead: streamHead ?? [],
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
cwd: agent.cwd,
}),
[agent.cwd, isMobile, streamHead, streamItems],
);
const [findState, setFindState] = useState<AgentStreamFindState>(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]);
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
@@ -504,10 +646,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client={client} client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup} isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup} isLastInGroup={layoutItem.isLastInUserGroup}
findHighlights={findHighlightsByItemId.get(item.id)}
/> />
); );
}, },
[agent.capabilities, agentId, client, resolvedServerId], [agent.capabilities, agentId, client, findHighlightsByItemId, resolvedServerId],
); );
const renderAssistantMessageItem = useCallback( const renderAssistantMessageItem = useCallback(
@@ -527,11 +670,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
serverId={resolvedServerId} serverId={resolvedServerId}
client={client} client={client}
spacing={layoutItem.assistantSpacing} spacing={layoutItem.assistantSpacing}
findHighlights={findHighlightsByItemId.get(item.id)}
/> />
</AssistantFileLinkResolverProvider> </AssistantFileLinkResolverProvider>
); );
}, },
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], [
client,
findHighlightsByItemId,
handleInlinePathPress,
resolvedServerId,
toast,
workspaceRoot,
],
); );
const renderThoughtItem = useCallback( const renderThoughtItem = useCallback(
@@ -630,9 +781,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
case "todo_list": case "todo_list":
return <TodoListCard items={item.items} />; return <TodoListCard items={item.items} />;
case "plan":
return <PlanTimelineCard item={item} agentId={agentId} client={client} />;
case "compaction": case "compaction":
return ( return (
<CompactionMarker <CompactionMarker
@@ -646,14 +794,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return null; return null;
} }
}, },
[ [renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
agentId,
client,
renderUserMessageItem,
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
],
); );
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter; const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
@@ -791,6 +932,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return ( return (
<ToolCallSheetProvider> <ToolCallSheetProvider>
<View style={stylesheet.container}> <View style={stylesheet.container}>
<AgentStreamFindBarSlot paneFind={paneFind} />
<MessageOuterSpacingProvider disableOuterSpacing> <MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({ {streamRenderStrategy.render({
agentId, agentId,
@@ -839,6 +981,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent); export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView"; AgentStreamView.displayName = "AgentStreamView";
function AgentStreamFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) {
return paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null;
}
interface ToolCallSlotProps extends Omit< interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>, ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange" "onInlineDetailsExpandedChange"
+2 -1
View File
@@ -867,7 +867,8 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/sessions" /> <Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" /> <Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" /> <Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="settings/hosts/[serverId]" /> <Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
</Stack> </Stack>
); );
} }
@@ -1,16 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsHostRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const view = useMemo(() => ({ kind: "host" as const, serverId }), [serverId]);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -0,0 +1,19 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes";
export default function SettingsHostSectionRoute() {
const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const rawSection = typeof params.hostSection === "string" ? params.hostSection : "";
const section: HostSectionSlug = isHostSectionSlug(rawSection) ? rawSection : "connections";
const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -0,0 +1,13 @@
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildSettingsHostSectionRoute, buildSettingsRoute } from "@/utils/host-routes";
export default function SettingsHostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
if (!serverId) {
return <Redirect href={buildSettingsRoute()} />;
}
return <Redirect href={buildSettingsHostSectionRoute(serverId, "connections")} />;
}
@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import {
buildAgentStreamSearchModel,
findAgentStreamSearchMatches,
} 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 getSearchableText(item: StreamItem): string {
const model = buildAgentStreamSearchModel({
streamItems: [item],
streamHead: [],
platform: "web",
isMobileBreakpoint: true,
});
return model.entries[0]?.text ?? "";
}
describe("buildAgentStreamSearchModel", () => {
it("indexes user and assistant message text", () => {
expect(getSearchableText(userMessage("u1", "user text"))).toBe("user text");
expect(getSearchableText(assistantMessage("a1", "assistant text"))).toBe("assistant text");
});
it("excludes non-message rows that do not render find highlights", () => {
expect(getSearchableText(thought("t1", "thought text"))).toBe("");
expect(getSearchableText(activityLog("l1", "activity text"))).toBe("");
expect(getSearchableText(todoList("todo"))).toBe("");
});
it("excludes tool call rows from the search index", () => {
const toolCall: StreamItem = {
kind: "tool_call",
id: "shell",
timestamp: timestamp(1),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "call-shell",
name: "exec_command",
status: "completed",
error: null,
detail: {
type: "shell",
command: "npm run typecheck",
output: "internal output should stay out",
},
},
},
};
expect(getSearchableText(toolCall)).toBe("");
});
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: [assistantMessage("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"]);
});
it("skips fenced code blocks while preserving message offsets for highlights", () => {
const model = buildAgentStreamSearchModel({
streamItems: [assistantMessage("a1", "before alpha\n```\nalpha\n```\nafter alpha")],
streamHead: [],
platform: "web",
isMobileBreakpoint: true,
});
const matches = findAgentStreamSearchMatches({
model,
query: "alpha",
});
expect(matches.map((match) => match.id)).toEqual(["a1:text:0:7:12", "a1:text:0:33:38"]);
});
});
@@ -0,0 +1,276 @@
import type { StreamItem } from "@/types/stream";
import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "@/agent-stream/web-virtualization";
type AgentStreamSearchSource = "historyVirtualized" | "historyMounted" | "liveHead";
interface AgentStreamSearchTextSegment {
key: string;
text: string;
startOffset: number;
}
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;
}
interface AgentStreamSearchModel {
entries: AgentStreamSearchEntry[];
segments: {
historyVirtualized: AgentStreamSearchEntry[];
historyMounted: AgentStreamSearchEntry[];
liveHead: AgentStreamSearchEntry[];
};
}
interface BuildAgentStreamSearchModelInput {
platform: "web" | "native";
isMobileBreakpoint: boolean;
streamItems: StreamItem[];
streamHead: StreamItem[];
optimisticItems?: StreamItem[];
cwd?: string;
}
interface FindAgentStreamSearchMatchesInput {
model: AgentStreamSearchModel;
query: string;
}
function getFenceDelimiter(line: string): string | null {
const match = /^( {0,3})(`{3,}|~{3,})/.exec(line);
return match?.[2] ?? null;
}
function getMessageSearchableSegments(text: string): AgentStreamSearchTextSegment[] {
const segments: AgentStreamSearchTextSegment[] = [];
let activeFenceCharacter: "`" | "~" | null = null;
let activeFenceLength = 0;
let currentText = "";
let currentStartOffset = 0;
let offset = 0;
const flush = () => {
if (currentText.length > 0) {
segments.push({ key: "text", text: currentText, startOffset: currentStartOffset });
currentText = "";
}
};
for (const line of text.split("\n")) {
const lineWithBreak = offset + line.length < text.length ? `${line}\n` : line;
const fenceDelimiter = getFenceDelimiter(line);
const isClosingFence =
activeFenceCharacter &&
fenceDelimiter?.[0] === activeFenceCharacter &&
fenceDelimiter.length >= activeFenceLength;
const isOpeningFence = !activeFenceCharacter && fenceDelimiter;
const isIndentedCode = !activeFenceCharacter && (/^( {4,}|\t)/.test(line) || line === " ");
if (isOpeningFence || activeFenceCharacter || isIndentedCode) {
flush();
} else {
if (currentText.length === 0) {
currentStartOffset = offset;
}
currentText += lineWithBreak;
}
if (isOpeningFence) {
activeFenceCharacter = fenceDelimiter[0] as "`" | "~";
activeFenceLength = fenceDelimiter.length;
} else if (isClosingFence) {
activeFenceCharacter = null;
activeFenceLength = 0;
}
offset += lineWithBreak.length;
}
flush();
return segments;
}
function getAgentStreamItemSearchableSegments(item: StreamItem): AgentStreamSearchTextSegment[] {
switch (item.kind) {
case "user_message":
case "assistant_message":
return item.text ? getMessageSearchableSegments(item.text) : [];
case "thought":
case "activity_log":
case "todo_list":
case "tool_call":
case "compaction":
return [];
}
}
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);
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;
const absoluteStart = segment.startOffset + start;
const absoluteEnd = segment.startOffset + end;
matches.push({
id: `${entry.item.id}:${segment.key}:${occurrenceIndex}:${absoluteStart}:${absoluteEnd}`,
entry,
segmentKey: segment.key,
occurrenceIndex,
start: absoluteStart,
end: absoluteEnd,
});
occurrenceIndex += 1;
fromIndex = end;
}
}
}
return matches;
}
@@ -0,0 +1,492 @@
/**
* @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 { BrowserPane } from "@/components/browser-pane.electron";
import {
PaneFocusProvider,
PaneProvider,
createPaneFocusContextValue,
type PaneContextValue,
} from "@/panels/pane-context";
import {
createPaneFindPaneId,
handlePaneFindKeyboardAction,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
const updateBrowser = vi.fn();
let browserState = {
browsersById: {
"browser-a": {
id: "browser-a",
url: "https://example.com",
title: "",
faviconUrl: null,
canGoBack: false,
canGoForward: false,
isLoading: false,
lastError: null,
},
},
updateBrowser,
};
const { theme } = vi.hoisted(() => ({
theme: {
borderRadius: { md: 6 },
colors: {
accent: "#3b82f6",
border: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
palette: { red: { 500: "#ef4444" } },
surface0: "#111",
surface1: "#222",
surface2: "#333",
},
fontSize: { sm: 13, xs: 11 },
spacing: { 1: 4, 2: 8 },
},
}));
const desktopBridge = vi.hoisted(() => {
const foundInPageListeners = new Set<(result: unknown) => void>();
return {
foundInPageListeners,
findInPage: vi.fn<(browserId: string, text: string, options?: unknown) => number>(),
setActivePane: vi.fn<(browserId: string | null) => Promise<void>>(),
stopFindInPage: vi.fn<(browserId: string, action: string) => void>(),
onFoundInPage: vi.fn((_browserId: string, listener: (result: unknown) => void) => {
const scopedListener = (result: unknown) => listener(result);
foundInPageListeners.add(scopedListener);
return () => {
foundInPageListeners.delete(scopedListener);
};
}),
eventsOn: vi.fn(),
};
});
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) =>
function Icon() {
return React.createElement("span", { "data-icon": name });
};
return {
ArrowLeft: createIcon("ArrowLeft"),
ArrowRight: createIcon("ArrowRight"),
ChevronDown: createIcon("ChevronDown"),
ChevronUp: createIcon("ChevronUp"),
MousePointer2: createIcon("MousePointer2"),
RotateCw: createIcon("RotateCw"),
X: createIcon("X"),
};
});
vi.mock("react-native", () => {
const MockView = ({
children,
testID,
style,
}: {
children?: React.ReactNode;
testID?: string;
style?: unknown;
}) => React.createElement("div", { "data-testid": testID, style }, 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;
onFocus?: () => void;
onKeyPress?: (event: {
nativeEvent: { key: string; shiftKey?: boolean };
preventDefault: () => void;
}) => void;
onSubmitEditing?: () => void;
testID?: string;
placeholder?: string;
}
>(function TextInput(
{ value, onChangeText, onFocus, onKeyPress, onSubmitEditing, testID, placeholder },
ref,
) {
return React.createElement("input", {
"data-testid": testID,
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
onChangeText?.(event.currentTarget.value),
onFocus,
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
onKeyPress?.({
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
preventDefault: () => event.preventDefault(),
});
if (event.key === "Enter") {
onSubmitEditing?.();
}
},
placeholder,
ref,
value: value ?? "",
});
});
return {
Platform: {
OS: "web",
select: (options: Record<string, unknown>) => options.web ?? options.default,
},
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("@/attachments/workspace-attachments-store", () => ({
buildWorkspaceAttachmentScopeKey: () => "scope-a",
useWorkspaceAttachments: () => [],
useWorkspaceAttachmentsStore: (
selector: (state: { setWorkspaceAttachments: () => void }) => unknown,
) => selector({ setWorkspaceAttachments: vi.fn() }),
}));
vi.mock("@/desktop/host", () => ({
getDesktopHost: () => ({
browser: {
findInPage: desktopBridge.findInPage,
onFoundInPage: desktopBridge.onFoundInPage,
setActivePane: desktopBridge.setActivePane,
stopFindInPage: desktopBridge.stopFindInPage,
},
events: { on: desktopBridge.eventsOn },
}),
isElectronRuntime: () => true,
}));
vi.mock("@/constants/layout", () => ({
WORKSPACE_SECONDARY_HEADER_HEIGHT: 36,
}));
vi.mock("@/stores/browser-store", () => ({
normalizeWorkspaceBrowserUrl: (url: string) => url,
useBrowserStore: (selector: (state: typeof browserState) => unknown) => selector(browserState),
}));
type FakeWebview = HTMLDivElement & {
getURL: ReturnType<typeof vi.fn<() => string>>;
canGoBack: ReturnType<typeof vi.fn<() => boolean>>;
canGoForward: ReturnType<typeof vi.fn<() => boolean>>;
reload: ReturnType<typeof vi.fn<() => void>>;
stop: ReturnType<typeof vi.fn<() => void>>;
};
let container: HTMLDivElement | null = null;
let root: Root | null = null;
let webview: FakeWebview | null = null;
let nextRequestId = 1;
let restoreCreateElement: (() => void) | null = null;
const paneInstanceId = createPaneFindPaneId({
serverId: "server-a",
workspaceId: "workspace-a",
paneId: "pane-a",
});
const paneContextValue: PaneContextValue = {
serverId: "server-a",
workspaceId: "workspace-a",
paneInstanceId,
tabId: "browser_browser-a",
target: { kind: "browser", browserId: "browser-a" },
openTab: vi.fn(),
closeCurrentTab: vi.fn(),
retargetCurrentTab: vi.fn(),
openFileInWorkspace: vi.fn(),
openImportSheet: vi.fn(),
};
function installWebviewElementFactory(): void {
const originalCreateElement = document.createElement.bind(document);
const createElementSpy = vi.spyOn(document, "createElement");
const createElement = ((tagName: string, options?: ElementCreationOptions) => {
if (tagName.toLowerCase() !== "webview") {
return originalCreateElement(tagName, options);
}
const element = originalCreateElement("div") as FakeWebview;
element.getURL = vi.fn(() => "https://example.com");
element.canGoBack = vi.fn(() => false);
element.canGoForward = vi.fn(() => false);
element.reload = vi.fn();
element.stop = vi.fn();
webview = element;
return element;
}) as typeof document.createElement;
createElementSpy.mockImplementation(createElement);
restoreCreateElement = () => createElementSpy.mockRestore();
}
function renderBrowserPane(input?: { isInteractive?: boolean }): void {
act(() => {
root?.render(
<PaneProvider value={paneContextValue}>
<PaneFocusProvider
value={createPaneFocusContextValue({
isPaneFocused: input?.isInteractive ?? true,
isWorkspaceFocused: true,
})}
>
<BrowserPane
browserId="browser-a"
serverId="server-a"
workspaceId="workspace-a"
cwd="/repo"
isInteractive={input?.isInteractive ?? true}
/>
</PaneFocusProvider>
</PaneProvider>,
);
});
}
function openFind(): void {
act(() => {
setActivePaneFindPaneId(paneInstanceId);
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
}
function markWebviewDomReady(): void {
act(() => {
webview?.dispatchEvent(new Event("dom-ready"));
});
}
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 pressKey(key: string, shiftKey = false): void {
const input = inputElement();
act(() => {
input.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 dispatchFoundInPage(result: {
requestId?: number;
activeMatchOrdinal: number;
matches: number;
}): void {
act(() => {
for (const listener of desktopBridge.foundInPageListeners) {
listener(result);
}
});
}
beforeEach(() => {
updateBrowser.mockClear();
desktopBridge.findInPage.mockImplementation(() => nextRequestId++);
desktopBridge.findInPage.mockClear();
desktopBridge.onFoundInPage.mockClear();
desktopBridge.setActivePane.mockClear();
desktopBridge.stopFindInPage.mockClear();
desktopBridge.eventsOn.mockClear();
desktopBridge.foundInPageListeners.clear();
browserState = {
...browserState,
browsersById: {
"browser-a": {
id: "browser-a",
url: "https://example.com",
title: "",
faviconUrl: null,
canGoBack: false,
canGoForward: false,
isLoading: false,
lastError: null,
},
},
};
nextRequestId = 1;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
installWebviewElementFactory();
});
afterEach(() => {
act(() => {
root?.unmount();
});
root = null;
container?.remove();
container = null;
webview = null;
restoreCreateElement?.();
restoreCreateElement = null;
});
describe("BrowserPane Electron find", () => {
it("registers browser find through the shared FindBar and desktop bridge find APIs", () => {
renderBrowserPane();
markWebviewDomReady();
openFind();
changeInput("needle");
expect(desktopBridge.onFoundInPage).toHaveBeenCalledWith("browser-a", expect.any(Function));
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
forward: true,
findNext: false,
matchCase: false,
});
expect(container?.textContent).toContain("Searching...");
dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 2, matches: 5 });
expect(container?.textContent).toContain("2 / 5");
pressKey("Enter");
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
forward: true,
findNext: true,
matchCase: false,
});
dispatchFoundInPage({ requestId: 2, activeMatchOrdinal: 3, matches: 5 });
pressKey("Enter", true);
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
forward: false,
findNext: true,
matchCase: false,
});
click("pane-find-close");
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
});
it("cleans browser find selection on empty query, navigation, blur, and unmount", () => {
renderBrowserPane();
markWebviewDomReady();
openFind();
changeInput("needle");
dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 1, matches: 3 });
changeInput("");
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
expect(container?.textContent).toContain("0 / 0");
dispatchFoundInPage({ activeMatchOrdinal: 2, matches: 9 });
expect(container?.textContent).toContain("0 / 0");
changeInput("needle");
expect(container?.textContent).toContain("Searching...");
act(() => {
webview?.dispatchEvent(new Event("did-start-loading"));
});
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
expect(container?.textContent).toContain("0 / 0");
act(() => {
webview?.dispatchEvent(new Event("dom-ready"));
});
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
forward: true,
findNext: false,
matchCase: false,
});
renderBrowserPane({ isInteractive: false });
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
act(() => {
root?.unmount();
});
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
expect(desktopBridge.foundInPageListeners.size).toBe(0);
});
it("does not call the browser find bridge before dom-ready", () => {
renderBrowserPane();
openFind();
changeInput("needle");
expect(container?.textContent).toContain("Searching...");
expect(desktopBridge.findInPage).not.toHaveBeenCalled();
click("pane-find-close");
expect(desktopBridge.stopFindInPage).not.toHaveBeenCalled();
openFind();
changeInput("needle");
markWebviewDomReady();
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
forward: true,
findNext: false,
matchCase: false,
});
});
});
@@ -1,4 +1,4 @@
import { import React, {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@@ -20,9 +20,12 @@ import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { import {
getDesktopHost, getDesktopHost,
isElectronRuntime, isElectronRuntime,
type DesktopBrowserFoundInPageResult,
type DesktopBrowserFindAction,
type DesktopBrowserShortcutEvent, type DesktopBrowserShortcutEvent,
} from "@/desktop/host"; } from "@/desktop/host";
import { isDev } from "@/constants/platform"; import { isDev } from "@/constants/platform";
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store"; import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
type ElectronWebview = HTMLElement & { type ElectronWebview = HTMLElement & {
@@ -50,6 +53,15 @@ type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted"> & {
const ERR_ABORTED = -3; const ERR_ABORTED = -3;
const ALLOWED_BROWSER_PROTOCOLS = new Set(["http:", "https:"]); const ALLOWED_BROWSER_PROTOCOLS = new Set(["http:", "https:"]);
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" };
interface ActiveBrowserFind {
generation: number;
query: string;
requestId: number | null;
}
function truncateText(value: string, maxLength: number): string { function truncateText(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value; return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value;
@@ -234,6 +246,18 @@ function isDesktopBrowserShortcutEvent(payload: unknown): payload is DesktopBrow
return event.action === "focus-url"; return event.action === "focus-url";
} }
function stopBrowserFindInPage(input: {
browserId: string;
action: DesktopBrowserFindAction;
}): void {
const bridge = getDesktopHost()?.browser;
if (!bridge?.stopFindInPage) {
console.warn("Electron browser find bridge is unavailable; cannot stop find-in-page.");
return;
}
void bridge.stopFindInPage(input.browserId, input.action);
}
function startSelectorResultPolling(input: { function startSelectorResultPolling(input: {
webview: ElectronWebview; webview: ElectronWebview;
onSelection: (selection: BrowserElementSelection) => void; onSelection: (selection: BrowserElementSelection) => void;
@@ -295,7 +319,13 @@ export function BrowserPane({
browserRef.current = browser; browserRef.current = browser;
const pendingNavigationUrlRef = useRef<string | null>(null); const pendingNavigationUrlRef = useRef<string | null>(null);
const domReadyRef = useRef(false); const domReadyRef = useRef(false);
const browserFindQueryRef = useRef("");
const browserFindGenerationRef = useRef(0);
const activeBrowserFindRef = useRef<ActiveBrowserFind | null>(null);
const browserFindOpenRef = useRef(false);
const [selectorActive, setSelectorActive] = useState(false); const [selectorActive, setSelectorActive] = useState(false);
const [browserFindMatchState, setBrowserFindMatchState] =
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
const [draftUrl, setDraftUrl] = useState(browser?.url ?? "https://example.com"); const [draftUrl, setDraftUrl] = useState(browser?.url ?? "https://example.com");
const workspaceAttachmentScopeKey = useMemo( const workspaceAttachmentScopeKey = useMemo(
() => buildBrowserAttachmentScopeKey({ cwd, serverId, workspaceId }), () => buildBrowserAttachmentScopeKey({ cwd, serverId, workspaceId }),
@@ -372,6 +402,115 @@ export function BrowserPane({
} }
}, []); }, []);
const clearBrowserFindSelection = useCallback((input?: { resetQuery?: boolean }) => {
if (input?.resetQuery) {
browserFindQueryRef.current = "";
}
browserFindGenerationRef.current += 1;
activeBrowserFindRef.current = null;
setBrowserFindMatchState(EMPTY_FIND_MATCH_STATE);
if (domReadyRef.current) {
stopBrowserFindInPage({
browserId: browserIdRef.current,
action: "clearSelection",
});
}
}, []);
const runBrowserFind = useCallback(
(query: string, direction: "next" | "previous", input?: { reset?: boolean }) => {
browserFindQueryRef.current = query;
if (!query) {
clearBrowserFindSelection({ resetQuery: true });
return;
}
if (!domReadyRef.current) {
setBrowserFindMatchState(PENDING_FIND_MATCH_STATE);
return;
}
const bridgeFindInPage = getDesktopHost()?.browser?.findInPage;
if (!bridgeFindInPage) {
console.warn("Electron browser find bridge is unavailable; cannot start find-in-page.");
setBrowserFindMatchState(NO_FIND_MATCH_STATE);
return;
}
const generation = (browserFindGenerationRef.current += 1);
activeBrowserFindRef.current = {
generation,
query,
requestId: null,
};
setBrowserFindMatchState(PENDING_FIND_MATCH_STATE);
const options = {
forward: direction === "next",
findNext: !input?.reset,
matchCase: false,
};
const requestIdResult = bridgeFindInPage(browserIdRef.current, query, options);
if (typeof requestIdResult === "number") {
activeBrowserFindRef.current = {
generation,
query,
requestId: requestIdResult,
};
return;
}
void Promise.resolve(requestIdResult ?? null).then((requestId) => {
const activeFind = activeBrowserFindRef.current;
if (
!activeFind ||
activeFind.generation !== generation ||
activeFind.query !== browserFindQueryRef.current
) {
return undefined;
}
activeBrowserFindRef.current = {
...activeFind,
requestId: typeof requestId === "number" ? requestId : null,
};
return undefined;
});
},
[clearBrowserFindSelection],
);
const handleFoundInPageResult = useCallback((result: DesktopBrowserFoundInPageResult) => {
const activeFind = activeBrowserFindRef.current;
if (
!activeFind ||
activeFind.generation !== browserFindGenerationRef.current ||
activeFind.query !== browserFindQueryRef.current
) {
return;
}
const requestId = result.requestId;
if (typeof requestId !== "number") {
return;
}
if (typeof activeFind.requestId !== "number" || requestId !== activeFind.requestId) {
return;
}
const total = typeof result.matches === "number" ? Math.max(0, result.matches) : 0;
if (total === 0) {
setBrowserFindMatchState(NO_FIND_MATCH_STATE);
return;
}
const current =
typeof result.activeMatchOrdinal === "number" && result.activeMatchOrdinal > 0
? result.activeMatchOrdinal
: 1;
setBrowserFindMatchState({
status: "matched",
current: Math.min(current, total),
total,
});
}, []);
useEffect(() => { useEffect(() => {
if (!isElectronRuntime()) { if (!isElectronRuntime()) {
return; return;
@@ -403,6 +542,7 @@ export function BrowserPane({
webview.style.background = "transparent"; webview.style.background = "transparent";
const handleStartLoading = () => { const handleStartLoading = () => {
clearBrowserFindSelection();
updateBrowser(browserId, { isLoading: true, lastError: null }); updateBrowser(browserId, { isLoading: true, lastError: null });
syncNavigationState({ syncUrl: false }); syncNavigationState({ syncUrl: false });
}; };
@@ -429,6 +569,7 @@ export function BrowserPane({
syncNavigationState(); syncNavigationState();
}; };
const handleWillNavigate = (event: Event) => { const handleWillNavigate = (event: Event) => {
clearBrowserFindSelection();
const nextUrl = const nextUrl =
typeof (event as Event & { url?: unknown }).url === "string" typeof (event as Event & { url?: unknown }).url === "string"
? ((event as Event & { url?: string }).url ?? "") ? ((event as Event & { url?: string }).url ?? "")
@@ -471,6 +612,9 @@ export function BrowserPane({
const handleDomReady = () => { const handleDomReady = () => {
domReadyRef.current = true; domReadyRef.current = true;
syncNavigationState(); syncNavigationState();
if (browserFindOpenRef.current && browserFindQueryRef.current) {
runBrowserFind(browserFindQueryRef.current, "next", { reset: true });
}
}; };
const handleWebviewFocus = () => { const handleWebviewFocus = () => {
onFocusPane?.(); onFocusPane?.();
@@ -488,6 +632,29 @@ export function BrowserPane({
webview.addEventListener("focus", handleWebviewFocus); webview.addEventListener("focus", handleWebviewFocus);
webview.addEventListener("mousedown", handleWebviewFocus); webview.addEventListener("mousedown", handleWebviewFocus);
const foundInPageBridge = getDesktopHost()?.browser?.onFoundInPage;
let unsubscribeFoundInPage: (() => void) | null = null;
let didCleanupFoundInPage = false;
if (foundInPageBridge) {
const unsubscribeResult = foundInPageBridge(browserId, (result) => {
handleFoundInPageResult(result);
});
if (typeof unsubscribeResult === "function") {
unsubscribeFoundInPage = unsubscribeResult;
} else {
void Promise.resolve(unsubscribeResult).then((unsubscribe) => {
if (didCleanupFoundInPage) {
unsubscribe();
return undefined;
}
unsubscribeFoundInPage = unsubscribe;
return undefined;
});
}
} else {
console.warn("Electron browser find bridge is unavailable; found-in-page events disabled.");
}
host.appendChild(webview); host.appendChild(webview);
if (initialUnsafeNavigationMessage) { if (initialUnsafeNavigationMessage) {
updateBrowserRef.current(browserIdRef.current, { updateBrowserRef.current(browserIdRef.current, {
@@ -506,8 +673,16 @@ export function BrowserPane({
webview.removeEventListener("page-favicon-updated", handleFaviconUpdated); webview.removeEventListener("page-favicon-updated", handleFaviconUpdated);
webview.removeEventListener("did-fail-load", handleLoadFailed); webview.removeEventListener("did-fail-load", handleLoadFailed);
webview.removeEventListener("dom-ready", handleDomReady); webview.removeEventListener("dom-ready", handleDomReady);
didCleanupFoundInPage = true;
unsubscribeFoundInPage?.();
webview.removeEventListener("focus", handleWebviewFocus); webview.removeEventListener("focus", handleWebviewFocus);
webview.removeEventListener("mousedown", handleWebviewFocus); webview.removeEventListener("mousedown", handleWebviewFocus);
if (domReadyRef.current) {
stopBrowserFindInPage({
browserId: browserIdRef.current,
action: "clearSelection",
});
}
if (host.contains(webview)) { if (host.contains(webview)) {
host.removeChild(webview); host.removeChild(webview);
} }
@@ -519,6 +694,35 @@ export function BrowserPane({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [browserId, onFocusPane]); }, [browserId, onFocusPane]);
const paneFind = usePaneFind({
matchState: browserFindMatchState,
onQuery: (query) => runBrowserFind(query, "next", { reset: true }),
onNext: () => runBrowserFind(browserFindQueryRef.current, "next"),
onPrev: () => runBrowserFind(browserFindQueryRef.current, "previous"),
onClose: () => clearBrowserFindSelection({ resetQuery: true }),
});
useEffect(() => {
browserFindOpenRef.current = paneFind.isOpen;
if (!paneFind.isOpen) {
clearBrowserFindSelection({ resetQuery: true });
}
}, [clearBrowserFindSelection, paneFind.isOpen]);
useEffect(() => {
if (isInteractive) {
if (paneFind.isOpen && browserFindQueryRef.current) {
runBrowserFind(browserFindQueryRef.current, "next", { reset: true });
}
return;
}
clearBrowserFindSelection();
}, [clearBrowserFindSelection, isInteractive, paneFind.isOpen, runBrowserFind]);
useEffect(() => {
return () => clearBrowserFindSelection({ resetQuery: true });
}, [clearBrowserFindSelection]);
const navigate = useCallback((nextUrl: string) => { const navigate = useCallback((nextUrl: string) => {
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl); const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
const webview = webviewRef.current; const webview = webviewRef.current;
@@ -939,6 +1143,7 @@ export function BrowserPane({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<View style={styles.chromeRow}> <View style={styles.chromeRow}>
<View style={styles.chromeLeft}> <View style={styles.chromeLeft}>
<Pressable <Pressable
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
createFilePaneFindTokenSegments,
createFilePaneLineFindHighlightMap,
createFilePaneTextRenderData,
findFilePaneTextMatches,
} from "@/components/file-pane-text-render-data";
type FilePaneTextLineRenderData = ReturnType<typeof createFilePaneTextRenderData>["lines"][number];
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 },
]);
});
});
@@ -0,0 +1,198 @@
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
interface FilePaneTextLineRenderData {
lineNumber: number;
text: string;
tokens: HighlightToken[];
}
interface FilePaneTextRenderData {
lines: FilePaneTextLineRenderData[];
searchableText: string;
}
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<number, FilePaneFindLineHighlight[]> {
const highlightsByLine = new Map<number, FilePaneFindLineHighlight[]>();
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;
}
@@ -0,0 +1,435 @@
/**
* @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, code: 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 flattenMockStyle = (value: unknown): React.CSSProperties => {
if (Array.isArray(value)) {
return value.reduce<React.CSSProperties>(
(acc, item) => Object.assign(acc, flattenMockStyle(item)),
{},
);
}
return (value as React.CSSProperties | null | undefined) ?? {};
};
const MockText = ({ children, style }: { children?: React.ReactNode; style?: unknown }) =>
React.createElement("span", { style: flattenMockStyle(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<HTMLInputElement>) =>
onChangeText?.(event.currentTarget.value),
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) =>
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<string, unknown>) => 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("@/styles/syntax-token-styles", () => ({
syntaxTokenStyleFor: () => ({ color: "#fff" }),
}));
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: () => {},
openImportSheet: () => {},
};
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(location: { path: string } = { path: "src/example.ts" }) {
act(() => {
root?.render(
<PaneProvider value={paneContext}>
<PaneFocusProvider value={paneFocus}>
<FilePane serverId="server" workspaceRoot="/repo" location={location} />
</PaneFocusProvider>
</PaneProvider>,
);
});
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>): 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({ path: "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({ path: "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({ path: "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);
});
});
+578 -151
View File
@@ -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 { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display"; 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 { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; 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 { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets"; 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 { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes } from "@/attachments/service"; import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils"; 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 { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target"; import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open"; import type { WorkspaceFileLocation } from "@/workspace/file-open";
import {
FindBar,
type PaneFindMatchState,
type UsePaneFindResult,
usePaneFind,
} from "@/panels/pane-find";
interface CodeLineProps { interface CodeLineProps {
tokens: HighlightToken[]; segments: FilePaneFindTokenSegment[];
lineNumber: number; lineNumber: number;
gutterWidth: number; gutterWidth: number;
highlighted: boolean; highlighted: boolean;
matchBackgroundColor: string;
currentMatchBackgroundColor: string;
onLineRef?: (lineNumber: number, node: View | null) => void;
} }
interface FilePreviewBodyProps { interface FilePreviewBodyProps {
@@ -46,6 +64,50 @@ interface FilePreviewBodyProps {
imagePreviewUri: string | null; imagePreviewUri: string | null;
} }
interface FilePaneTextScrollRefs {
lineRefs: React.MutableRefObject<Map<number, View>>;
previewScrollRef: React.RefObject<RNScrollView | null>;
registerLineRef: (lineNumber: number, node: View | null) => void;
}
interface FilePaneCenterStateProps {
children: React.ReactNode;
}
interface FilePaneTextPreviewProps {
currentMatchBackgroundColor: string;
findHighlightsByLine: Map<number, FilePaneFindLineHighlight[]>;
gutterWidth: number;
isMarkdownFile: boolean;
isMobile: boolean;
lineSelection: FileLineSelection | null;
markdownParser: ReturnType<typeof MarkdownIt>;
markdownStyles: ReturnType<typeof createMarkdownStyles>;
matchBackgroundColor: string;
preview: ExplorerFile;
previewScrollRef: React.RefObject<RNScrollView | null>;
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
showDesktopWebScrollbar: boolean;
textRenderData: ReturnType<typeof createFilePaneTextRenderData> | null;
textScrollRefs: FilePaneTextScrollRefs;
webScrollbarStyle: object;
}
interface FilePaneSearchableTextPreviewProps extends Omit<
FilePaneTextPreviewProps,
"findHighlightsByLine" | "textRenderData"
> {
textRenderData: ReturnType<typeof createFilePaneTextRenderData>;
}
interface FilePaneImagePreviewProps {
imagePreviewUri: string | null;
imageSource: { uri: string } | null;
previewScrollRef: React.RefObject<RNScrollView | null>;
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
showDesktopWebScrollbar: boolean;
}
function trimNonEmpty(value: string | null | undefined): string | null { function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") { if (typeof value !== "string") {
return null; return null;
@@ -117,11 +179,20 @@ function clampLineSelection(input: {
} }
const CodeLine = React.memo(function CodeLine({ const CodeLine = React.memo(function CodeLine({
tokens, segments,
lineNumber, lineNumber,
gutterWidth, gutterWidth,
highlighted, highlighted,
matchBackgroundColor,
currentMatchBackgroundColor,
onLineRef,
}: CodeLineProps) { }: CodeLineProps) {
const setLineRef = useCallback(
(node: View | null) => {
onLineRef?.(lineNumber, node);
},
[lineNumber, onLineRef],
);
const gutterStyle = useMemo( const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })], () => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth], [gutterWidth],
@@ -131,19 +202,28 @@ const CodeLine = React.memo(function CodeLine({
[highlighted], [highlighted],
); );
const keyedTokens = useMemo( const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), () => segments.map((segment, index) => ({ key: `${index}-${segment.text}`, segment })),
[tokens], [segments],
); );
return ( return (
<View style={lineStyle}> <View ref={setLineRef} style={lineStyle}>
<View style={gutterStyle}> <View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}> <Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)} {String(lineNumber)}
</Text> </Text>
</View> </View>
<Text selectable style={codeLineStyles.lineText}> <Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => ( {keyedTokens.map(({ key, segment }) => (
<CodeLineToken key={key} token={token} /> <CodeLineToken
key={key}
backgroundColor={getFindSegmentBackgroundColor({
currentMatchBackgroundColor,
matchBackgroundColor,
segment,
})}
style={segment.style}
text={segment.text}
/>
))} ))}
</Text> </Text>
</View> </View>
@@ -151,11 +231,237 @@ const CodeLine = React.memo(function CodeLine({
}); });
interface CodeLineTokenProps { interface CodeLineTokenProps {
token: HighlightToken; backgroundColor?: string;
style: HighlightToken["style"];
text: string;
} }
function CodeLineToken({ token }: CodeLineTokenProps) { function CodeLineToken({ backgroundColor, style, text }: CodeLineTokenProps) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>; const tokenStyle = useMemo(
() => [
style ? syntaxTokenStyleFor(style) : undefined,
backgroundColor ? { backgroundColor } : null,
],
[backgroundColor, style],
);
return <Text style={tokenStyle}>{text}</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<RNScrollView>(null);
const lineRefs = useRef(new Map<number, View>());
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<Map<number, View>>;
previewScrollRef: React.RefObject<RNScrollView | null>;
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<Map<number, View>>;
previewScrollRef: React.RefObject<RNScrollView | null>;
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<typeof createFilePaneTextRenderData> | null;
textScrollRefs: FilePaneTextScrollRefs;
}) {
const [findState, setFindState] = useState<FilePaneFindState>(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) => ({ const codeLineStyles = StyleSheet.create((theme) => ({
@@ -186,6 +492,175 @@ const codeLineStyles = StyleSheet.create((theme) => ({
}, },
})); }));
function FilePaneFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) {
return paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null;
}
function FilePaneCenterState({ children }: FilePaneCenterStateProps) {
return <View style={styles.centerState}>{children}</View>;
}
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 (
<>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</RNScrollView>
{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 = (
<View>
{keyedLines.map(({ key, line }) => (
<CodeLine
key={key}
segments={createFilePaneFindTokenSegments(
line,
findHighlightsByLine.get(line.lineNumber) ?? [],
)}
lineNumber={line.lineNumber}
gutterWidth={gutterWidth}
highlighted={isLineSelected(lineSelection, line.lineNumber)}
matchBackgroundColor={matchBackgroundColor}
currentMatchBackgroundColor={currentMatchBackgroundColor}
onLineRef={textScrollRefs.registerLineRef}
/>
))}
</View>
);
return (
<>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
{scrollbar.overlay}
</>
);
}
function FilePaneSearchableTextPreview(props: FilePaneSearchableTextPreviewProps) {
const { findHighlightsByLine, paneFind } = useFilePaneFindAdapter({
textRenderData: props.textRenderData,
textScrollRefs: props.textScrollRefs,
});
return (
<>
<FilePaneFindBarSlot paneFind={paneFind} />
<FilePaneTextPreview {...props} findHighlightsByLine={findHighlightsByLine} />
</>
);
}
function FilePaneImagePreview({
imagePreviewUri,
imageSource,
previewScrollRef,
scrollbar,
showDesktopWebScrollbar,
}: FilePaneImagePreviewProps) {
if (!imagePreviewUri) {
return (
<FilePaneCenterState>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</FilePaneCenterState>
);
}
return (
<>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<RNImage
source={imageSource ?? undefined}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
{scrollbar.overlay}
</>
);
}
function FilePreviewBody({ function FilePreviewBody({
preview, preview,
isLoading, isLoading,
@@ -196,40 +671,50 @@ function FilePreviewBody({
}: FilePreviewBodyProps) { }: FilePreviewBodyProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const filePath = location.path; 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 markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile = const isMarkdownFile =
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart; preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
const previewScrollRef = useRef<RNScrollView>(null); const fallbackScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle(); const webScrollbarStyle = useWebScrollbarStyle();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => { const textRenderData = useMemo(() => {
if (!preview || preview.kind !== "text" || isMarkdownFile) { if (!preview || preview.kind !== "text" || isMarkdownFile) {
return null; return null;
} }
return highlightCode(preview.content ?? "", filePath); return createFilePaneTextRenderData(preview.content ?? "", filePath);
}, [isMarkdownFile, preview, 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 gutterWidth = useMemo(() => { const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0; if (!textRenderData) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code); return lineNumberGutterWidth(textRenderData.lines.length, theme.fontSize.code);
}, [highlightedLines, theme.fontSize.code]); }, [textRenderData, theme.fontSize.code]);
const lineHeight = theme.fontSize.code * 1.45; const lineHeight = theme.fontSize.code * 1.45;
const lineSelection = useMemo(() => { const lineSelection = useMemo(() => {
if (!highlightedLines) { if (!textRenderData) {
return null; return null;
} }
return clampLineSelection({ return clampLineSelection({
lineStart: location.lineStart, lineStart: location.lineStart,
lineEnd: location.lineEnd, lineEnd: location.lineEnd,
lineCount: highlightedLines.length, lineCount: textRenderData.lines.length,
}); });
}, [highlightedLines, location.lineEnd, location.lineStart]); }, [textRenderData, location.lineEnd, location.lineStart]);
const imageSource = useMemo( const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null), () => (imagePreviewUri ? { uri: imagePreviewUri } : null),
@@ -247,141 +732,83 @@ function FilePreviewBody({
}); });
}, 0); }, 0);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [lineHeight, lineSelection]); }, [lineHeight, lineSelection, previewScrollRef]);
let content: React.ReactNode;
if (isLoading && !preview) { if (isLoading && !preview) {
return ( content = (
<View style={styles.centerState}> <FilePaneCenterState>
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text> <Text style={styles.loadingText}>Loading file…</Text>
</View> </FilePaneCenterState>
); );
} } else if (!preview) {
content = (
if (!preview) { <FilePaneCenterState>
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>No preview available</Text> <Text style={styles.emptyText}>No preview available</Text>
</View> </FilePaneCenterState>
);
} else if (preview.kind === "text" && textRenderData) {
content = (
<FilePaneSearchableTextPreview
currentMatchBackgroundColor={currentMatchBackgroundColor}
gutterWidth={gutterWidth}
isMarkdownFile={isMarkdownFile}
isMobile={isMobile}
lineSelection={lineSelection}
markdownParser={markdownParser}
markdownStyles={markdownStyles}
matchBackgroundColor={matchBackgroundColor}
preview={preview}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
textRenderData={textRenderData}
textScrollRefs={textScrollRefs}
webScrollbarStyle={webScrollbarStyle}
/>
);
} else if (preview.kind === "text") {
content = (
<FilePaneTextPreview
currentMatchBackgroundColor={currentMatchBackgroundColor}
findHighlightsByLine={new Map()}
gutterWidth={gutterWidth}
isMarkdownFile={isMarkdownFile}
isMobile={isMobile}
lineSelection={lineSelection}
markdownParser={markdownParser}
markdownStyles={markdownStyles}
matchBackgroundColor={matchBackgroundColor}
preview={preview}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
textRenderData={textRenderData}
textScrollRefs={textScrollRefs}
webScrollbarStyle={webScrollbarStyle}
/>
);
} else if (preview.kind === "image") {
content = (
<FilePaneImagePreview
imagePreviewUri={imagePreviewUri}
imageSource={imageSource}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
/>
);
} else {
content = (
<FilePaneCenterState>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</FilePaneCenterState>
); );
} }
if (preview.kind === "text") { return <View style={styles.previewScrollContainer}>{content}</View>;
if (isMarkdownFile) {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const keyedLines = lines.map((tokens, index) => ({
key: `line-${index}`,
tokens,
lineNumber: index + 1,
}));
const codeLines = (
<View>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={key}
tokens={tokens}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
highlighted={
Boolean(lineSelection) &&
lineNumber >= (lineSelection?.lineStart ?? 0) &&
lineNumber <= (lineSelection?.lineEnd ?? 0)
}
/>
))}
</View>
);
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
if (preview.kind === "image") {
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
);
}
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<RNImage
source={imageSource ?? undefined}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
} }
export function FilePane({ export function FilePane({
+198 -18
View File
@@ -124,6 +124,7 @@ interface UserMessageProps {
isFirstInGroup?: boolean; isFirstInGroup?: boolean;
isLastInGroup?: boolean; isLastInGroup?: boolean;
disableOuterSpacing?: boolean; disableOuterSpacing?: boolean;
findHighlights?: MessageFindHighlight[];
} }
const MessageOuterSpacingContext = createContext(false); const MessageOuterSpacingContext = createContext(false);
@@ -158,6 +159,19 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
] as const; ] as const;
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>; const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
export interface MessageFindHighlight {
id: string;
start: number;
end: number;
isCurrent: boolean;
}
interface MessageFindTextSegment {
key: string;
text: string;
highlight?: MessageFindHighlight;
}
interface MarkdownWithStableRendererProps { interface MarkdownWithStableRendererProps {
children: ReactNode; children: ReactNode;
style: ReturnType<typeof createMarkdownStyles>; style: ReturnType<typeof createMarkdownStyles>;
@@ -209,6 +223,95 @@ const SCROLL_EDGE_EPSILON = 0.5;
export const STREAM_METADATA_FONT_SIZE = 13; export const STREAM_METADATA_FONT_SIZE = 13;
type ScrollAxis = "x" | "y"; 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);
}
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;
}
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() { function ensureWebToolCallShimmerKeyframes() {
if (isNative) { if (isNative) {
return; return;
@@ -369,6 +472,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
lineHeight: 22, lineHeight: 22,
overflowWrap: "anywhere", 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: { imagePreviewContainer: {
flexDirection: "row", flexDirection: "row",
gap: theme.spacing[2], gap: theme.spacing[2],
@@ -436,6 +547,28 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
}, },
})); }));
function MessageFindHighlightedText({ segments }: { segments: MessageFindTextSegment[] }) {
return (
<>
{segments.map((segment) => (
<Text key={segment.key} style={getMessageFindHighlightStyle(segment.highlight)}>
{segment.text}
</Text>
))}
</>
);
}
function getMessageFindHighlightStyle(highlight: MessageFindHighlight | undefined) {
if (highlight?.isCurrent) {
return userMessageStylesheet.findCurrentMatchText;
}
if (highlight) {
return userMessageStylesheet.findMatchText;
}
return undefined;
}
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) { function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
const uri = useAttachmentPreviewUrl(image); const uri = useAttachmentPreviewUrl(image);
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]); const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]);
@@ -475,6 +608,7 @@ export const UserMessage = memo(function UserMessage({
isFirstInGroup = true, isFirstInGroup = true,
isLastInGroup = true, isLastInGroup = true,
disableOuterSpacing, disableOuterSpacing,
findHighlights,
}: UserMessageProps) { }: UserMessageProps) {
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@@ -498,6 +632,10 @@ export const UserMessage = memo(function UserMessage({
}, },
[rewindMutation], [rewindMutation],
); );
const highlightedMessageSegments = useMemo(
() => createMessageFindTextSegments(message, findHighlights),
[findHighlights, message],
);
const containerStyle = useMemo( const containerStyle = useMemo(
() => [ () => [
@@ -567,7 +705,7 @@ export const UserMessage = memo(function UserMessage({
) : null} ) : null}
{hasText ? ( {hasText ? (
<Text selectable style={userMessageStylesheet.text}> <Text selectable style={userMessageStylesheet.text}>
{message} <MessageFindHighlightedText segments={highlightedMessageSegments} />
</Text> </Text>
) : null} ) : null}
</View> </View>
@@ -752,6 +890,7 @@ interface AssistantMessageProps {
serverId?: string; serverId?: string;
client?: DaemonClient | null; client?: DaemonClient | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
findHighlights?: MessageFindHighlight[];
} }
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
@@ -1487,6 +1626,7 @@ interface MemoizedMarkdownBlockProps {
rules: RenderRules; rules: RenderRules;
parser: MarkdownIt; parser: MarkdownIt;
onLinkPress: (url: string) => boolean; onLinkPress: (url: string) => boolean;
findHighlights?: MessageFindHighlight[];
} }
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
@@ -1494,26 +1634,61 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
rules, rules,
parser, parser,
onLinkPress, onLinkPress,
findHighlights,
}: MemoizedMarkdownBlockProps) { }: 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 <MessageFindHighlightedText segments={segments} />;
},
[findHighlights, text],
);
return ( return (
<ThemedMarkdown <MarkdownFindHighlightContext.Provider value={renderFindHighlightedText}>
uniProps={markdownStyleMapping} <ThemedMarkdown
rules={rules} uniProps={markdownStyleMapping}
markdownit={parser} rules={rules}
onLinkPress={onLinkPress} markdownit={parser}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS} onLinkPress={onLinkPress}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM} allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
> topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
{text} >
</ThemedMarkdown> {text}
</ThemedMarkdown>
</MarkdownFindHighlightContext.Provider>
); );
}); });
const MarkdownFindHighlightContext = createContext<((content: string) => ReactNode) | null>(null);
interface MarkdownInheritedTextProps { interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle; inheritedStyles: TextStyle;
textStyle: TextStyle; textStyle: TextStyle;
style?: StyleProp<TextStyle>; style?: StyleProp<TextStyle>;
children: ReactNode; children: ReactNode;
disableFindHighlight?: boolean;
} }
function MarkdownInheritedText({ function MarkdownInheritedText({
@@ -1521,12 +1696,18 @@ function MarkdownInheritedText({
textStyle, textStyle,
style: overrideStyle, style: overrideStyle,
children, children,
disableFindHighlight = false,
}: MarkdownInheritedTextProps) { }: MarkdownInheritedTextProps) {
const renderFindHighlightedText = useContext(MarkdownFindHighlightContext);
const style = useMemo( const style = useMemo(
() => [inheritedStyles, textStyle, overrideStyle], () => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle], [inheritedStyles, textStyle, overrideStyle],
); );
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>; const renderedChildren =
!disableFindHighlight && typeof children === "string" && renderFindHighlightedText
? renderFindHighlightedText(children)
: children;
return <MarkdownTextSpan style={style}>{renderedChildren}</MarkdownTextSpan>;
} }
interface MarkdownListItemContentProps { interface MarkdownListItemContentProps {
@@ -1559,6 +1740,7 @@ export const AssistantMessage = memo(function AssistantMessage({
serverId, serverId,
client, client,
spacing = "default", spacing = "default",
findHighlights,
}: AssistantMessageProps) { }: AssistantMessageProps) {
const markdownParser = useMemo(() => { const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true }); const parser = MarkdownIt({ typographer: true, linkify: true });
@@ -1696,6 +1878,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key} key={node.key}
inheritedStyles={inheritedStyles} inheritedStyles={inheritedStyles}
textStyle={styles.code_inline} textStyle={styles.code_inline}
disableFindHighlight
> >
{content} {content}
</MarkdownInheritedText> </MarkdownInheritedText>
@@ -1803,11 +1986,7 @@ export const AssistantMessage = memo(function AssistantMessage({
}; };
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]); }, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const keyedBlocks = useMemo(() => createMarkdownBlocksWithOffsets(message), [message]);
const keyedBlocks = useMemo(
() => blocks.map((block, index) => ({ key: `${index}:${block.slice(0, 32)}`, block })),
[blocks],
);
const assistantContainerStyle = useMemo( const assistantContainerStyle = useMemo(
() => [ () => [
@@ -1822,7 +2001,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return ( return (
<View testID="assistant-message" style={assistantContainerStyle}> <View testID="assistant-message" style={assistantContainerStyle}>
{keyedBlocks.map(({ key, block }, index) => ( {keyedBlocks.map(({ key, block, startOffset }, index) => (
<AssistantMessageBlockContainer <AssistantMessageBlockContainer
key={key} key={key}
block={block} block={block}
@@ -1833,6 +2012,7 @@ export const AssistantMessage = memo(function AssistantMessage({
rules={markdownRules} rules={markdownRules}
parser={markdownParser} parser={markdownParser}
onLinkPress={handleMarkdownLinkPress} onLinkPress={handleMarkdownLinkPress}
findHighlights={createBlockFindHighlights(startOffset, block.length, findHighlights)}
/> />
</AssistantMessageBlockContainer> </AssistantMessageBlockContainer>
))} ))}
@@ -47,7 +47,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
: undefined; : undefined;
await input.client.fetchAgentTimeline(input.agentId, { await input.client.fetchAgentTimeline(input.agentId, {
direction: "tail", direction: "tail",
projection: "canonical", projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}), ...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
}); });
} }
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { getNativeScrollToIndexFallbackOffset } from "@/agent-stream/strategy-native";
describe("getNativeScrollToIndexFallbackOffset", () => {
it("approximates the offset for an unmeasured native FlatList row", () => {
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
averageItemLength: 72,
}),
).toBe(1800);
});
it("falls back to the start when the average item length is not useful", () => {
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
averageItemLength: 0,
}),
).toBe(0);
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
averageItemLength: Number.NaN,
}),
).toBe(0);
});
});
@@ -22,6 +22,7 @@ import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-m
import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { import {
TerminalEmulatorRuntime, TerminalEmulatorRuntime,
type TerminalFindResultChangeEvent,
type TerminalOutputData, type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime"; } from "../terminal/runtime/terminal-emulator-runtime";
import type { import type {
@@ -49,6 +50,10 @@ export interface TerminalEmulatorHandle {
renderSnapshot: (state: TerminalState | null) => void; renderSnapshot: (state: TerminalState | null) => void;
clear: () => void; clear: () => void;
blur: () => 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; const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
@@ -328,6 +333,18 @@ export default function TerminalEmulator({
blur: () => { blur: () => {
runtimeRef.current?.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) ?? (() => {});
},
}), }),
[], [],
); );
@@ -0,0 +1,452 @@
/**
* @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;
}
interface TerminalKeyInput {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
interface MockTerminalEmulatorProps {
testId?: string;
onTerminalKey?: (input: TerminalKeyInput) => Promise<void> | void;
}
const { client, findListeners, terminalProps, theme, terminalHandle, resetTerminalHandle } =
vi.hoisted(() => {
const listeners: Array<(event: FindResultChange) => void> = [];
const handle = {
writeOutput: vi.fn(),
restoreOutput: vi.fn(),
renderSnapshot: vi.fn(),
clear: vi.fn(),
blur: 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 | MockTerminalEmulatorProps,
},
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<HTMLInputElement>) =>
onChangeText?.(event.currentTarget.value),
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) =>
onKeyPress?.({
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
preventDefault: () => event.preventDefault(),
}),
placeholder,
ref,
value: value ?? "",
});
});
return {
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity" }),
Platform: {
OS: "web",
select: (options: Record<string, unknown>) => options.web ?? options.default,
},
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) =>
Object.prototype.toString.call(factory) === "[object Function]"
? (factory as (value: unknown) => 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("@/hooks/use-settings", () => ({
useAppSettings: () => ({ settings: { terminalScrollbackLines: 1000 } }),
}));
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<TerminalEmulatorHandle, MockTerminalEmulatorProps>(
function TerminalEmulator(props, ref) {
terminalProps.current = props;
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: () => {},
openImportSheet: () => {},
};
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(
<PaneProvider value={paneContext}>
<PaneFocusProvider value={paneFocus}>
<TerminalPane
serverId="server-a"
cwd="/repo"
terminalId="terminal-a"
isWorkspaceFocused
isPaneFocused
onOpenFileExplorer={vi.fn()}
onOpenWorkspaceFile={vi.fn()}
/>
</PaneFocusProvider>
</PaneProvider>,
);
});
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",
});
});
});
+76 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
@@ -47,6 +47,8 @@ import {
type OpenFileDisposition, type OpenFileDisposition,
type WorkspaceFileOpenRequest, type WorkspaceFileOpenRequest,
} from "@/workspace/file-open"; } from "@/workspace/file-open";
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
import type { TerminalFindResultChangeEvent } from "@/terminal/runtime/terminal-emulator-runtime";
interface TerminalPaneProps { interface TerminalPaneProps {
serverId: string; serverId: string;
@@ -106,10 +108,25 @@ const EMPTY_MODIFIERS: ModifierState = {
alt: false, 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 { function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`; 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 { interface ModifierButtonProps {
modifier: keyof ModifierState; modifier: keyof ModifierState;
active: boolean; active: boolean;
@@ -212,9 +229,16 @@ export function TerminalPane({
win32InputMode: false, win32InputMode: false,
}); });
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]); const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const terminalFindQueryRef = useRef("");
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]); const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null); const lastAutoFocusKeyRef = useRef<string | null>(null);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId }); const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
const [terminalFindMatchState, setTerminalFindMatchStateValue] =
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
const setTerminalFindMatchState = useCallback((nextState: PaneFindMatchState) => {
setTerminalFindMatchStateValue(nextState);
}, []);
useEffect(() => { useEffect(() => {
terminalIdRef.current = terminalId; terminalIdRef.current = terminalId;
@@ -224,6 +248,19 @@ export function TerminalPane({
}; };
}, [terminalId]); }, [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(() => { const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1); setFocusRequestToken((current) => current + 1);
}, []); }, []);
@@ -251,6 +288,43 @@ export function TerminalPane({
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots], [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(() => { useEffect(() => {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) { if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null; lastAutoFocusKeyRef.current = null;
@@ -748,6 +822,7 @@ export function TerminalPane({
return ( return (
<Animated.View style={containerStyle}> <Animated.View style={containerStyle}>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<View style={styles.outputContainer}> <View style={styles.outputContainer}>
{isWorkspaceFocused ? ( {isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}> <View style={styles.terminalGestureContainer}>
+25
View File
@@ -92,10 +92,35 @@ export interface DesktopBrowserShortcutEvent {
action: "focus-url"; action: "focus-url";
} }
export interface DesktopBrowserFindOptions {
forward?: boolean;
findNext?: boolean;
matchCase?: boolean;
}
export type DesktopBrowserFindAction = "clearSelection" | "keepSelection" | "activateSelection";
export interface DesktopBrowserFoundInPageResult {
requestId?: number;
activeMatchOrdinal?: number;
matches?: number;
finalUpdate?: boolean;
}
export interface DesktopBrowserBridge { export interface DesktopBrowserBridge {
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>; setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>; openDevTools?: (browserId: string) => Promise<unknown>;
clearPartition?: (browserId: string) => Promise<void>; clearPartition?: (browserId: string) => Promise<void>;
findInPage: (
browserId: string,
text: string,
options?: DesktopBrowserFindOptions,
) => Promise<number | null> | number | null;
stopFindInPage: (browserId: string, action: DesktopBrowserFindAction) => Promise<void> | void;
onFoundInPage: (
browserId: string,
listener: (result: DesktopBrowserFoundInPageResult) => void,
) => Promise<() => void> | (() => void);
} }
export interface DesktopInvokeBridge { export interface DesktopInvokeBridge {
@@ -34,7 +34,7 @@ afterEach(() => {
}); });
describe("ensureAgentIsInitialized", () => { describe("ensureAgentIsInitialized", () => {
it("requests bounded canonical catch-up after the current cursor when authoritative history is loaded", () => { it("requests bounded projected catch-up after the current cursor when authoritative history is loaded", () => {
const client = makeClient(); const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never); useSessionStore.getState().initializeSession(serverId, client as never);
useSessionStore useSessionStore
@@ -56,12 +56,12 @@ describe("ensureAgentIsInitialized", () => {
direction: "after", direction: "after",
cursor: { epoch: "epoch-1", seq: 42 }, cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
}); });
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after"); expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
}); });
it("requests a bounded canonical tail when no authoritative cursor is available", () => { it("requests a bounded projected tail when no authoritative cursor is available", () => {
const client = makeClient(); const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never); useSessionStore.getState().initializeSession(serverId, client as never);
@@ -75,7 +75,7 @@ describe("ensureAgentIsInitialized", () => {
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail", direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
}); });
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail"); expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
}); });
@@ -107,7 +107,7 @@ describe("ensureAgentIsInitialized", () => {
}); });
describe("refreshAgent", () => { describe("refreshAgent", () => {
it("fetches a bounded canonical tail after refreshing the agent", async () => { it("fetches a bounded projected tail after refreshing the agent", async () => {
const client = makeClient(); const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never); useSessionStore.getState().initializeSession(serverId, client as never);
@@ -121,7 +121,7 @@ describe("refreshAgent", () => {
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, { expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail", direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
}); });
}); });
}); });
@@ -27,6 +27,25 @@ import {
navigateToLastWorkspace, navigateToLastWorkspace,
useActiveWorkspaceSelection, useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { handlePaneFindKeyboardAction } from "@/panels/pane-find-registry";
type KeyboardShortcutResult = ReturnType<typeof resolveKeyboardShortcut>;
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({ export function useKeyboardShortcuts({
enabled, enabled,
@@ -94,6 +113,8 @@ export function useKeyboardShortcuts({
return false; return false;
case "dispatch": case "dispatch":
return keyboardActionDispatcher.dispatch(action.action); return keyboardActionDispatcher.dispatch(action.action);
case "pane-find-open":
return handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
case "navigate-workspace": case "navigate-workspace":
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname }); navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true; return true;
@@ -182,10 +203,7 @@ export function useKeyboardShortcuts({
chordStateRef.current = result.nextChordState; chordStateRef.current = result.nextChordState;
if (result.preventDefault) { applyPendingChordEventHandling(event, result);
event.preventDefault();
event.stopPropagation();
}
if (!result.match) { if (!result.match) {
return; return;
@@ -208,12 +226,7 @@ export function useKeyboardShortcuts({
return; return;
} }
if (result.match.preventDefault) { applyHandledShortcutEventHandling(event, result);
event.preventDefault();
}
if (result.match.stopPropagation) {
event.stopPropagation();
}
}; };
const handleKeyUp = (event: KeyboardEvent) => { const handleKeyUp = (event: KeyboardEvent) => {
@@ -147,7 +147,7 @@ describe("loadOlderAgentHistory", () => {
direction: "before", direction: "before",
cursor: { epoch: "epoch-1", seq: 10 }, cursor: { epoch: "epoch-1", seq: 10 },
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
}, },
}, },
]); ]);
@@ -10,7 +10,7 @@ export interface LoadOlderAgentHistoryClient {
direction: "before"; direction: "before";
cursor: { epoch: string; seq: number }; cursor: { epoch: string; seq: number };
limit: number; limit: number;
projection: "canonical"; projection: "projected";
}, },
) => Promise<unknown>; ) => Promise<unknown>;
} }
+1
View File
@@ -20,6 +20,7 @@ export type KeyboardActionId =
| "agent.new" | "agent.new"
| "workspace.tab.new" | "workspace.tab.new"
| "workspace.tab.close.current" | "workspace.tab.close.current"
| "workspace.find.open"
| "workspace.tab.navigate.index" | "workspace.tab.navigate.index"
| "workspace.tab.navigate.relative" | "workspace.tab.navigate.relative"
| "workspace.pane.split.right" | "workspace.pane.split.right"
@@ -206,6 +206,18 @@ describe("keyboard-shortcuts", () => {
context: { isMac: true, isDesktop: true }, context: { isMac: true, isDesktop: true },
action: "workspace.tab.close.current", 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", name: "matches Ctrl+W to close current tab on non-mac desktop",
event: { key: "w", code: "KeyW", ctrlKey: true }, event: { key: "w", code: "KeyW", ctrlKey: true },
@@ -540,6 +552,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["alt", "1-9"], "workspace-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["alt", "shift", "1-9"], "workspace-tab-jump-index": ["alt", "shift", "1-9"],
"workspace-tab-close-current": ["alt", "shift", "W"], "workspace-tab-close-current": ["alt", "shift", "W"],
"workspace-find-open": ["mod", "F"],
"workspace-pane-split-right": ["mod", "\\"], "workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"], "workspace-pane-close": ["mod", "shift", "W"],
}, },
@@ -553,6 +566,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["mod", "1-9"], "workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"], "workspace-tab-jump-index": ["mod", "alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"], "workspace-tab-close-current": ["meta", "W"],
"workspace-find-open": ["mod", "F"],
"workspace-pane-split-right": ["mod", "\\"], "workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"], "workspace-pane-close": ["mod", "shift", "W"],
}, },
@@ -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 --- // --- Workspace index jump ---
{ {
id: "workspace-navigate-index-cmd-digit-mac", id: "workspace-navigate-index-cmd-digit-mac",
+4 -1
View File
@@ -37,7 +37,8 @@ export type ShortcutAction =
| { kind: "open-project-picker" } | { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName } | { kind: "callback"; name: ShortcutCallbackName }
| { kind: "command-center-toggle"; nextOpen: boolean } | { 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" }; const NONE: ShortcutAction = { kind: "none" };
@@ -201,6 +202,8 @@ export function routeKeyboardShortcut(
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen }; return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
case "shortcuts.dialog.toggle": case "shortcuts.dialog.toggle":
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen }; return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
case "workspace.find.open":
return { kind: "pane-find-open" };
default: default:
return NONE; return NONE;
} }
+1
View File
@@ -6,6 +6,7 @@ import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue { export interface PaneContextValue {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
paneInstanceId: string | null;
tabId: string; tabId: string;
target: WorkspaceTabTarget; target: WorkspaceTabTarget;
openTab: (target: WorkspaceTabTarget) => void; openTab: (target: WorkspaceTabTarget) => void;
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
import {
clearActivePaneFindPaneId,
createPaneFindRegistry,
handlePaneFindKeyboardAction,
paneFindRegistry,
setActivePaneFindPaneId,
type PaneFindController,
} from "@/panels/pane-find-registry";
function createController(input?: { openResult?: boolean }): PaneFindController {
return {
openFind: vi.fn(() => input?.openResult ?? 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({ openResult: false });
const right = createController({ openResult: true });
registry.register({
paneId: "server:workspace:left",
controller: left,
});
registry.register({
paneId: "server:workspace:right",
controller: right,
});
expect(registry.openFindInActivePane()).toBe(false);
});
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.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({ openResult: false });
const right = createController({ openResult: true });
registry.register({
paneId: "server:workspace:left",
controller: left,
});
registry.register({
paneId: "server:workspace:right",
controller: right,
});
expect(registry.openFindInActivePane()).toBe(true);
});
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();
});
});
@@ -0,0 +1,82 @@
export interface PaneFindController {
openFind(): boolean;
closeFind(): boolean;
}
interface PaneFindKeyboardAction {
id: "workspace.find.open";
scope: "workspace";
}
interface RegisterPaneFindInput {
paneId: string;
controller: PaneFindController;
}
interface PaneFindRegistry {
register(input: RegisterPaneFindInput): () => void;
openFindInActivePane(): boolean;
closeFindInPane(paneId: string): boolean;
}
export function createPaneFindRegistry(input: { getActivePaneId: () => string | null }) {
const controllers = new Map<string, PaneFindController>();
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: PaneFindKeyboardAction): boolean {
void action;
return paneFindRegistry.openFindInActivePane();
}
+503
View File
@@ -0,0 +1,503 @@
/**
* @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<HTMLInputElement>) =>
onChangeText?.(event.currentTarget.value),
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) =>
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<typeof vi.fn<(query: string) => PaneFindCommandResult>>;
next: ReturnType<typeof vi.fn<() => PaneFindCommandResult>>;
prev: ReturnType<typeof vi.fn<() => PaneFindCommandResult>>;
close: ReturnType<typeof vi.fn<() => void>>;
}
const controllers = new Map<string, FakeSearchController>();
function createController(input?: { total?: number }): FakeSearchController {
const total = input?.total ?? 3;
return {
query: vi.fn((query: string) =>
query === "missing" ? { status: "no-match" } : { status: "matched", current: 1, total },
),
next: vi.fn(() => ({ status: "matched", current: 2, total })),
prev: vi.fn(() => ({ status: "matched", current: 3, total })),
close: vi.fn(),
};
}
function FakeFindPanel() {
const paneContext = usePaneContext();
const controller = controllers.get(paneContext.paneInstanceId ?? "");
if (!controller) {
throw new Error(`Missing fake find controller for pane ${paneContext.paneInstanceId}`);
}
const paneFind = usePaneFind({
onQuery: controller.query,
onNext: controller.next,
onPrev: controller.prev,
onClose: controller.close,
});
return (
<View>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<Text>Pane body</Text>
</View>
);
}
function AsyncFindPanel({
matchState,
onQuery,
}: {
matchState: PaneFindMatchState;
onQuery: (query: string) => PaneFindCommandResult;
}) {
const paneFind = usePaneFind({
matchState,
onQuery,
onNext: () => undefined,
onPrev: () => undefined,
onClose: vi.fn(),
});
return (
<View>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<Text>Pane body</Text>
</View>
);
}
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: () => {},
openImportSheet: () => {},
};
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(
<PaneProvider value={harnessPaneContext}>
<PaneFocusProvider value={harnessPaneFocus}>
<FakeFindPanel />
</PaneFocusProvider>
</PaneProvider>,
);
});
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(container?.textContent).toContain("2 / 3");
act(() => {
button("pane-find-prev").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container?.textContent).toContain("3 / 3");
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(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
});
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<PaneFindMatchState>({
status: "empty",
});
setExternalMatchState = setMatchState;
return (
<PaneProvider value={harnessPaneContext}>
<PaneFocusProvider value={harnessPaneFocus}>
<AsyncFindPanel matchState={externalMatchState} onQuery={onQuery} />
</PaneFocusProvider>
</PaneProvider>
);
}
act(() => {
root?.render(<Harness />);
});
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({ total: 7 });
const right = createController({ total: 5 });
const leftContent = buildWorkspacePaneContentModel({
tab,
paneId: "left",
normalizedServerId: "server-a",
normalizedWorkspaceId: "workspace-a",
onOpenTab: vi.fn(),
onCloseCurrentTab: vi.fn(),
onRetargetCurrentTab: vi.fn(),
onOpenWorkspaceFile: vi.fn(),
onOpenImportSheet: 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(),
onOpenImportSheet: 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(
<View>
<WorkspacePaneContent
content={leftContent}
isPaneFocused={false}
isWorkspaceFocused
onFocusPane={focusLeft}
/>
<WorkspacePaneContent
content={rightContent}
isPaneFocused
isWorkspaceFocused
onFocusPane={focusRight}
/>
</View>,
);
});
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("abc");
expect(container?.textContent).toContain("1 / 5");
expect(container?.textContent).not.toContain("1 / 7");
expect(focusLeft).not.toHaveBeenCalled();
expect(focusRight).not.toHaveBeenCalled();
});
});
+310
View File
@@ -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<PaneFindMatchState>({ 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<TextInput>(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<TextInputKeyPressEventData & { shiftKey?: boolean }>) => {
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 (
<View style={styles.container} testID="pane-find-bar">
<Text style={styles.label}>Find</Text>
<TextInput
ref={inputRef}
testID="pane-find-input"
value={query}
onChangeText={onQueryChange}
onKeyPress={handleKeyPress}
autoCapitalize="none"
autoCorrect={false}
placeholder="Search"
placeholderTextColor={theme.colors?.foregroundMuted ?? "#71717a"}
style={styles.input}
/>
<Text style={styles.counter}>{counterText}</Text>
<Pressable
accessibilityLabel="Previous match"
disabled={controlDisabled}
onPress={onPrev}
style={matchButtonStyle}
testID="pane-find-prev"
>
<ChevronUp size={14} color={iconColor} />
</Pressable>
<Pressable
accessibilityLabel="Next match"
disabled={controlDisabled}
onPress={onNext}
style={matchButtonStyle}
testID="pane-find-next"
>
<ChevronDown size={14} color={iconColor} />
</Pressable>
<Pressable
accessibilityLabel="Close find"
onPress={onClose}
style={styles.iconButton}
testID="pane-find-close"
>
<X size={14} color={iconColor} />
</Pressable>
</View>
);
}
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,
},
}));
@@ -17,7 +17,7 @@ import {
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildHostAgentDetailRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { ImportSessionSheet } from "@/components/import-session-sheet"; import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
@@ -62,7 +62,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
); );
const handleOpenProviders = useCallback(() => { const handleOpenProviders = useCallback(() => {
router.push(buildSettingsHostRoute(serverId)); router.push(buildSettingsHostSectionRoute(serverId, "providers"));
}, [router, serverId]); }, [router, serverId]);
return ( return (
+382 -87
View File
@@ -1,5 +1,13 @@
import { Fragment, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import {
import type { ComponentType, ReactNode } from "react"; Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import { import {
Alert, Alert,
Pressable, Pressable,
@@ -22,6 +30,9 @@ import {
ChevronDown, ChevronDown,
Settings, Settings,
Server, Server,
Network,
Workflow,
Boxes,
Keyboard, Keyboard,
Stethoscope, Stethoscope,
Info, Info,
@@ -72,6 +83,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
@@ -81,7 +93,12 @@ import { resolveAppVersion } from "@/utils/app-version";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm"; import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { HostPage, HostRenameButton } from "@/screens/settings/host-page"; import {
HostConnectionsPage,
HostDaemonPage,
HostOrchestrationPage,
HostProvidersPage,
} from "@/screens/settings/host-page";
import ProjectsScreen from "@/screens/projects-screen"; import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -90,8 +107,9 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { import {
buildHostOpenProjectRoute, buildHostOpenProjectRoute,
buildProjectsSettingsRoute, buildProjectsSettingsRoute,
buildSettingsHostRoute, buildSettingsHostSectionRoute,
buildSettingsSectionRoute, buildSettingsSectionRoute,
type HostSectionSlug,
type SettingsSectionSlug, type SettingsSectionSlug,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store"; import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -103,7 +121,7 @@ import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-st
export type SettingsView = export type SettingsView =
| { kind: "root" } | { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug } | { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string } | { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "projects" } | { kind: "projects" }
| { kind: "project"; projectKey: string }; | { kind: "project"; projectKey: string };
@@ -123,6 +141,19 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ id: "about", label: "About", icon: Info }, { id: "about", label: "About", icon: Info },
]; ];
interface HostSectionItem {
id: HostSectionSlug;
label: string;
icon: ComponentType<{ size: number; color: string }>;
}
const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "connections", label: "Connections", icon: Network },
{ id: "orchestration", label: "Orchestration", icon: Workflow },
{ id: "providers", label: "Providers", icon: Boxes },
{ id: "daemon", label: "Daemon", icon: Server },
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Theme helpers (General section) // Theme helpers (General section)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -709,6 +740,26 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
); );
} }
/**
* Local daemon first, then remaining hosts in their existing order. Lets the
* picker and the active-host resolver agree on a stable "first" host.
*/
function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] {
return useMemo(() => {
if (!localServerId) {
return hosts;
}
const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
if (localIndex <= 0) {
return hosts;
}
const next = hosts.slice();
const [local] = next.splice(localIndex, 1);
next.unshift(local);
return next;
}, [hosts, localServerId]);
}
interface SidebarSectionButtonProps { interface SidebarSectionButtonProps {
itemId: SettingsSectionSlug; itemId: SettingsSectionSlug;
label: string; label: string;
@@ -751,6 +802,49 @@ function SidebarSectionButton({
); );
} }
interface SidebarHostSectionButtonProps {
itemId: HostSectionSlug;
label: string;
icon: ComponentType<{ size: number; color: string }>;
isSelected: boolean;
onSelect: (section: HostSectionSlug) => void;
}
function SidebarHostSectionButton({
itemId,
label,
icon: IconComponent,
isSelected,
onSelect,
}: SidebarHostSectionButtonProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onSelect(itemId);
}, [onSelect, itemId]);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
testID={`settings-host-section-${itemId}`}
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
interface SidebarProjectsButtonProps { interface SidebarProjectsButtonProps {
isSelected: boolean; isSelected: boolean;
onSelect: () => void; onSelect: () => void;
@@ -782,83 +876,213 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr
); );
} }
interface SidebarHostItemProps { // Sentinel option id for the "Add host" row appended to the picker list.
const ADD_HOST_OPTION_ID = "__add_host__";
interface HostPickerOptionProps {
serverId: string; serverId: string;
label: string; label: string;
isSelected: boolean;
isLocal: boolean; isLocal: boolean;
onSelect: (serverId: string) => void; selected: boolean;
active: boolean;
onPress: () => void;
} }
function SidebarHostItem({ serverId, label, isSelected, isLocal, onSelect }: SidebarHostItemProps) { function HostPickerOption({
serverId,
label,
isLocal,
selected,
active,
onPress,
}: HostPickerOptionProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const handlePress = useCallback(() => { const leadingSlot = useMemo(
onSelect(serverId); () => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
}, [onSelect, serverId]); [theme.iconSize.sm, theme.colors.foregroundMuted],
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
); );
return ( // The local host carries a "Local" marker; the active host is conveyed by the
<Pressable // row's selected check, so both can coexist on one row.
accessibilityRole="button" const trailingSlot = useMemo(
accessibilityState={accessibilityState} () =>
onPress={handlePress} isLocal ? (
testID={`settings-host-entry-${serverId}`}
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<Server
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
{isLocal ? (
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker"> <Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
Local Local
</Text> </Text>
) : null} ) : undefined,
</Pressable> [isLocal],
);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={`settings-host-picker-item-${serverId}`}
/>
);
}
function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const leadingSlot = useMemo(
() => <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
label="Add host"
leadingSlot={leadingSlot}
active={active}
onPress={onPress}
testID="settings-add-host"
/>
);
}
interface HostPickerProps {
activeServerId: string | null;
sortedHosts: HostProfile[];
localServerId: string | null;
onSelectHost: (serverId: string) => void;
onAddHost: () => void;
}
/**
* Scopes the four host sections to a host. Reuses the canonical sidebar host
* switcher pattern (left-sidebar.tsx): a quiet row-styled trigger opening a
* <Combobox>. The local host is listed first and tagged "Local"; an "Add host"
* row is always reachable from the list — even with a single host.
*/
function HostPicker({
activeServerId,
sortedHosts,
localServerId,
onSelectHost,
onAddHost,
}: HostPickerProps) {
const { theme } = useUnistyles();
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
const activeHost =
sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null;
const options = useMemo<ComboboxOption[]>(() => {
const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label }));
return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: "Add host" }];
}, [sortedHosts]);
const handleSelect = useCallback(
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost();
return;
}
onSelectHost(id);
},
[onAddHost, onSelectHost],
);
const renderOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}): ReactElement => {
if (option.id === ADD_HOST_OPTION_ID) {
return <AddHostOption active={active} onPress={onPress} />;
}
return (
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={localServerId !== null && option.id === localServerId}
selected={selected}
active={active}
onPress={onPress}
/>
);
},
[localServerId],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
sidebarStyles.pickerTrigger,
hovered && sidebarStyles.pickerTriggerHovered,
],
[],
);
return (
<>
<Pressable
ref={triggerRef}
style={triggerStyle}
onPress={handleOpen}
accessibilityRole="button"
accessibilityLabel="Switch host"
testID="settings-host-picker"
>
<Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
{activeHost?.label ?? "Host"}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={options}
value={activeServerId ?? ""}
onSelect={handleSelect}
renderOption={renderOption}
searchable={false}
title="Switch host"
desktopMinWidth={240}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
/>
</>
); );
} }
interface SettingsSidebarProps { interface SettingsSidebarProps {
view: SettingsView; view: SettingsView;
onSelectSection: (section: SettingsSectionSlug) => void; onSelectSection: (section: SettingsSectionSlug) => void;
onSelectHostSection: (section: HostSectionSlug) => void;
onSelectHost: (serverId: string) => void; onSelectHost: (serverId: string) => void;
onSelectProjects: () => void; onSelectProjects: () => void;
onAddHost: () => void; onAddHost: () => void;
onBackToWorkspace: () => void; onBackToWorkspace: () => void;
activeHostServerId: string | null;
layout: "desktop" | "mobile"; layout: "desktop" | "mobile";
} }
function SettingsSidebar({ function SettingsSidebar({
view, view,
onSelectSection, onSelectSection,
onSelectHostSection,
onSelectHost, onSelectHost,
onSelectProjects, onSelectProjects,
onAddHost, onAddHost,
onBackToWorkspace, onBackToWorkspace,
activeHostServerId,
layout, layout,
}: SettingsSidebarProps) { }: SettingsSidebarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const hosts = useHosts(); const hosts = useHosts();
const localServerId = useLocalDaemonServerId(); const localServerId = useLocalDaemonServerId();
const sortedHosts = useMemo(() => { const sortedHosts = useSortedHosts(hosts, localServerId);
if (!localServerId) { const hasHosts = sortedHosts.length > 0;
return hosts;
}
const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
if (localIndex <= 0) {
return hosts;
}
const next = hosts.slice();
const [local] = next.splice(localIndex, 1);
next.unshift(local);
return next;
}, [hosts, localServerId]);
const isDesktopApp = isElectronRuntime(); const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp); const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -872,7 +1096,7 @@ function SettingsSidebar({
[insets.top, isDesktop], [insets.top, isDesktop],
); );
const selectedSectionId = view.kind === "section" ? view.section : null; const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null; const selectedHostSection = view.kind === "host" ? view.section : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project"; const isProjectsSelected = view.kind === "projects" || view.kind === "project";
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]); const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
@@ -893,6 +1117,7 @@ function SettingsSidebar({
/> />
) : null} ) : null}
<View style={sidebarStyles.list}> <View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>App</Text>
{items.map((item) => ( {items.map((item) => (
<Fragment key={item.id}> <Fragment key={item.id}>
<SidebarSectionButton <SidebarSectionButton
@@ -909,30 +1134,43 @@ function SettingsSidebar({
))} ))}
</View> </View>
<SidebarSeparator /> <SidebarSeparator />
<View style={sidebarStyles.list}> {hasHosts ? (
{sortedHosts.map((host) => ( <View style={sidebarStyles.list}>
<SidebarHostItem <Text style={sidebarStyles.groupLabel}>Host</Text>
key={host.serverId} <HostPicker
serverId={host.serverId} activeServerId={activeHostServerId}
label={host.label} sortedHosts={sortedHosts}
isSelected={selectedServerId === host.serverId} localServerId={localServerId}
isLocal={localServerId !== null && host.serverId === localServerId} onSelectHost={onSelectHost}
onSelect={onSelectHost} onAddHost={onAddHost}
/> />
))} {HOST_SECTION_ITEMS.map((item) => (
<Pressable <SidebarHostSectionButton
accessibilityRole="button" key={item.id}
accessibilityLabel="Add host" itemId={item.id}
onPress={onAddHost} label={item.label}
testID="settings-add-host" icon={item.icon}
style={sidebarItemStyle} isSelected={selectedHostSection === item.id}
> onSelect={onSelectHostSection}
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> />
<Text style={sidebarStyles.label} numberOfLines={1}> ))}
Add host </View>
</Text> ) : (
</Pressable> <View style={sidebarStyles.list}>
</View> <Pressable
accessibilityRole="button"
accessibilityLabel="Add host"
onPress={onAddHost}
testID="settings-add-host"
style={sidebarItemStyle}
>
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.label} numberOfLines={1}>
Add host
</Text>
</Pressable>
</View>
)}
</View> </View>
); );
} }
@@ -967,9 +1205,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[webScrollbarStyle], [webScrollbarStyle],
); );
const hosts = useHosts(); const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId);
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds); const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
// The host the four sections scope to: the host on the active view, otherwise
// the local daemon, otherwise the first available host.
const activeHostServerId = useMemo(() => {
if (view.kind === "host") return view.serverId;
return localServerId ?? sortedHosts[0]?.serverId ?? null;
}, [view, localServerId, sortedHosts]);
const handleThemeChange = useCallback( const handleThemeChange = useCallback(
(nextTheme: AppSettings["theme"]) => { (nextTheme: AppSettings["theme"]) => {
void updateSettings({ theme: nextTheme }); void updateSettings({ theme: nextTheme });
@@ -1055,7 +1302,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const handleHostAdded = useCallback( const handleHostAdded = useCallback(
({ serverId }: { serverId: string }) => { ({ serverId }: { serverId: string }) => {
const target = buildSettingsHostRoute(serverId); const target = buildSettingsHostSectionRoute(serverId, "connections");
if (isCompactLayout) { if (isCompactLayout) {
router.push(target); router.push(target);
} else { } else {
@@ -1077,16 +1324,34 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[isCompactLayout, router], [isCompactLayout, router],
); );
// Picker: swap the host but keep the section the user is already looking at.
const handleSelectHost = useCallback( const handleSelectHost = useCallback(
(serverId: string) => { (serverId: string) => {
const target = buildSettingsHostRoute(serverId); const section: HostSectionSlug = view.kind === "host" ? view.section : "connections";
const target = buildSettingsHostSectionRoute(serverId, section);
if (isCompactLayout) { if (isCompactLayout) {
router.push(target); router.push(target);
} else { } else {
router.replace(target); router.replace(target);
} }
}, },
[isCompactLayout, router], [isCompactLayout, router, view],
);
const handleSelectHostSection = useCallback(
(section: HostSectionSlug) => {
if (!activeHostServerId) {
handleAddHost();
return;
}
const target = buildSettingsHostSectionRoute(activeHostServerId, section);
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
},
[activeHostServerId, handleAddHost, isCompactLayout, router],
); );
const handleSelectProjects = useCallback(() => { const handleSelectProjects = useCallback(() => {
@@ -1140,13 +1405,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
titleAccessory?: ReactNode; titleAccessory?: ReactNode;
} | null => { } | null => {
if (view.kind === "host") { if (view.kind === "host") {
const host = hosts.find((h) => h.serverId === view.serverId); const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
if (!host) return null; if (!item) return null;
return { return { title: item.label, Icon: item.icon };
title: host.label,
Icon: Server,
titleAccessory: <HostRenameButton host={host} />,
};
} }
if (view.kind === "section") { if (view.kind === "section") {
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section); const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
@@ -1161,7 +1422,16 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const content = (() => { const content = (() => {
if (view.kind === "host") { if (view.kind === "host") {
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />; switch (view.section) {
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "orchestration":
return <HostOrchestrationPage serverId={view.serverId} />;
case "providers":
return <HostProvidersPage serverId={view.serverId} />;
case "daemon":
return <HostDaemonPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
}
} }
if (view.kind === "projects") { if (view.kind === "projects") {
return <ProjectsScreen view={view} />; return <ProjectsScreen view={view} />;
@@ -1251,10 +1521,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<SettingsSidebar <SettingsSidebar
view={view} view={view}
onSelectSection={handleSelectSection} onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost} onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects} onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost} onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace} onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="mobile" layout="mobile"
/> />
</ScrollView> </ScrollView>
@@ -1293,10 +1565,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<SettingsSidebar <SettingsSidebar
view={view} view={view}
onSelectSection={handleSelectSection} onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost} onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects} onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost} onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace} onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="desktop" layout="desktop"
/> />
<View style={desktopStyles.contentPane}> <View style={desktopStyles.contentPane}>
@@ -1448,6 +1722,13 @@ const sidebarStyles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
groupLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
item: { item: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -1472,11 +1753,25 @@ const sidebarStyles = StyleSheet.create((theme) => ({
localMarker: { localMarker: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
marginLeft: theme.spacing[1],
},
pickerTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: 2, borderRadius: theme.borderRadius.lg,
borderWidth: 1, },
borderColor: theme.colors.border, pickerTriggerHovered: {
borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surfaceSidebarHover,
backgroundColor: theme.colors.surface3, },
pickerTriggerLabel: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
}, },
})); }));
+139 -62
View File
@@ -74,25 +74,30 @@ function formatDaemonVersionBadge(version: string | null): string | null {
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" }; const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" }; const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
export interface HostPageProps { function useHostProfile(serverId: string): HostProfile | null {
serverId: string; const daemons = useHosts();
onHostRemoved?: () => void; return daemons.find((entry) => entry.serverId === serverId) ?? null;
} }
export function HostPage({ serverId, onHostRemoved }: HostPageProps) { function HostNotFound() {
const daemons = useHosts(); return (
const host = daemons.find((entry) => entry.serverId === serverId) ?? null; <View>
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>Host not found</Text>
</View>
</View>
);
}
function HostStatusBadges({ serverId }: { serverId: string }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId); const snapshot = useHostRuntimeSnapshot(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const daemonVersion = useSessionStore( const daemonVersion = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.version ?? null, (state) => state.sessions[serverId]?.serverInfo?.version ?? null,
); );
const connectionStatus = snapshot?.connectionStatus ?? "connecting"; const connectionStatus = snapshot?.connectionStatus ?? "connecting";
const activeConnection = snapshot?.activeConnection ?? null; const activeConnection = snapshot?.activeConnection ?? null;
const lastError = snapshot?.lastError ?? null;
const statusLabel = formatConnectionStatus(connectionStatus); const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus); const statusTone = getConnectionStatusTone(connectionStatus);
let statusColor: string; let statusColor: string;
@@ -117,8 +122,6 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
} }
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme); const connectionBadge = formatActiveConnectionBadge(activeConnection, theme);
const versionBadgeText = formatDaemonVersionBadge(daemonVersion); const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const statusPillStyle = useMemo( const statusPillStyle = useMemo(
() => [styles.statusPill, { backgroundColor: statusPillBg }], () => [styles.statusPill, { backgroundColor: statusPillBg }],
@@ -130,46 +133,125 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
); );
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]); const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
if (!host) { return (
return ( <View style={styles.identityBadges} testID="host-page-identity">
<View testID={`settings-host-page-${serverId}`}> <View style={statusPillStyle}>
<View style={EMPTY_CARD_STYLE}> <View style={statusDotStyle} />
<Text style={styles.emptyText}>Host not found</Text> <Text style={statusTextStyle}>{statusLabel}</Text>
</View>
</View> </View>
); {connectionBadge ? (
<View style={styles.badgePill}>
{connectionBadge.icon}
<Text style={styles.badgeText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
{versionBadgeText ? (
<View style={styles.badgePill}>
<Text style={styles.badgeText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
</View>
);
}
function HostConnectionError({ serverId }: { serverId: string }) {
const snapshot = useHostRuntimeSnapshot(serverId);
const lastError = snapshot?.lastError ?? null;
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
if (!connectionError) return null;
return <Text style={styles.errorText}>{connectionError}</Text>;
}
export function HostConnectionsPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
if (!host) {
return <HostNotFound />;
} }
return ( return (
<View testID={`settings-host-page-${serverId}`}> <View>
<View style={styles.identityBadges} testID="host-page-identity"> <HostConnectionError serverId={serverId} />
<View style={statusPillStyle}>
<View style={statusDotStyle} />
<Text style={statusTextStyle}>{statusLabel}</Text>
</View>
{connectionBadge ? (
<View style={styles.badgePill}>
{connectionBadge.icon}
<Text style={styles.badgeText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
{versionBadgeText ? (
<View style={styles.badgePill}>
<Text style={styles.badgeText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
</View>
{connectionError ? <Text style={styles.errorText}>{connectionError}</Text> : null}
<ConnectionsSection host={host} /> <ConnectionsSection host={host} />
{isLocalDaemon ? (
<SettingsSection title="Pair devices">
<PairDeviceRow />
</SettingsSection>
) : null}
</View>
);
}
<DaemonSection host={host} isLocalDaemon={isLocalDaemon} /> export function HostOrchestrationPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
{isConnected ? (
<SettingsSection title="Orchestration">
<InjectPaseoToolsCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} />
</SettingsSection>
) : (
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>Connect to this host to manage orchestration</Text>
</View>
)}
</View>
);
}
export function HostProvidersPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<ProvidersSection serverId={serverId} /> <ProvidersSection serverId={serverId} />
</View>
);
}
export function HostDaemonPage({
serverId,
onHostRemoved,
}: {
serverId: string;
onHostRemoved?: () => void;
}) {
const host = useHostProfile(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<View style={styles.daemonHeader}>
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
{host.label}
</Text>
<HostRenameButton host={host} />
</View>
<HostStatusBadges serverId={serverId} />
{isLocalDaemon ? <LocalDaemonSection /> : null}
<RemoveHostSection host={host} onRemoved={onHostRemoved} /> <RemoveHostSection host={host} onRemoved={onHostRemoved} />
</View> </View>
@@ -337,7 +419,7 @@ function ConnectionRow({
if (latencyLoading) return "..."; if (latencyLoading) return "...";
if (latencyError) return "Timeout"; if (latencyError) return "Timeout";
if (latencyMs != null) return formatLatency(latencyMs); if (latencyMs != null) return formatLatency(latencyMs);
return "\u2014"; return "—";
})(); })();
const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted; const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted;
@@ -378,23 +460,6 @@ function ConnectionRow({
); );
} }
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
return (
<>
<SettingsSection title="Daemon settings">
<InjectPaseoToolsCard serverId={host.serverId} />
<AppendSystemPromptCard serverId={host.serverId} />
</SettingsSection>
{isLocalDaemon ? (
<SettingsSection title="Pair devices">
<PairDeviceRow />
</SettingsSection>
) : null}
{isLocalDaemon ? <LocalDaemonSection /> : null}
</>
);
}
const delay = (ms: number) => const delay = (ms: number) =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
setTimeout(resolve, ms); setTimeout(resolve, ms);
@@ -612,7 +677,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
<View style={settingsStyles.row}> <View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>System prompt</Text> <Text style={settingsStyles.rowTitle}>System prompt</Text>
<Text style={settingsStyles.rowHint}>Added a system prompt to all agents</Text> <Text style={settingsStyles.rowHint}>Adds a system prompt to all agents</Text>
</View> </View>
<Button <Button
variant="outline" variant="outline"
@@ -803,6 +868,18 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[1], padding: theme.spacing[1],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
}, },
daemonHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginBottom: theme.spacing[4],
},
daemonHeaderLabel: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
identityBadges: { identityBadges: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -113,6 +113,7 @@ describe("WorkspacePaneContent", () => {
isInteractive: false, isInteractive: false,
focusPane: expect.any(Function), focusPane: expect.any(Function),
}); });
expect(snapshots[0]?.paneContextValue.paneInstanceId).toBeNull();
expect(snapshots[1]?.focus).toEqual({ expect(snapshots[1]?.focus).toEqual({
isWorkspaceFocused: true, isWorkspaceFocused: true,
isPaneFocused: true, isPaneFocused: true,
@@ -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 invariant from "tiny-invariant";
import { import {
createPaneFocusContextValue, createPaneFocusContextValue,
@@ -10,6 +10,11 @@ import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels"; import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import {
clearActivePaneFindPaneId,
createPaneFindPaneId,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
export interface WorkspacePaneContentModel { export interface WorkspacePaneContentModel {
key: string; key: string;
@@ -19,6 +24,7 @@ export interface WorkspacePaneContentModel {
export interface BuildWorkspacePaneContentModelInput { export interface BuildWorkspacePaneContentModelInput {
tab: WorkspaceTabDescriptor; tab: WorkspaceTabDescriptor;
paneId?: string | null;
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string; normalizedWorkspaceId: string;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
@@ -30,6 +36,7 @@ export interface BuildWorkspacePaneContentModelInput {
export function buildWorkspacePaneContentModel({ export function buildWorkspacePaneContentModel({
tab, tab,
paneId,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
onOpenTab, onOpenTab,
@@ -41,12 +48,20 @@ export function buildWorkspacePaneContentModel({
ensurePanelsRegistered(); ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind); const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`); invariant(registration, `No panel registration for kind: ${tab.kind}`);
const paneInstanceId = paneId
? createPaneFindPaneId({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneId,
})
: null;
return { return {
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`, key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
Component: registration.component, Component: registration.component,
paneContextValue: { paneContextValue: {
serverId: normalizedServerId, serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId, workspaceId: normalizedWorkspaceId,
paneInstanceId,
tabId: tab.tabId, tabId: tab.tabId,
target: tab.target, target: tab.target,
openTab: onOpenTab, openTab: onOpenTab,
@@ -81,6 +96,16 @@ export function WorkspacePaneContent({
}), }),
[isPaneFocused, isWorkspaceFocused, onFocusPane], [isPaneFocused, isWorkspaceFocused, onFocusPane],
); );
useEffect(() => {
if (!paneContextValue.paneInstanceId || !isWorkspaceFocused || !isPaneFocused) {
return;
}
const paneInstanceId = paneContextValue.paneInstanceId;
setActivePaneFindPaneId(paneInstanceId);
return () => {
clearActivePaneFindPaneId(paneInstanceId);
};
}, [isPaneFocused, isWorkspaceFocused, paneContextValue.paneInstanceId]);
return ( return (
<PaneProvider value={paneContextValue}> <PaneProvider value={paneContextValue}>
@@ -2465,7 +2465,7 @@ function WorkspaceScreenContent({
const currentCursor = sessionState?.agentTimelineCursor.get(agentId); const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await client.fetchAgentTimeline(agentId, { await client.fetchAgentTimeline(agentId, {
direction: "tail", direction: "tail",
projection: "canonical", projection: "projected",
...(currentCursor ...(currentCursor
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } } ? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
: {}), : {}),
@@ -2827,6 +2827,7 @@ function WorkspaceScreenContent({
}) => }) =>
buildWorkspacePaneContentModel({ buildWorkspacePaneContentModel({
tab: input.tab, tab: input.tab,
paneId: input.paneId,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
onOpenTab: (target) => { onOpenTab: (target) => {
@@ -26,6 +26,16 @@ vi.mock("@xterm/addon-ligatures/lib/addon-ligatures.mjs", () => ({
vi.mock("@xterm/addon-search", () => ({ vi.mock("@xterm/addon-search", () => ({
SearchAddon: class SearchAddon { SearchAddon: class SearchAddon {
findNext(): boolean {
return false;
}
findPrevious(): boolean {
return false;
}
clearDecorations(): void {}
onDidChangeResults(): { dispose: () => void } {
return { dispose: () => {} };
}
dispose(): void {} dispose(): void {}
}, },
})); }));
@@ -91,6 +101,15 @@ interface StubTerminal {
cols?: number; 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(): { function createRuntimeWithTerminal(): {
runtime: TerminalEmulatorRuntime; runtime: TerminalEmulatorRuntime;
terminal: StubTerminal & { terminal: StubTerminal & {
@@ -145,6 +164,55 @@ function decodeTerminalOutput(data: string | Uint8Array): string {
return new TextDecoder().decode(data); 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", () => { describe("terminal-emulator-runtime", () => {
const originalWindow = (globalThis as { window?: unknown }).window; const originalWindow = (globalThis as { window?: unknown }).window;
@@ -418,4 +486,71 @@ describe("terminal-emulator-runtime", () => {
expect(fitAndEmitResize).not.toHaveBeenCalled(); 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();
});
}); });
@@ -2,11 +2,12 @@ import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image"; import { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search"; import { SearchAddon } from "@xterm/addon-search";
import type { ISearchOptions, ISearchResultChangeEvent } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11"; import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl"; import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs"; 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 { TerminalState } from "@getpaseo/protocol/messages";
import { import {
type TerminalInputModeState, type TerminalInputModeState,
@@ -61,6 +62,12 @@ export interface TerminalEmulatorRuntimeCallbacks {
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void; onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
} }
export type TerminalFindResultChangeEvent = ISearchResultChangeEvent;
export interface TerminalFindQueryInput {
query: string;
}
interface TerminalEmulatorRuntimeDisposables { interface TerminalEmulatorRuntimeDisposables {
disposeInput: () => void; disposeInput: () => void;
disconnectResizeObserver: () => void; disconnectResizeObserver: () => void;
@@ -128,6 +135,16 @@ function prependTerminalOutput(
return output; return output;
} }
const TERMINAL_FIND_SEARCH_OPTIONS: ISearchOptions = {
caseSensitive: false,
decorations: {
matchBackground: "#facc15",
matchOverviewRuler: "#facc15",
activeMatchBackground: "#38bdf8",
activeMatchColorOverviewRuler: "#38bdf8",
},
};
const DEFAULT_TERMINAL_FONT_FAMILY = [ const DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs. // Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
"JetBrains Mono", "JetBrains Mono",
@@ -164,6 +181,7 @@ export class TerminalEmulatorRuntime {
}; };
private terminal: Terminal | null = null; private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null; private fitAddon: FitAddon | null = null;
private searchAddon: SearchAddon | null = null;
private fitAndEmitResize: ((force: boolean) => void) | null = null; private fitAndEmitResize: ((force: boolean) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null; private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null; private cleanup: (() => void) | null = null;
@@ -223,6 +241,7 @@ export class TerminalEmulatorRuntime {
theme: withOverviewRulerBorderHidden(input.theme), theme: withOverviewRulerBorderHidden(input.theme),
}); });
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
const searchAddon = new SearchAddon({ highlightLimit: 20_000 });
const unicode11Addon = new Unicode11Addon(); const unicode11Addon = new Unicode11Addon();
let webglAddon: WebglAddon | null = null; let webglAddon: WebglAddon | null = null;
let imageAddon: ImageAddon | 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()); terminal.loadAddon(new ClipboardAddon());
try { try {
terminal.loadAddon(new LigaturesAddon()); terminal.loadAddon(new LigaturesAddon());
@@ -334,6 +353,7 @@ export class TerminalEmulatorRuntime {
this.terminal = terminal; this.terminal = terminal;
this.fitAddon = fitAddon; this.fitAddon = fitAddon;
this.searchAddon = searchAddon;
window.__paseoTerminal = terminal; window.__paseoTerminal = terminal;
const fitAndEmitResize = (force: boolean): void => { const fitAndEmitResize = (force: boolean): void => {
@@ -687,6 +707,25 @@ export class TerminalEmulatorRuntime {
this.terminal?.blur(); 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 { private refreshVisibleRows(): void {
const terminal = this.terminal; const terminal = this.terminal;
if (!terminal || terminal.rows <= 0) { if (!terminal || terminal.rows <= 0) {
@@ -739,6 +778,7 @@ export class TerminalEmulatorRuntime {
} }
this.terminal = null; this.terminal = null;
this.fitAddon = null; this.fitAddon = null;
this.searchAddon = null;
this.fitAndEmitResize = null; this.fitAndEmitResize = null;
this.lastSize = null; this.lastSize = null;
this.themeBackgroundElements = []; this.themeBackgroundElements = [];
@@ -547,6 +547,153 @@ describe("processTimelineResponse", () => {
}); });
}); });
it("hydrates a fetched in-progress tool call as one item and streams the next update on top", () => {
const fetched = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "tail",
payload: {
...baseTimelineInput.payload,
direction: "tail",
epoch: "epoch-1",
startCursor: { seq: 10 },
endCursor: { seq: 250 },
entries: [
{
...makeToolCallTimelineEntry(10, "call-1", "running", {
type: "read",
filePath: "/tmp/example.ts",
}),
seqEnd: 250,
sourceSeqRanges: [
{ startSeq: 10, endSeq: 10 },
{ startSeq: 250, endSeq: 250 },
],
collapsed: ["tool_lifecycle"],
},
],
},
});
expect(getAgentToolCalls(fetched.tail)).toHaveLength(1);
expect(fetched.cursor).toEqual({ epoch: "epoch-1", startSeq: 10, endSeq: 250 });
const streamed = processAgentStreamEvent({
...baseStreamInput,
currentTail: fetched.tail,
currentHead: fetched.head,
currentCursor: fetched.cursor ?? undefined,
seq: 251,
epoch: "epoch-1",
event: {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
callId: "call-1",
name: "Read",
status: "completed",
detail: {
type: "read",
filePath: "/tmp/example.ts",
},
error: null,
},
} as AgentStreamEventPayload,
});
const tools = getAgentToolCalls(streamed.tail);
expect(tools).toHaveLength(1);
expect(tools[0]?.payload.data.status).toBe("completed");
expect(streamed.cursor).toEqual({ epoch: "epoch-1", startSeq: 10, endSeq: 251 });
});
it("accepts an after-page projected tool update whose item started before the cursor", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 10,
endSeq: 249,
};
const runningTool = hydrateStreamState(
[
{
event: makeToolCallTimelineEvent("call-1"),
timestamp: new Date(1010),
},
],
{ source: "canonical" },
);
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: runningTool,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
epoch: "epoch-1",
startCursor: { seq: 250 },
endCursor: { seq: 250 },
entries: [
{
...makeToolCallTimelineEntry(10, "call-1", "completed", {
type: "read",
filePath: "/tmp/example.ts",
}),
seqEnd: 250,
sourceSeqRanges: [
{ startSeq: 10, endSeq: 10 },
{ startSeq: 250, endSeq: 250 },
],
collapsed: ["tool_lifecycle"],
},
],
},
});
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
const tools = getAgentToolCalls(result.tail);
expect(catchUp).toBeUndefined();
expect(tools).toHaveLength(1);
expect(tools[0]?.payload.data.status).toBe("completed");
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 10, endSeq: 250 });
});
it("replaces an active assistant head when after-page returns a full projected assistant item", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
};
const currentHead = [makeAssistantItem("ABC")];
const result = processTimelineResponse({
...baseTimelineInput,
currentHead,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
epoch: "epoch-1",
startCursor: { seq: 4 },
endCursor: { seq: 5 },
entries: [
{
...makeTimelineEntry(1, "ABCDE"),
seqEnd: 5,
sourceSeqRanges: [{ startSeq: 1, endSeq: 5 }],
collapsed: ["assistant_merge"],
},
],
},
});
expect(getAssistantTexts(result.tail)).toEqual([]);
expect(getAssistantTexts(result.head)).toEqual(["ABCDE"]);
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 5 });
});
it("detects gap and emits catch-up side effect", () => { it("detects gap and emits catch-up side effect", () => {
const existingCursor: TimelineCursor = { const existingCursor: TimelineCursor = {
epoch: "epoch-1", epoch: "epoch-1",
@@ -53,9 +53,16 @@ type SessionTimelineSeqCursor =
type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init"; type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
interface TimelineSeqRange {
startSeq: number;
endSeq: number;
}
interface TimelineResponseEntry { interface TimelineResponseEntry {
seqStart: number; seqStart: number;
seqEnd: number; seqEnd: number;
sourceSeqRanges?: TimelineSeqRange[];
collapsed?: string[];
provider: string; provider: string;
item: Record<string, unknown>; item: Record<string, unknown>;
timestamp: string; timestamp: string;
@@ -96,6 +103,7 @@ export interface ProcessTimelineResponseOutput {
interface TimelineUnit { interface TimelineUnit {
seq: number; seq: number;
seqEnd: number; seqEnd: number;
sourceSeqRanges: TimelineSeqRange[];
event: AgentStreamEventPayload; event: AgentStreamEventPayload;
timestamp: Date; timestamp: Date;
} }
@@ -376,44 +384,56 @@ function acceptIncrementalTimelineUnits(args: {
currentCursor: TimelineCursor | undefined; currentCursor: TimelineCursor | undefined;
}): IncrementalAcceptResult { }): IncrementalAcceptResult {
const { timelineUnits, payload, currentCursor } = args; const { timelineUnits, payload, currentCursor } = args;
const acceptedUnits: TimelineUnit[] = []; const firstUnit = timelineUnits[0];
let cursor: TimelineCursor | undefined = currentCursor; const lastUnit = timelineUnits[timelineUnits.length - 1];
let gapCursor: { epoch: string; endSeq: number } | null = null; const responseStartSeq = payload.startCursor?.seq ?? firstUnit?.seq;
const responseEndSeq = payload.endCursor?.seq ?? lastUnit?.seqEnd;
for (const unit of timelineUnits) { if (responseStartSeq === undefined || responseEndSeq === undefined) {
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({ return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
epoch: payload.epoch,
seq: unit.seq,
});
if (decision === "gap") {
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
break;
}
if (decision === "drop_stale") {
if (cursor && unit.seqEnd > cursor.endSeq) {
gapCursor = { epoch: cursor.epoch, endSeq: cursor.endSeq };
break;
}
continue;
}
if (decision === "drop_epoch") {
continue;
}
acceptedUnits.push(unit);
if (decision === "init") {
cursor = { epoch: payload.epoch, startSeq: unit.seq, endSeq: unit.seqEnd };
continue;
}
if (!cursor) {
continue;
}
cursor = { ...cursor, endSeq: unit.seqEnd };
} }
return { acceptedUnits, cursor, gapCursor }; if (!currentCursor) {
return {
acceptedUnits: timelineUnits,
cursor: { epoch: payload.epoch, startSeq: responseStartSeq, endSeq: responseEndSeq },
gapCursor: null,
};
}
if (currentCursor.epoch !== payload.epoch) {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
}
if (
(!payload.startCursor || !payload.endCursor) &&
responseStartSeq <= currentCursor.endSeq &&
responseEndSeq > currentCursor.endSeq
) {
return {
acceptedUnits: [],
cursor: currentCursor,
gapCursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq },
};
}
if (responseEndSeq <= currentCursor.endSeq) {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
}
if (responseStartSeq > currentCursor.endSeq + 1) {
return {
acceptedUnits: [],
cursor: currentCursor,
gapCursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq },
};
}
return {
acceptedUnits: timelineUnits,
cursor: { ...currentCursor, endSeq: responseEndSeq },
gapCursor: null,
};
} }
function acceptOlderTimelineUnits(args: { function acceptOlderTimelineUnits(args: {
@@ -426,16 +446,21 @@ function acceptOlderTimelineUnits(args: {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null }; return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
} }
const acceptedUnits = timelineUnits.filter((unit) => unit.seqEnd < currentCursor.startSeq); const firstUnit = timelineUnits[0];
if (acceptedUnits.length === 0) { const lastUnit = timelineUnits[timelineUnits.length - 1];
return { acceptedUnits, cursor: currentCursor, gapCursor: null }; const responseStartSeq = payload.startCursor?.seq ?? firstUnit?.seq;
const responseEndSeq = payload.endCursor?.seq ?? lastUnit?.seqEnd;
if (
responseStartSeq === undefined ||
responseEndSeq === undefined ||
responseEndSeq >= currentCursor.startSeq
) {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
} }
const firstAccepted = acceptedUnits[0];
const startSeq = payload.startCursor?.seq ?? firstAccepted?.seq ?? currentCursor.startSeq;
return { return {
acceptedUnits, acceptedUnits: timelineUnits,
cursor: { ...currentCursor, startSeq }, cursor: { ...currentCursor, startSeq: responseStartSeq },
gapCursor: null, gapCursor: null,
}; };
} }
@@ -480,6 +505,32 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
]; ];
} }
function replaceLiveAssistantWithProjectedText(params: {
head: StreamItem[];
event: AgentStreamEventPayload;
timestamp: Date;
}): StreamItem[] | null {
const { head, event, timestamp } = params;
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
return null;
}
const index = head.findLastIndex((item) => item.kind === "assistant_message");
const current = head[index];
if (!current || current.kind !== "assistant_message") {
return null;
}
if (!event.item.text.startsWith(current.text)) {
return null;
}
const next = [...head];
next[index] = {
...current,
text: event.item.text,
timestamp,
};
return next;
}
function applyTimelineIncrementalPath(args: { function applyTimelineIncrementalPath(args: {
timelineUnits: TimelineUnit[]; timelineUnits: TimelineUnit[];
payload: ProcessTimelineResponseInput["payload"]; payload: ProcessTimelineResponseInput["payload"];
@@ -523,6 +574,15 @@ function applyTimelineIncrementalPath(args: {
nextTail = mergePrependedCanonicalTail(olderTail, currentTail); nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
} else if (currentHead.length > 0) { } else if (currentHead.length > 0) {
for (const { event, timestamp } of acceptedUnits) { for (const { event, timestamp } of acceptedUnits) {
const replacedHead = replaceLiveAssistantWithProjectedText({
head: nextHead,
event,
timestamp,
});
if (replacedHead) {
nextHead = replacedHead;
continue;
}
const applied = applyStreamEvent({ const applied = applyStreamEvent({
tail: nextTail, tail: nextTail,
head: nextHead, head: nextHead,
@@ -597,6 +657,10 @@ export function processTimelineResponse(
const timelineUnits = payload.entries.map((entry) => ({ const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seqStart, seq: entry.seqStart,
seqEnd: entry.seqEnd, seqEnd: entry.seqEnd,
sourceSeqRanges:
entry.sourceSeqRanges && entry.sourceSeqRanges.length > 0
? entry.sourceSeqRanges
: [{ startSeq: entry.seqStart, endSeq: entry.seqEnd }],
event: { event: {
type: "timeline", type: "timeline",
provider: entry.provider, provider: entry.provider,
@@ -1,2 +1,3 @@
// Count is projected timeline items, not delta chunks. The daemon's `selectTimelineWindowByProjectedLimit` interprets this against canonical entries: `assistant_merge`, `reasoning_merge`, and `tool_lifecycle`. Do not confuse this with raw stream deltas. // Count is projected timeline items, not delta chunks. Fetch responses never return
// tool lifecycle deltas; `sourceSeqRanges` maps projected items back to source seqs.
export const TIMELINE_FETCH_PAGE_SIZE = 100; export const TIMELINE_FETCH_PAGE_SIZE = 100;
@@ -18,7 +18,7 @@ describe("timeline sync planning", () => {
expect(plan).toEqual({ expect(plan).toEqual({
direction: "tail", direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
}); });
}); });
@@ -32,7 +32,7 @@ describe("timeline sync planning", () => {
direction: "after", direction: "after",
cursor: { epoch: "epoch-1", seq: 42 }, cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
}); });
}); });
@@ -45,7 +45,7 @@ describe("timeline sync planning", () => {
direction: "after", direction: "after",
cursor: { epoch: "epoch-1", seq: 100 }, cursor: { epoch: "epoch-1", seq: 100 },
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
}); });
}); });
@@ -55,7 +55,7 @@ describe("timeline sync planning", () => {
expect(plan).toEqual({ expect(plan).toEqual({
direction: "tail", direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
}); });
}); });
@@ -66,7 +66,7 @@ describe("timeline sync planning", () => {
direction: "before", direction: "before",
cursor: { epoch: "epoch-1", seq: 25 }, cursor: { epoch: "epoch-1", seq: 25 },
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
}); });
}); });
@@ -82,7 +82,7 @@ describe("timeline sync planning", () => {
direction: "after", direction: "after",
cursor: { epoch: "epoch-1", seq: 200 }, cursor: { epoch: "epoch-1", seq: 200 },
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
}); });
}); });
+19 -19
View File
@@ -11,39 +11,39 @@ export interface AgentTimelineCursorRange {
endSeq: number; endSeq: number;
} }
export interface CanonicalTimelineTailFetchPlan { export interface ProjectedTimelineTailFetchPlan {
direction: "tail"; direction: "tail";
limit: number; limit: number;
projection: "canonical"; projection: "projected";
} }
export interface CanonicalTimelineAfterFetchPlan { export interface ProjectedTimelineAfterFetchPlan {
direction: "after"; direction: "after";
cursor: TimelineSyncCursor; cursor: TimelineSyncCursor;
limit: number; limit: number;
projection: "canonical"; projection: "projected";
} }
export interface CanonicalTimelineBeforeFetchPlan { export interface ProjectedTimelineBeforeFetchPlan {
direction: "before"; direction: "before";
cursor: TimelineSyncCursor; cursor: TimelineSyncCursor;
limit: number; limit: number;
projection: "canonical"; projection: "projected";
} }
export type CanonicalTimelineFetchPlan = export type ProjectedTimelineFetchPlan =
| CanonicalTimelineTailFetchPlan | ProjectedTimelineTailFetchPlan
| CanonicalTimelineAfterFetchPlan | ProjectedTimelineAfterFetchPlan
| CanonicalTimelineBeforeFetchPlan; | ProjectedTimelineBeforeFetchPlan;
export type CanonicalTimelineForwardFetchPlan = export type ProjectedTimelineForwardFetchPlan =
| CanonicalTimelineTailFetchPlan | ProjectedTimelineTailFetchPlan
| CanonicalTimelineAfterFetchPlan; | ProjectedTimelineAfterFetchPlan;
export function planInitialAgentTimelineSync(input: { export function planInitialAgentTimelineSync(input: {
cursor: AgentTimelineCursorRange | undefined; cursor: AgentTimelineCursorRange | undefined;
hasAuthoritativeHistory: boolean; hasAuthoritativeHistory: boolean;
}): CanonicalTimelineForwardFetchPlan { }): ProjectedTimelineForwardFetchPlan {
if (input.hasAuthoritativeHistory && input.cursor) { if (input.hasAuthoritativeHistory && input.cursor) {
return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq }); return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq });
} }
@@ -53,7 +53,7 @@ export function planInitialAgentTimelineSync(input: {
export function planResumeTimelineSync(input: { export function planResumeTimelineSync(input: {
cursor: AgentTimelineCursorRange | undefined; cursor: AgentTimelineCursorRange | undefined;
}): CanonicalTimelineForwardFetchPlan { }): ProjectedTimelineForwardFetchPlan {
if (input.cursor) { if (input.cursor) {
return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq }); return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq });
} }
@@ -66,7 +66,7 @@ export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) {
direction: "after", direction: "after",
cursor, cursor,
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
} as const; } as const;
} }
@@ -74,7 +74,7 @@ export function planTimelineTailFetch() {
return { return {
direction: "tail", direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
} as const; } as const;
} }
@@ -83,7 +83,7 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
direction: "before", direction: "before",
cursor, cursor,
limit: TIMELINE_FETCH_PAGE_SIZE, limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical", projection: "projected",
} as const; } as const;
} }
@@ -92,7 +92,7 @@ export function planTimelineCatchUpFollowUp(input: {
hasNewer: boolean; hasNewer: boolean;
endCursor: TimelineSyncCursor | null; endCursor: TimelineSyncCursor | null;
error: string | null; error: string | null;
}): CanonicalTimelineAfterFetchPlan | null { }): ProjectedTimelineAfterFetchPlan | null {
if (input.error || input.direction !== "after" || !input.hasNewer || !input.endCursor) { if (input.error || input.direction !== "after" || !input.hasNewer || !input.endCursor) {
return null; return null;
} }
-33
View File
@@ -99,19 +99,6 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea
}; };
} }
function planTimeline(): AgentStreamEventPayload {
return {
type: "timeline",
provider: "codex",
item: {
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Implement it",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
};
}
function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined { function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined {
return state.find( return state.find(
(item): item is AgentToolCallItem => (item): item is AgentToolCallItem =>
@@ -706,26 +693,6 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(todos.items[1]?.completed, true); assert.strictEqual(todos.items[1]?.completed, true);
}); });
it("converts plan timeline updates to plan items", () => {
const state = hydrateStreamState([
{
event: planTimeline(),
timestamp: new Date("2025-01-01T10:55:00Z"),
},
]);
const plan = state.find(
(item): item is Extract<StreamItem, { kind: "plan" }> => item.kind === "plan",
);
assert.ok(plan);
assert.strictEqual(plan.planId, "plan-1");
assert.strictEqual(plan.text, "# Plan\n\n- Implement it");
assert.deepStrictEqual(plan.actions, [
{ id: "implement", label: "Implement", variant: "primary" },
]);
});
it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => { it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => {
const state = hydrateStreamState([ const state = hydrateStreamState([
{ {
+1 -46
View File
@@ -1,8 +1,4 @@
import type { import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types";
AgentPlanAction,
AgentProvider,
ToolCallDetail,
} from "@getpaseo/protocol/agent-types";
import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers"; import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
@@ -52,7 +48,6 @@ export type StreamItem =
| AssistantMessageItem | AssistantMessageItem
| ThoughtItem | ThoughtItem
| ToolCallItem | ToolCallItem
| PlanItem
| TodoListItem | TodoListItem
| ActivityLogItem | ActivityLogItem
| CompactionItem; | CompactionItem;
@@ -173,16 +168,6 @@ export interface TodoListItem {
items: TodoEntry[]; items: TodoEntry[];
} }
export interface PlanItem {
kind: "plan";
id: string;
timestamp: Date;
provider: AgentProvider;
planId: string;
text: string;
actions?: AgentPlanAction[];
}
export type StreamUpdateSource = "live" | "canonical"; export type StreamUpdateSource = "live" | "canonical";
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -668,34 +653,6 @@ function appendTodoList(
return [...state, entry]; return [...state, entry];
} }
function appendPlan(
state: StreamItem[],
provider: AgentProvider,
plan: { planId: string; text: string; actions?: AgentPlanAction[] },
timestamp: Date,
): StreamItem[] {
const existingIndex = state.findIndex(
(item) => item.kind === "plan" && item.provider === provider && item.planId === plan.planId,
);
const entry: PlanItem = {
kind: "plan",
id: `plan_${plan.planId}`,
timestamp,
provider,
planId: plan.planId,
text: plan.text,
actions: plan.actions,
};
if (existingIndex >= 0) {
const next = [...state];
next[existingIndex] = entry;
return next;
}
return [...state, entry];
}
function reduceTimelineToolCall( function reduceTimelineToolCall(
state: StreamItem[], state: StreamItem[],
event: Extract<AgentStreamEventPayload, { type: "timeline" }>, event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
@@ -817,8 +774,6 @@ function reduceTimelineEvent(
})); }));
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp)); return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
} }
case "plan":
return finalizeActiveThoughts(appendPlan(state, event.provider, item, timestamp));
case "error": { case "error": {
const activity: ActivityLogItem = { const activity: ActivityLogItem = {
kind: "activity_log", kind: "activity_log",
+16
View File
@@ -390,6 +390,14 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value); return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value);
} }
export const HOST_SECTION_SLUGS = ["connections", "orchestration", "providers", "daemon"] as const;
export type HostSectionSlug = (typeof HOST_SECTION_SLUGS)[number];
export function isHostSectionSlug(value: string): value is HostSectionSlug {
return (HOST_SECTION_SLUGS as readonly string[]).includes(value);
}
export function buildSettingsRoute() { export function buildSettingsRoute() {
return "/settings" as const; return "/settings" as const;
} }
@@ -406,6 +414,14 @@ export function buildSettingsHostRoute(serverId: string) {
return `/settings/hosts/${encodeSegment(normalized)}` as const; return `/settings/hosts/${encodeSegment(normalized)}` as const;
} }
export function buildSettingsHostSectionRoute(serverId: string, section: HostSectionSlug) {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
throw new Error("buildSettingsHostSectionRoute requires a non-empty serverId");
}
return `/settings/hosts/${encodeSegment(normalized)}/${section}` as const;
}
export function buildProjectsSettingsRoute() { export function buildProjectsSettingsRoute() {
return "/settings/projects" as const; return "/settings/projects" as const;
} }
@@ -255,7 +255,6 @@ test("advertises client capabilities in hello", async () => {
protocolVersion: 1, protocolVersion: 1,
capabilities: { capabilities: {
custom_mode_icons: true, custom_mode_icons: true,
first_class_plans: true,
reasoning_merge_enum: true, reasoning_merge_enum: true,
}, },
}); });
-38
View File
@@ -84,7 +84,6 @@ import type {
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentPlanResponse,
AgentPersistenceHandle, AgentPersistenceHandle,
AgentProvider, AgentProvider,
AgentSessionConfig, AgentSessionConfig,
@@ -364,10 +363,6 @@ type DictationFinishAcceptedPayload = Extract<
{ type: "dictation_stream_finish_accepted" } { type: "dictation_stream_finish_accepted" }
>["payload"]; >["payload"];
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
type AgentPlanRespondPayload = Extract<
SessionOutboundMessage,
{ type: "agent.plan.respond.response" }
>["payload"];
type ListTerminalsPayload = ListTerminalsResponse["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"];
export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"]; export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"];
@@ -3618,38 +3613,6 @@ export class DaemonClient {
}); });
} }
async respondToPlan(
agentId: string,
planId: string,
response: AgentPlanResponse,
requestId = `plan-response-${Date.now()}-${Math.random().toString(36).slice(2)}`,
timeout = 15000,
): Promise<AgentPlanRespondPayload> {
const message = SessionInboundMessageSchema.parse({
type: "agent.plan.respond.request",
agentId,
planId,
actionId: response.actionId,
...(response.feedback !== undefined ? { feedback: response.feedback } : {}),
requestId,
});
return this.sendRequest({
requestId,
message,
timeout,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "agent.plan.respond.response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
});
}
// ============================================================================ // ============================================================================
// Waiting / Streaming Helpers // Waiting / Streaming Helpers
// ============================================================================ // ============================================================================
@@ -4315,7 +4278,6 @@ export class DaemonClient {
protocolVersion: 1, protocolVersion: 1,
capabilities: { capabilities: {
[CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.customModeIcons]: true,
[CLIENT_CAPS.firstClassPlans]: true,
[CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.reasoningMergeEnum]: true,
}, },
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
@@ -1,16 +1,71 @@
import { webContents as allWebContents, type WebContents } from "electron"; import { webContents as allWebContents, type WebContents } from "electron";
export const BROWSER_FOUND_IN_PAGE_EVENT = "paseo:event:browser-found-in-page";
const browserIdsByWebContentsId = new Map<number, string>(); const browserIdsByWebContentsId = new Map<number, string>();
const webContentsIdsByBrowserId = new Map<string, number>();
const ownerWebContentsIdsByBrowserId = new Map<string, number>();
const activeFindBrowserIdsByOwnerWebContentsId = new Map<number, string>();
const ownerFoundInPageListenerWebContentsIds = new Set<number>();
let workspaceActiveBrowserId: string | null = null; let workspaceActiveBrowserId: string | null = null;
export function listRegisteredPaseoBrowserIds(): string[] { export function listRegisteredPaseoBrowserIds(): string[] {
return Array.from(new Set(browserIdsByWebContentsId.values())).sort(); return Array.from(new Set(browserIdsByWebContentsId.values())).sort();
} }
export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void { function ensureOwnerFoundInPageListener(ownerContents: WebContents): void {
if (ownerFoundInPageListenerWebContentsIds.has(ownerContents.id)) {
return;
}
ownerFoundInPageListenerWebContentsIds.add(ownerContents.id);
const handleFoundInPage = (_event: Electron.Event, result: Electron.Result): void => {
const browserId = activeFindBrowserIdsByOwnerWebContentsId.get(ownerContents.id);
if (!browserId || ownerContents.isDestroyed()) {
return;
}
ownerContents.send(BROWSER_FOUND_IN_PAGE_EVENT, {
browserId,
requestId: result.requestId,
activeMatchOrdinal: result.activeMatchOrdinal,
matches: result.matches,
finalUpdate: result.finalUpdate,
});
if (result.finalUpdate) {
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContents.id);
}
};
ownerContents.on("found-in-page", handleFoundInPage);
ownerContents.once("destroyed", () => {
ownerContents.removeListener("found-in-page", handleFoundInPage);
ownerFoundInPageListenerWebContentsIds.delete(ownerContents.id);
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContents.id);
});
}
export function registerPaseoBrowserWebContents(
contents: WebContents,
browserId: string,
ownerContents: WebContents,
): void {
browserIdsByWebContentsId.set(contents.id, browserId); browserIdsByWebContentsId.set(contents.id, browserId);
webContentsIdsByBrowserId.set(browserId, contents.id);
if (!ownerContents.isDestroyed()) {
ownerWebContentsIdsByBrowserId.set(browserId, ownerContents.id);
ensureOwnerFoundInPageListener(ownerContents);
}
contents.once("destroyed", () => { contents.once("destroyed", () => {
browserIdsByWebContentsId.delete(contents.id); browserIdsByWebContentsId.delete(contents.id);
if (webContentsIdsByBrowserId.get(browserId) === contents.id) {
webContentsIdsByBrowserId.delete(browserId);
const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId);
ownerWebContentsIdsByBrowserId.delete(browserId);
if (
ownerContentsId &&
activeFindBrowserIdsByOwnerWebContentsId.get(ownerContentsId) === browserId
) {
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContentsId);
}
}
if (workspaceActiveBrowserId === browserId) { if (workspaceActiveBrowserId === browserId) {
workspaceActiveBrowserId = null; workspaceActiveBrowserId = null;
} }
@@ -29,14 +84,35 @@ export function setWorkspaceActivePaseoBrowserId(browserId: string | null): void
} }
export function getPaseoBrowserWebContents(browserId: string): WebContents | null { export function getPaseoBrowserWebContents(browserId: string): WebContents | null {
for (const [contentsId, registeredBrowserId] of browserIdsByWebContentsId) { const contentsId = webContentsIdsByBrowserId.get(browserId);
if (registeredBrowserId !== browserId) continue; if (!contentsId) {
const contents = allWebContents.fromId(contentsId); return null;
if (contents && !contents.isDestroyed()) { }
return contents; const contents = allWebContents.fromId(contentsId);
} return contents && !contents.isDestroyed() ? contents : null;
}
export function setActivePaseoBrowserFind(browserId: string): void {
const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId);
if (!ownerContentsId) {
return;
}
const ownerContents = allWebContents.fromId(ownerContentsId);
if (!ownerContents || ownerContents.isDestroyed()) {
return;
}
ensureOwnerFoundInPageListener(ownerContents);
activeFindBrowserIdsByOwnerWebContentsId.set(ownerContents.id, browserId);
}
export function clearActivePaseoBrowserFind(browserId: string): void {
const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId);
if (!ownerContentsId) {
return;
}
if (activeFindBrowserIdsByOwnerWebContentsId.get(ownerContentsId) === browserId) {
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContentsId);
} }
return null;
} }
export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null { export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null {
+45 -1
View File
@@ -33,10 +33,12 @@ import {
import { registerOpenerHandlers } from "./features/opener.js"; import { registerOpenerHandlers } from "./features/opener.js";
import { setupApplicationMenu } from "./features/menu.js"; import { setupApplicationMenu } from "./features/menu.js";
import { import {
clearActivePaseoBrowserFind,
getPaseoBrowserIdForWebContents, getPaseoBrowserIdForWebContents,
getPaseoBrowserWebContents, getPaseoBrowserWebContents,
listRegisteredPaseoBrowserIds, listRegisteredPaseoBrowserIds,
registerPaseoBrowserWebContents, registerPaseoBrowserWebContents,
setActivePaseoBrowserFind,
setWorkspaceActivePaseoBrowserId, setWorkspaceActivePaseoBrowserId,
} from "./features/browser-webviews.js"; } from "./features/browser-webviews.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
@@ -310,6 +312,48 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow
await session.fromPartition(partition).clearStorageData(); await session.fromPartition(partition).clearStorageData();
}); });
ipcMain.handle(
"paseo:browser:find-in-page",
(_event, browserId: unknown, text: unknown, options: unknown): number | null => {
if (typeof browserId !== "string" || typeof text !== "string" || text.length === 0) {
return null;
}
const contents = getPaseoBrowserWebContents(browserId);
if (!contents) {
return null;
}
setActivePaseoBrowserFind(browserId);
const inputOptions =
options && typeof options === "object"
? (options as { forward?: unknown; findNext?: unknown; matchCase?: unknown })
: {};
return contents.findInPage(text, {
forward: inputOptions.forward !== false,
findNext: inputOptions.findNext === true,
matchCase: inputOptions.matchCase === true,
});
},
);
ipcMain.handle(
"paseo:browser:stop-find-in-page",
(_event, browserId: unknown, action: unknown): null => {
if (typeof browserId !== "string") {
return null;
}
if (
action !== "clearSelection" &&
action !== "keepSelection" &&
action !== "activateSelection"
) {
return null;
}
getPaseoBrowserWebContents(browserId)?.stopFindInPage(action);
clearActivePaseoBrowserFind(browserId);
return null;
},
);
protocol.registerSchemesAsPrivileged([ protocol.registerSchemesAsPrivileged([
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
]); ]);
@@ -431,7 +475,7 @@ async function createMainWindow(): Promise<void> {
mainWindow.webContents.on("did-attach-webview", (_event, contents) => { mainWindow.webContents.on("did-attach-webview", (_event, contents) => {
const browserId = pendingBrowserWebviewIds.shift() ?? null; const browserId = pendingBrowserWebviewIds.shift() ?? null;
if (browserId) { if (browserId) {
registerPaseoBrowserWebContents(contents, browserId); registerPaseoBrowserWebContents(contents, browserId, mainWindow.webContents);
log.info("[browser-webview] registered", { log.info("[browser-webview] registered", {
browserId, browserId,
webContentsId: contents.id, webContentsId: contents.id,
+34
View File
@@ -1,6 +1,14 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"; import { contextBridge, ipcRenderer, webUtils } from "electron";
type EventHandler = (payload: unknown) => void; type EventHandler = (payload: unknown) => void;
type BrowserFindAction = "clearSelection" | "keepSelection" | "activateSelection";
interface BrowserFoundInPageResult {
requestId: number;
activeMatchOrdinal: number;
matches: number;
finalUpdate: boolean;
}
contextBridge.exposeInMainWorld("paseoDesktop", { contextBridge.exposeInMainWorld("paseoDesktop", {
platform: process.platform, platform: process.platform,
@@ -69,5 +77,31 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
ipcRenderer.invoke("paseo:browser:open-devtools", browserId), ipcRenderer.invoke("paseo:browser:open-devtools", browserId),
clearPartition: (browserId: string) => clearPartition: (browserId: string) =>
ipcRenderer.invoke("paseo:browser:clear-partition", browserId), ipcRenderer.invoke("paseo:browser:clear-partition", browserId),
findInPage: (
browserId: string,
text: string,
options?: { forward?: boolean; findNext?: boolean; matchCase?: boolean },
) => ipcRenderer.invoke("paseo:browser:find-in-page", browserId, text, options),
stopFindInPage: (browserId: string, action: BrowserFindAction) =>
ipcRenderer.invoke("paseo:browser:stop-find-in-page", browserId, action),
onFoundInPage: (
browserId: string,
listener: (result: BrowserFoundInPageResult) => void,
): (() => void) => {
const ipcListener = (
_ipcEvent: Electron.IpcRendererEvent,
payload: BrowserFoundInPageResult & { browserId?: unknown },
) => {
if (payload?.browserId !== browserId) {
return;
}
const { browserId: _browserId, ...result } = payload;
listener(result);
};
ipcRenderer.on("paseo:event:browser-found-in-page", ipcListener);
return () => {
ipcRenderer.removeListener("paseo:event:browser-found-in-page", ipcListener);
};
},
}, },
}); });
-20
View File
@@ -313,32 +313,12 @@ export interface CompactionTimelineItem {
preTokens?: number; preTokens?: number;
} }
export interface AgentPlanAction {
id: string;
label: string;
variant?: "primary" | "secondary" | "danger";
}
export interface PlanTimelineItem {
[key: string]: unknown;
type: "plan";
planId: string;
text: string;
actions?: AgentPlanAction[];
}
export interface AgentPlanResponse {
actionId: string;
feedback?: string;
}
export type AgentTimelineItem = export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string } | { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string; messageId?: string } | { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string } | { type: "reasoning"; text: string }
| ToolCallTimelineItem | ToolCallTimelineItem
| { type: "todo"; items: { text: string; completed: boolean }[] } | { type: "todo"; items: { text: string; completed: boolean }[] }
| PlanTimelineItem
| { type: "error"; message: string } | { type: "error"; message: string }
| CompactionTimelineItem; | CompactionTimelineItem;
@@ -1,5 +1,4 @@
export const CLIENT_CAPS = { export const CLIENT_CAPS = {
firstClassPlans: "first_class_plans",
reasoningMergeEnum: "reasoning_merge_enum", reasoningMergeEnum: "reasoning_merge_enum",
// COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to // COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to
// a closed enum and crash rendering unknown values; daemon downgrades icons // a closed enum and crash rendering unknown values; daemon downgrades icons
@@ -179,13 +179,11 @@ describe("checkout PR schemas", () => {
features: { features: {
providersSnapshot: true, providersSnapshot: true,
checkoutGithubSetAutoMerge: true, checkoutGithubSetAutoMerge: true,
firstClassPlans: true,
}, },
}).features, }).features,
).toEqual({ ).toEqual({
providersSnapshot: true, providersSnapshot: true,
checkoutGithubSetAutoMerge: true, checkoutGithubSetAutoMerge: true,
firstClassPlans: true,
}); });
}); });
}); });
-38
View File
@@ -153,7 +153,6 @@ import type {
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentPersistenceHandle, AgentPersistenceHandle,
AgentPlanAction,
ProviderStatus, ProviderStatus,
AgentRuntimeInfo, AgentRuntimeInfo,
AgentTimelineItem, AgentTimelineItem,
@@ -335,12 +334,6 @@ export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
}), }),
]); ]);
const AgentPlanActionSchema: z.ZodType<AgentPlanAction> = z.object({
id: z.string(),
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
});
export const AgentPermissionRequestPayloadSchema: z.ZodType< export const AgentPermissionRequestPayloadSchema: z.ZodType<
AgentPermissionRequest, AgentPermissionRequest,
z.ZodTypeDef, z.ZodTypeDef,
@@ -556,12 +549,6 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, z.ZodT
}), }),
), ),
}), }),
z.object({
type: z.literal("plan"),
planId: z.string(),
text: z.string(),
actions: z.array(AgentPlanActionSchema).optional(),
}),
z.object({ z.object({
type: z.literal("error"), type: z.literal("error"),
message: z.string(), message: z.string(),
@@ -1337,15 +1324,6 @@ export const AgentPermissionResponseMessageSchema = z.object({
response: AgentPermissionResponseSchema, response: AgentPermissionResponseSchema,
}); });
export const AgentPlanRespondRequestMessageSchema = z.object({
type: z.literal("agent.plan.respond.request"),
agentId: z.string(),
planId: z.string(),
actionId: z.string(),
feedback: z.string().optional(),
requestId: z.string(),
});
const CheckoutErrorCodeSchema = z.enum([ const CheckoutErrorCodeSchema = z.enum([
"NOT_GIT_REPO", "NOT_GIT_REPO",
"NOT_ALLOWED", "NOT_ALLOWED",
@@ -1923,7 +1901,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentFeatureRequestMessageSchema, SetAgentFeatureRequestMessageSchema,
AgentRewindRequestMessageSchema, AgentRewindRequestMessageSchema,
AgentPermissionResponseMessageSchema, AgentPermissionResponseMessageSchema,
AgentPlanRespondRequestMessageSchema,
CheckoutStatusRequestSchema, CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema, SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema, UnsubscribeCheckoutDiffRequestSchema,
@@ -2161,8 +2138,6 @@ export const ServerInfoStatusPayloadSchema = z
.object({ .object({
providersSnapshot: z.boolean().optional(), providersSnapshot: z.boolean().optional(),
checkoutGithubSetAutoMerge: z.boolean().optional(), checkoutGithubSetAutoMerge: z.boolean().optional(),
// COMPAT(firstClassPlans): added in v0.1.82, remove gate after 2026-11-28.
firstClassPlans: z.boolean().optional(),
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: z.boolean().optional(), daemonStatusRpc: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
@@ -2729,17 +2704,6 @@ export const SendAgentMessageResponseMessageSchema = z.object({
}), }),
}); });
export const AgentPlanRespondResponseMessageSchema = z.object({
type: z.literal("agent.plan.respond.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
planId: z.string(),
ok: z.boolean(),
error: z.string().nullable().optional(),
}),
});
export const WaitForFinishResponseMessageSchema = z.object({ export const WaitForFinishResponseMessageSchema = z.object({
type: z.literal("wait_for_finish_response"), type: z.literal("wait_for_finish_response"),
payload: z.object({ payload: z.object({
@@ -3729,7 +3693,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CancelAgentResponseMessageSchema, CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema, ClearAgentAttentionResponseMessageSchema,
SendAgentMessageResponseMessageSchema, SendAgentMessageResponseMessageSchema,
AgentPlanRespondResponseMessageSchema,
SetVoiceModeResponseMessageSchema, SetVoiceModeResponseMessageSchema,
DaemonGetStatusResponseSchema, DaemonGetStatusResponseSchema,
DaemonGetPairingOfferResponseSchema, DaemonGetPairingOfferResponseSchema,
@@ -4143,7 +4106,6 @@ export const WSHelloMessageSchema = z.object({
.object({ .object({
voice: z.boolean().optional(), voice: z.boolean().optional(),
pushNotifications: z.boolean().optional(), pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.firstClassPlans]: z.boolean().optional(),
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(), [CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(), [CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
}) })
@@ -19,8 +19,6 @@ import {
type AgentLaunchContext, type AgentLaunchContext,
type AgentSlashCommand, type AgentSlashCommand,
type AgentMode, type AgentMode,
type AgentPlanResponse,
type AgentPlanResult,
type AgentPermissionRequest, type AgentPermissionRequest,
type AgentPermissionResponse, type AgentPermissionResponse,
type AgentPermissionResult, type AgentPermissionResult,
@@ -1838,31 +1836,6 @@ export class AgentManager {
} }
} }
async respondToPlan(
agentId: string,
planId: string,
response: AgentPlanResponse,
): Promise<AgentPlanResult | void> {
const agent = this.requireAgent(agentId);
if (!agent.session.respondToPlan) {
throw new Error(`Agent provider '${agent.provider}' does not support plan responses`);
}
const result = await agent.session.respondToPlan(planId, response);
try {
await this.refreshSessionState(agent);
} catch {
// Ignore refresh errors - state sync after plan response is best effort.
}
this.touchUpdatedAt(agent);
await this.persistSnapshot(agent);
this.emitState(agent);
return result;
}
async cancelAgentRun(agentId: string): Promise<boolean> { async cancelAgentRun(agentId: string): Promise<boolean> {
const agent = this.requireSessionAgent(agentId); const agent = this.requireSessionAgent(agentId);
const pendingRun = this.foregroundRuns.getPendingRun(agentId); const pendingRun = this.foregroundRuns.getPendingRun(agentId);
@@ -342,27 +342,12 @@ export interface CompactionTimelineItem {
preTokens?: number; preTokens?: number;
} }
export interface AgentPlanAction {
id: string;
label: string;
variant?: "primary" | "secondary" | "danger";
}
export interface PlanTimelineItem {
[key: string]: unknown;
type: "plan";
planId: string;
text: string;
actions?: AgentPlanAction[];
}
export type AgentTimelineItem = export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string } | { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string; messageId?: string } | { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string } | { type: "reasoning"; text: string }
| ToolCallTimelineItem | ToolCallTimelineItem
| { type: "todo"; items: { text: string; completed: boolean }[] } | { type: "todo"; items: { text: string; completed: boolean }[] }
| PlanTimelineItem
| { type: "error"; message: string } | { type: "error"; message: string }
| CompactionTimelineItem; | CompactionTimelineItem;
@@ -566,15 +551,6 @@ export interface AgentPermissionResult {
followUpPrompt?: AgentPromptInput; followUpPrompt?: AgentPromptInput;
} }
export interface AgentPlanResponse {
actionId: string;
feedback?: string;
}
export interface AgentPlanResult {
followUpPrompt?: AgentPromptInput;
}
export interface AgentSession { export interface AgentSession {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly id: string | null; readonly id: string | null;
@@ -593,7 +569,6 @@ export interface AgentSession {
requestId: string, requestId: string,
response: AgentPermissionResponse, response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>; ): Promise<AgentPermissionResult | void>;
respondToPlan?(planId: string, response: AgentPlanResponse): Promise<AgentPlanResult | void>;
describePersistence(): AgentPersistenceHandle | null; describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>; interrupt(): Promise<void>;
close(): Promise<void>; close(): Promise<void>;
@@ -1,41 +0,0 @@
import { describe, expect, test } from "vitest";
import { isPlanFilePath, planItemFromToolCall } from "./plan-files.js";
describe("plan file detection", () => {
test("accepts only narrow Paseo and OpenCode plan markdown paths", () => {
expect(isPlanFilePath(".paseo/plans/feature.md")).toBe(true);
expect(isPlanFilePath("/Users/me/project/.paseo/plans/feature.markdown")).toBe(true);
expect(isPlanFilePath(".opencode/plans/refactor.md")).toBe(true);
expect(isPlanFilePath("/Users/me/.opencode/plans/refactor.markdown")).toBe(true);
expect(isPlanFilePath("PLAN.md")).toBe(false);
expect(isPlanFilePath("docs/plan.md")).toBe(false);
expect(isPlanFilePath(".paseo/notes/feature.md")).toBe(false);
expect(isPlanFilePath(".paseo/plans/feature.txt")).toBe(false);
});
test("turns successful plan writes into non-actionable plan items", async () => {
const item = await planItemFromToolCall({
cwd: "/workspace",
homeDir: "/Users/me",
item: {
type: "tool_call",
callId: "write-plan",
name: "write",
status: "completed",
error: null,
detail: {
type: "write",
filePath: ".paseo/plans/feature.md",
content: "# Plan\n\n- Implement it",
},
},
});
expect(item).toEqual({
type: "plan",
planId: "plan-file:.paseo/plans/feature.md",
text: "# Plan\n\n- Implement it",
});
});
});
@@ -1,70 +0,0 @@
import path from "node:path";
import fs from "node:fs/promises";
import type { AgentTimelineItem, ToolCallTimelineItem } from "./agent-sdk-types.js";
const PLAN_FILE_EXTENSIONS = new Set([".md", ".markdown"]);
const PLAN_DIRECTORIES = new Set(["/.paseo/plans/", "/.opencode/plans/"]);
export function isPlanFilePath(filePath: string): boolean {
const normalized = normalizePlanPath(filePath);
const ext = path.posix.extname(normalized).toLowerCase();
if (!PLAN_FILE_EXTENSIONS.has(ext)) {
return false;
}
const searchable = normalized.startsWith("/") ? normalized : `/${normalized}`;
return Array.from(PLAN_DIRECTORIES).some((dir) => searchable.includes(dir));
}
export async function planItemFromToolCall(params: {
item: ToolCallTimelineItem;
cwd: string;
homeDir: string;
}): Promise<AgentTimelineItem | null> {
const { item, cwd, homeDir } = params;
if (item.status !== "completed") {
return null;
}
const detail = item.detail;
if (detail.type !== "write" && detail.type !== "edit") {
return null;
}
if (!isPlanFilePath(detail.filePath)) {
return null;
}
const explicitContent = detail.type === "write" ? detail.content : undefined;
const text = explicitContent ?? (await readPlanFile(detail.filePath, cwd, homeDir));
if (!text?.trim()) {
return null;
}
return {
type: "plan",
planId: `plan-file:${normalizePlanPath(detail.filePath)}`,
text: text.trim(),
};
}
function normalizePlanPath(filePath: string): string {
return path.posix.normalize(filePath.replace(/\\/g, "/"));
}
async function readPlanFile(
filePath: string,
cwd: string,
homeDir: string,
): Promise<string | null> {
const candidates = path.isAbsolute(filePath)
? [filePath]
: [path.resolve(cwd, filePath), path.resolve(homeDir, filePath)];
for (const candidate of candidates) {
try {
return await fs.readFile(candidate, "utf8");
} catch {
// Try the next candidate.
}
}
return null;
}
@@ -363,7 +363,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
setMode: (modeId) => inner.setMode(modeId), setMode: (modeId) => inner.setMode(modeId),
getPendingPermissions: () => inner.getPendingPermissions(), getPendingPermissions: () => inner.getPendingPermissions(),
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response), respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
respondToPlan: inner.respondToPlan?.bind(inner),
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()), describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(), interrupt: () => inner.interrupt(),
close: () => inner.close(), close: () => inner.close(),
@@ -997,7 +997,7 @@ test("preserves bypass capability across query restarts triggered by thinking ch
} }
}); });
test("plan item exposes a resume-bypass action and can return to bypassPermissions", async () => { test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => {
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
sdkQueryFactory.mockImplementation(() => queryMock); sdkQueryFactory.mockImplementation(() => queryMock);
@@ -1023,36 +1023,44 @@ test("plan item exposes a resume-bypass action and can return to bypassPermissio
{}, {},
); );
const planEvent = events.find( const requestEvent = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> => (event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "timeline" && event.item.type === "plan", event.type === "permission_requested" && event.request.kind === "plan",
); );
expect(planEvent).toBeDefined(); expect(requestEvent).toBeDefined();
expect(planEvent?.item.type === "plan" ? planEvent.item.actions : undefined).toEqual([ expect(requestEvent?.request.actions).toEqual([
{ {
id: "reject", id: "reject",
label: "Reject", label: "Reject",
behavior: "deny",
variant: "danger", variant: "danger",
intent: "dismiss",
}, },
{ {
id: "implement", id: "implement",
label: "Implement", label: "Implement",
behavior: "allow",
variant: "primary", variant: "primary",
intent: "implement",
}, },
{ {
id: "implement_resume", id: "implement_resume",
label: "Implement with Bypass", label: "Implement with Bypass",
behavior: "allow",
variant: "secondary", variant: "secondary",
intent: "implement_resume",
}, },
]); ]);
if (!planEvent || planEvent.item.type !== "plan") { if (!requestEvent) {
throw new Error("Expected plan item"); throw new Error("Expected plan permission request");
} }
expect(session.getPendingPermissions()).toEqual([]);
await session.respondToPlan?.(planEvent.item.planId, { actionId: "implement_resume" }); await session.respondToPermission(requestEvent.request.id, {
behavior: "allow",
selectedActionId: "implement_resume",
});
await expect(pendingResolution).resolves.toMatchObject({ await expect(pendingResolution).resolves.toMatchObject({
behavior: "allow", behavior: "allow",
@@ -57,8 +57,6 @@ import {
type AgentMetadata, type AgentMetadata,
type AgentMode, type AgentMode,
type AgentModelDefinition, type AgentModelDefinition,
type AgentPlanAction,
type AgentPlanResponse,
type AgentPermissionRequest, type AgentPermissionRequest,
type AgentPermissionRequestKind, type AgentPermissionRequestKind,
type AgentPermissionResponse, type AgentPermissionResponse,
@@ -899,14 +897,6 @@ function buildClaudePlanPermissionActions(
return actions; return actions;
} }
function buildClaudePlanActions(resumeMode: PermissionMode | null): AgentPlanAction[] {
return buildClaudePlanPermissionActions(resumeMode).map(({ id, label, variant }) => ({
id,
label,
variant,
}));
}
interface TimelineFragment { interface TimelineFragment {
kind: "assistant" | "reasoning"; kind: "assistant" | "reasoning";
text: string; text: string;
@@ -1594,7 +1584,6 @@ class ClaudeAgentSession implements AgentSession {
private toolUseIndexToId = new Map<number, string>(); private toolUseIndexToId = new Map<number, string>();
private toolUseInputBuffers = new Map<string, string>(); private toolUseInputBuffers = new Map<string, string>();
private pendingPermissions = new Map<string, PendingPermission>(); private pendingPermissions = new Map<string, PendingPermission>();
private pendingPlans = new Map<string, string>();
private activeForegroundTurnId: string | null = null; private activeForegroundTurnId: string | null = null;
private autonomousTurn: AutonomousTurnState | null = null; private autonomousTurn: AutonomousTurnState | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
@@ -1930,140 +1919,82 @@ class ClaudeAgentSession implements AgentSession {
} }
getPendingPermissions(): AgentPermissionRequest[] { getPendingPermissions(): AgentPermissionRequest[] {
const hiddenPlanRequestIds = new Set(this.pendingPlans.values()); return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
return Array.from(this.pendingPermissions.values())
.filter((entry) => !hiddenPlanRequestIds.has(entry.request.id))
.map((entry) => entry.request);
} }
private clearPendingPlanForPermission(requestId: string): void { async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
for (const [planId, pendingRequestId] of this.pendingPlans) {
if (pendingRequestId === requestId) {
this.pendingPlans.delete(planId);
}
}
}
async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
emitResolution = true,
): Promise<void> {
const pending = this.pendingPermissions.get(requestId); const pending = this.pendingPermissions.get(requestId);
if (!pending) { if (!pending) {
throw new Error(`No pending permission request with id '${requestId}'`); throw new Error(`No pending permission request with id '${requestId}'`);
} }
this.pendingPermissions.delete(requestId); this.pendingPermissions.delete(requestId);
this.clearPendingPlanForPermission(requestId);
pending.cleanup?.(); pending.cleanup?.();
if (response.behavior === "allow") { if (response.behavior === "allow") {
await this.resolveAllowedPermission(pending, response); if (pending.request.kind === "plan") {
const selectedActionId = response.selectedActionId;
const shouldResumePriorMode =
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
const targetMode: PermissionMode = shouldResumePriorMode
? "bypassPermissions"
: "acceptEdits";
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
name: "plan_approval",
callId: pending.request.id,
input: pending.request.input ?? null,
output: {
approved: true,
actionId: selectedActionId ?? "implement",
},
}),
);
}
const updatedInput =
pending.request.kind === "question"
? normalizeClaudeAskUserQuestionUpdatedInput(
response.updatedInput,
pending.request.input ?? undefined,
)
: (response.updatedInput ?? pending.request.input ?? {});
const result: PermissionResult = {
behavior: "allow",
updatedInput,
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
};
pending.resolve(result);
} else { } else {
this.resolveDeniedPermission(pending, response); if (pending.request.kind === "tool") {
this.pushToolCall(
mapClaudeFailedToolCall({
name: pending.request.name,
callId:
(typeof pending.request.metadata?.toolUseId === "string"
? pending.request.metadata.toolUseId
: null) ?? pending.request.id,
input: pending.request.input ?? null,
output: null,
error: { message: response.message ?? "Permission denied" },
}),
);
}
const result: PermissionResult = {
behavior: "deny",
message: response.message ?? "Permission request denied",
interrupt: response.interrupt,
};
pending.resolve(result);
} }
if (emitResolution) { this.pushEvent({
this.pushEvent({ type: "permission_resolved",
type: "permission_resolved", provider: "claude",
provider: "claude", requestId,
requestId, resolution: response,
resolution: response,
});
}
}
private async resolveAllowedPermission(
pending: PendingPermission,
response: Extract<AgentPermissionResponse, { behavior: "allow" }>,
): Promise<void> {
if (pending.request.kind === "plan") {
const selectedActionId = response.selectedActionId;
const shouldResumePriorMode =
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
const targetMode: PermissionMode = shouldResumePriorMode
? "bypassPermissions"
: "acceptEdits";
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
name: "plan_approval",
callId: pending.request.id,
input: pending.request.input ?? null,
output: {
approved: true,
actionId: selectedActionId ?? "implement",
},
}),
);
}
const updatedInput =
pending.request.kind === "question"
? normalizeClaudeAskUserQuestionUpdatedInput(
response.updatedInput,
pending.request.input ?? undefined,
)
: (response.updatedInput ?? pending.request.input ?? {});
pending.resolve({
behavior: "allow",
updatedInput,
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
}); });
} }
private resolveDeniedPermission(
pending: PendingPermission,
response: Extract<AgentPermissionResponse, { behavior: "deny" }>,
): void {
if (pending.request.kind === "tool") {
this.pushToolCall(
mapClaudeFailedToolCall({
name: pending.request.name,
callId:
(typeof pending.request.metadata?.toolUseId === "string"
? pending.request.metadata.toolUseId
: null) ?? pending.request.id,
input: pending.request.input ?? null,
output: null,
error: { message: response.message ?? "Permission denied" },
}),
);
}
pending.resolve({
behavior: "deny",
message: response.message ?? "Permission request denied",
interrupt: response.interrupt,
});
}
async respondToPlan(planId: string, response: AgentPlanResponse): Promise<void> {
const requestId = this.pendingPlans.get(planId);
if (!requestId) {
throw new Error(`No pending Claude plan with id '${planId}'`);
}
this.pendingPlans.delete(planId);
if (response.actionId === "implement" || response.actionId === "implement_resume") {
await this.respondToPermission(
requestId,
{ behavior: "allow", selectedActionId: response.actionId },
false,
);
return;
}
if (response.actionId === "reject") {
await this.respondToPermission(
requestId,
{ behavior: "deny", selectedActionId: response.actionId, message: response.feedback },
false,
);
return;
}
throw new Error(`Unknown Claude plan action '${response.actionId}'`);
}
describePersistence(): AgentPersistenceHandle | null { describePersistence(): AgentPersistenceHandle | null {
if (this.persistence) { if (this.persistence) {
return this.persistence; return this.persistence;
@@ -3843,26 +3774,11 @@ class ClaudeAgentSession implements AgentSession {
metadata: Object.keys(metadata).length ? metadata : undefined, metadata: Object.keys(metadata).length ? metadata : undefined,
}; };
if (kind === "plan" && typeof input.plan === "string") { this.pushEvent({
const planId = `plan-${randomUUID()}`; type: "permission_requested",
this.pendingPlans.set(planId, requestId); provider: "claude",
this.pushEvent({ request,
type: "timeline", });
provider: "claude",
item: {
type: "plan",
planId,
text: input.plan,
actions: buildClaudePlanActions(this.planResumeMode),
},
});
} else {
this.pushEvent({
type: "permission_requested",
provider: "claude",
request,
});
}
return await new Promise<PermissionResult>((resolve, reject) => { return await new Promise<PermissionResult>((resolve, reject) => {
const cleanupFns: Array<() => void> = []; const cleanupFns: Array<() => void> = [];
@@ -3879,11 +3795,6 @@ class ClaudeAgentSession implements AgentSession {
const abortHandler = () => { const abortHandler = () => {
this.pendingPermissions.delete(requestId); this.pendingPermissions.delete(requestId);
for (const [planId, pendingRequestId] of this.pendingPlans) {
if (pendingRequestId === requestId) {
this.pendingPlans.delete(planId);
}
}
cleanup(); cleanup();
reject(new Error("Permission request aborted")); reject(new Error("Permission request aborted"));
}; };
@@ -1989,7 +1989,7 @@ describe("Codex app-server provider", () => {
}); });
}); });
test("emits an actionable plan item after a successful Codex plan turn", () => { test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: true }, featureValues: { plan_mode: true, fast_mode: true },
}); });
@@ -2018,20 +2018,27 @@ describe("Codex app-server provider", () => {
), ),
).toBe(false); ).toBe(false);
expect(events.at(-2)).toEqual({ expect(events.at(-2)).toEqual({
type: "timeline", type: "permission_requested",
provider: "codex", provider: "codex",
turnId: "test-turn", turnId: "test-turn",
item: expect.objectContaining({ request: expect.objectContaining({
type: "plan", provider: "codex",
text: "- Inspect the existing auth flow\n- Implement the button behavior", name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
input: {
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
},
actions: [ actions: [
expect.objectContaining({ expect.objectContaining({
id: "reject", id: "reject",
label: "Reject", label: "Reject",
behavior: "deny",
}), }),
expect.objectContaining({ expect.objectContaining({
id: "implement", id: "implement",
label: "Implement", label: "Implement",
behavior: "allow",
}), }),
], ],
}), }),
@@ -2075,12 +2082,16 @@ describe("Codex app-server provider", () => {
}), }),
); );
expect(events.at(-2)).toEqual({ expect(events.at(-2)).toEqual({
type: "timeline", type: "permission_requested",
provider: "codex", provider: "codex",
turnId: "test-turn", turnId: "test-turn",
item: expect.objectContaining({ request: expect.objectContaining({
type: "plan", provider: "codex",
text: "- Inspect README\n- Add a short note", name: "CodexPlanApproval",
kind: "plan",
input: {
plan: "- Inspect README\n- Add a short note",
},
}), }),
}); });
}); });
@@ -2458,7 +2469,7 @@ describe("Codex app-server provider", () => {
]); ]);
}); });
test("responding to a Codex plan item disables plan mode, preserves fast mode, and returns follow-up prompt", async () => { test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: true }, featureValues: { plan_mode: true, fast_mode: true },
}); });
@@ -2475,16 +2486,19 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null }, turn: { status: "completed", error: null },
}); });
const plan = events.find( const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> => (event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "timeline" && event.item.type === "plan", event.type === "permission_requested" && event.request.kind === "plan",
); );
expect(plan).toBeDefined(); expect(request).toBeDefined();
if (!plan || plan.item.type !== "plan") { if (!request) {
throw new Error("Expected plan item"); throw new Error("Expected synthetic plan approval permission");
} }
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" }); const result = await session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
expect(asInternals(session).serviceTier).toBe("fast"); expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(false); expect(asInternals(session).planModeEnabled).toBe(false);
@@ -2498,10 +2512,18 @@ describe("Codex app-server provider", () => {
expect(result!.followUpPrompt).toEqual( expect(result!.followUpPrompt).toEqual(
expect.stringContaining("The user approved the plan. Implement it now."), expect.stringContaining("The user approved the plan. Implement it now."),
); );
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_resolved" })); expect(events.at(-1)).toEqual({
type: "permission_resolved",
provider: "codex",
requestId: request.request.id,
resolution: {
behavior: "allow",
selectedActionId: "implement",
},
});
}); });
test("responding to a Codex plan item keeps fast mode disabled when it started disabled", async () => { test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: false }, featureValues: { plan_mode: true, fast_mode: false },
}); });
@@ -2518,16 +2540,19 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null }, turn: { status: "completed", error: null },
}); });
const plan = events.find( const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> => (event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "timeline" && event.item.type === "plan", event.type === "permission_requested" && event.request.kind === "plan",
); );
expect(plan).toBeDefined(); expect(request).toBeDefined();
if (!plan || plan.item.type !== "plan") { if (!request) {
throw new Error("Expected plan item"); throw new Error("Expected synthetic plan approval permission");
} }
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" }); const result = await session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
expect(asInternals(session).serviceTier).toBeNull(); expect(asInternals(session).serviceTier).toBeNull();
expect(asInternals(session).planModeEnabled).toBe(false); expect(asInternals(session).planModeEnabled).toBe(false);
@@ -2583,16 +2608,19 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null }, turn: { status: "completed", error: null },
}); });
const plan = events.find( const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> => (event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "timeline" && event.item.type === "plan", event.type === "permission_requested" && event.request.kind === "plan",
); );
expect(plan).toBeDefined(); expect(permissionRequest).toBeDefined();
if (!plan || plan.item.type !== "plan") { if (!permissionRequest) {
throw new Error("Expected plan item"); throw new Error("Expected synthetic plan approval permission");
} }
const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" }); const result = await session.respondToPermission(permissionRequest.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
expect(result?.followUpPrompt).toEqual(expect.any(String)); expect(result?.followUpPrompt).toEqual(expect.any(String));
await session.startTurn(result!.followUpPrompt!); await session.startTurn(result!.followUpPrompt!);
@@ -8,9 +8,6 @@ import {
type AgentLaunchContext, type AgentLaunchContext,
type AgentMode, type AgentMode,
type AgentModelDefinition, type AgentModelDefinition,
type AgentPlanAction,
type AgentPlanResponse,
type AgentPlanResult,
type McpServerConfig, type McpServerConfig,
type AgentPersistenceHandle, type AgentPersistenceHandle,
type AgentPermissionRequest, type AgentPermissionRequest,
@@ -44,7 +41,6 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { z } from "zod"; import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { planItemFromToolCall } from "../plan-files.js";
import { composeSystemPromptParts } from "../system-prompt.js"; import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js"; import { curateAgentActivity } from "../activity-curator.js";
import { import {
@@ -945,10 +941,6 @@ function buildPlanPermissionActions(options?: {
return actions; return actions;
} }
function buildPlanActions(): AgentPlanAction[] {
return buildPlanPermissionActions().map(({ id, label, variant }) => ({ id, label, variant }));
}
function buildCodexPlanImplementationPrompt(planText: string): string { function buildCodexPlanImplementationPrompt(planText: string): string {
const normalizedPlan = normalizePlanMarkdown(planText); const normalizedPlan = normalizePlanMarkdown(planText);
if (!normalizedPlan) { if (!normalizedPlan) {
@@ -2933,7 +2925,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null; private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
private readonly userMessageTurnIndexes = new Map<string, number>(); private readonly userMessageTurnIndexes = new Map<string, number>();
private readonly userMessageTurnIds: string[] = []; private readonly userMessageTurnIds: string[] = [];
private pendingPlans = new Map<string, { text: string }>();
private pendingManualCompactionStarts = 0; private pendingManualCompactionStarts = 0;
private compactionTriggerByItemId = new Map<string, "auto" | "manual">(); private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
// Codex can report one completed compaction through both channels: // Codex can report one completed compaction through both channels:
@@ -3196,36 +3187,30 @@ export class CodexAppServerAgentSession implements AgentSession {
}; };
} }
private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem): void { private emitSyntheticPlanApprovalRequest(planText: string): void {
void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() }) const requestId = `permission-${randomUUID()}`;
.then((planItem) => { const request: AgentPermissionRequest = {
if (planItem) { id: requestId,
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: planItem });
}
return undefined;
})
.catch((error) => {
this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item");
});
}
private emitPlanApprovalItem(planText: string): void {
const text = normalizePlanMarkdown(planText);
if (!text) {
return;
}
const planId = `plan-${randomUUID()}`;
this.pendingPlans.set(planId, { text });
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
item: { name: "CodexPlanApproval",
type: "plan", kind: "plan",
planId, title: "Plan",
text, description: "Review the proposed plan before implementation starts.",
actions: buildPlanActions(), input: { plan: planText },
actions: buildPlanPermissionActions(),
metadata: {
planText,
source: "codex_plan_approval",
}, },
};
this.pendingPermissions.set(requestId, request);
this.pendingPermissionHandlers.set(requestId, {
resolve: () => undefined,
kind: "plan",
planText,
}); });
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
} }
/** /**
@@ -3785,29 +3770,6 @@ export class CodexAppServerAgentSession implements AgentSession {
pending.resolve({ answers: {} }); pending.resolve({ answers: {} });
} }
async respondToPlan(
planId: string,
response: AgentPlanResponse,
): Promise<AgentPlanResult | void> {
const pending = this.pendingPlans.get(planId);
if (!pending) {
throw new Error(`No pending Codex app-server plan with id '${planId}'`);
}
this.pendingPlans.delete(planId);
if (response.actionId === "implement" || response.actionId === "implement_resume") {
return {
followUpPrompt: this.preparePlanImplementation({ planText: pending.text }),
};
}
if (response.actionId === "reject") {
return;
}
throw new Error(`Unknown Codex plan action '${response.actionId}'`);
}
private handlePlanPermissionResponse(params: { private handlePlanPermissionResponse(params: {
requestId: string; requestId: string;
response: AgentPermissionResponse; response: AgentPermissionResponse;
@@ -4607,7 +4569,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" }); this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else { } else {
if (this.planModeEnabled && this.latestPlanResult?.text) { if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitPlanApprovalItem(this.latestPlanResult.text); this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
} }
this.emitEvent({ this.emitEvent({
type: "turn_completed", type: "turn_completed",
@@ -4921,9 +4883,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item); this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
} }
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem.type === "tool_call") {
this.emitPlanFileItemFromToolCall(timelineItem);
}
if (timelineItem.type === "assistant_message") { if (timelineItem.type === "assistant_message") {
this.pendingAssistantMessageBoundary = true; this.pendingAssistantMessageBoundary = true;
} }
@@ -24,7 +24,7 @@ describe("Codex app-server provider (real) plan mode", () => {
} }
}); });
test("maps gpt-5.4 markdown plans to a normalized plan item instead of todo items", async () => { test("maps gpt-5.4 markdown plans to a plan tool call instead of todo items", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const client = new CodexAppServerAgentClient(createTestLogger()); const client = new CodexAppServerAgentClient(createTestLogger());
@@ -50,16 +50,18 @@ describe("Codex app-server provider (real) plan mode", () => {
}), }),
); );
const planItem = result.timeline.find((item) => item.type === "plan"); const planCall = result.timeline.find(
(item) => item.type === "tool_call" && item.detail.type === "plan",
);
expect(planItem).toBeDefined(); expect(planCall).toBeDefined();
if (!planItem || planItem.type !== "plan") { if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") {
throw new Error("Expected a normalized plan item"); throw new Error("Expected a plan tool call");
} }
expect(planItem.text).toContain("Login"); expect(planCall.detail.text).toContain("Login");
expect(planItem.text).toContain("- "); expect(planCall.detail.text).toContain("- ");
expect(planItem.actions?.some((action) => action.id === "implement")).toBe(true); expect(result.finalText).toBe(planCall.detail.text);
} finally { } finally {
await session.close(); await session.close();
} }
@@ -57,7 +57,6 @@ import {
resolveProviderLaunch, resolveProviderLaunch,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { isPlanFilePath, planItemFromToolCall } from "../plan-files.js";
import { withTimeout } from "../../../utils/promise-timeout.js"; import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand } from "../../../utils/spawn.js"; import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
@@ -2155,22 +2154,6 @@ function appendOpenCodeToolCallTimelineItem(
provider: "opencode", provider: "opencode",
item: timelineItem, item: timelineItem,
}); });
if (
timelineItem.status === "completed" &&
timelineItem.detail.type === "write" &&
timelineItem.detail.content?.trim() &&
isPlanFilePath(timelineItem.detail.filePath)
) {
events.push({
type: "timeline",
provider: "opencode",
item: {
type: "plan",
planId: `plan-file:${timelineItem.detail.filePath}`,
text: timelineItem.detail.content.trim(),
},
});
}
if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) { if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) {
flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events); flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events);
} }
@@ -3230,28 +3213,9 @@ class OpenCodeAgentSession implements AgentSession {
return; return;
} }
this.notifySubscribers(e, turnId); this.notifySubscribers(e, turnId);
if (e.type === "timeline" && e.item.type === "tool_call") {
this.emitPlanFileItemFromToolCall(e.item, turnId);
}
} }
} }
private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem, turnId: string): void {
void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() })
.then((planItem) => {
if (planItem) {
this.notifySubscribers(
{ type: "timeline", provider: "opencode", item: planItem },
turnId,
);
}
return undefined;
})
.catch((error) => {
this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item");
});
}
private finishForegroundTurn( private finishForegroundTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>, event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string, turnId: string,
@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import type { AgentTimelineRow } from "./agent-manager.js"; import type { AgentTimelineRow } from "./agent-manager.js";
import { import {
projectTimelineRows, projectTimelineRows,
selectProjectedTimelinePage,
selectTimelineWindowByProjectedLimit, selectTimelineWindowByProjectedLimit,
} from "./timeline-projection.js"; } from "./timeline-projection.js";
@@ -436,58 +437,178 @@ describe("selectTimelineWindowByProjectedLimit", () => {
}); });
}); });
test("can enforce a hard projected limit when tool lifecycle collapsing is disabled", () => { test("tail limit treats a repeated running tool call as one projected item", () => {
const rows: AgentTimelineRow[] = [ const rows: AgentTimelineRow[] = [
{ ...Array.from({ length: 6 }, (_, index) => ({
seq: 1, seq: index + 1,
timestamp: "2026-02-13T00:00:00.000Z", timestamp: `2026-02-13T00:00:00.00${index}Z`,
item: { type: "assistant_message" as const, text: `old ${index}` },
})),
...Array.from({ length: 20 }, (_, index) => ({
seq: index + 7,
timestamp: `2026-02-13T00:00:01.0${index}Z`,
item: { item: {
type: "tool_call", type: "tool_call" as const,
callId: "call_1", callId: "call_1",
name: "shell", name: "shell",
status: "running", status: "running" as const,
error: null, error: null,
detail: { detail: {
type: "unknown", type: "unknown" as const,
input: { cmd: "pwd" }, input: { cmd: "sleep 10" },
output: null, output: { progress: index },
}, },
}, },
}, })),
{
seq: 2,
timestamp: "2026-02-13T00:00:00.100Z",
item: { type: "assistant_message", text: "work" },
},
{
seq: 3,
timestamp: "2026-02-13T00:00:00.200Z",
item: {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "completed",
error: null,
detail: {
type: "unknown",
input: { cmd: "pwd" },
output: { stdout: "/tmp" },
},
},
},
]; ];
const selected = selectTimelineWindowByProjectedLimit({ const selected = selectTimelineWindowByProjectedLimit({
rows, rows,
direction: "tail", direction: "tail",
limit: 1, limit: 100,
collapseToolLifecycle: false,
}); });
expect(selected.minSeq).toBe(3); const tools = selected.projectedEntries.filter((entry) => entry.item.type === "tool_call");
expect(selected.maxSeq).toBe(3); expect(tools).toHaveLength(1);
expect(selected.selectedRows.map((row) => row.seq)).toEqual([3]); expect(tools[0]?.collapsed).toContain("tool_lifecycle");
expect(selected.projectedEntries).toHaveLength(1); expect(selected.projectedEntries).toHaveLength(2);
expect(selected.projectedEntries[0]?.item.type).toBe("tool_call"); });
});
describe("selectProjectedTimelinePage", () => {
function toolRow(seq: number, status: "running" | "completed"): AgentTimelineRow {
return {
seq,
timestamp: new Date(1000 + seq).toISOString(),
item: {
type: "tool_call",
callId: "call_1",
name: "shell",
status,
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: status === "completed" ? { stdout: "done" } : null,
},
},
};
}
test("tail page returns full projected items instead of tool lifecycle deltas", () => {
const rows: AgentTimelineRow[] = [
{ seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } },
...Array.from({ length: 120 }, (_, index) => toolRow(index + 2, "running")),
];
const page = selectProjectedTimelinePage({ rows, direction: "tail", limit: 100 });
expect(page.entries.map((entry) => entry.item.type)).toEqual(["user_message", "tool_call"]);
expect(page.entries[1]?.collapsed).toContain("tool_lifecycle");
expect(page.entries[1]?.sourceSeqRanges).toEqual([{ startSeq: 2, endSeq: 121 }]);
expect(page.startSeq).toBe(1);
expect(page.endSeq).toBe(121);
expect(page.hasNewer).toBe(false);
});
test("after page includes a full projected tool item when only its update is new", () => {
const rows: AgentTimelineRow[] = [
toolRow(10, "running"),
{
seq: 11,
timestamp: "2026-02-13T00:00:00.011Z",
item: { type: "assistant_message", text: "working" },
},
toolRow(250, "completed"),
];
const page = selectProjectedTimelinePage({
rows,
direction: "after",
cursorSeq: 249,
limit: 100,
});
expect(page.entries).toHaveLength(1);
expect(page.entries[0]?.item.type).toBe("tool_call");
expect(page.entries[0]?.seqStart).toBe(10);
expect(page.entries[0]?.seqEnd).toBe(250);
expect(page.entries[0]?.sourceSeqRanges).toEqual([
{ startSeq: 10, endSeq: 10 },
{ startSeq: 250, endSeq: 250 },
]);
expect(page.startSeq).toBe(250);
expect(page.endSeq).toBe(250);
});
test("after page cursor advances only through contiguously covered seqs", () => {
const rows: AgentTimelineRow[] = [
toolRow(1, "running"),
...Array.from({ length: 498 }, (_, index) => ({
seq: index + 2,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `middle ${index + 2}` },
})),
toolRow(500, "completed"),
...Array.from({ length: 101 }, (_, index) => ({
seq: index + 501,
timestamp: new Date(3000 + index).toISOString(),
item: { type: "user_message" as const, text: `later ${index + 501}` },
})),
];
const page = selectProjectedTimelinePage({
rows,
direction: "after",
cursorSeq: 0,
limit: 100,
});
expect(page.entries[0]?.item.type).toBe("tool_call");
expect(
page.entries.some((entry) => entry.item.type === "user_message" && entry.seqStart === 101),
).toBe(false);
expect(page.endSeq).toBe(100);
expect(page.hasNewer).toBe(true);
});
test("before page includes a wide tool whose earlier source range is before the cursor", () => {
const rows: AgentTimelineRow[] = [
toolRow(1, "running"),
...Array.from({ length: 498 }, (_, index) => ({
seq: index + 2,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `middle ${index + 2}` },
})),
toolRow(500, "completed"),
];
const page = selectProjectedTimelinePage({
rows,
direction: "before",
cursorSeq: 500,
limit: 100,
});
expect(page.entries.some((entry) => entry.item.type === "tool_call")).toBe(true);
expect(page.endSeq).toBeLessThan(500);
expect(page.hasOlder).toBe(true);
});
test("tail page includes a wide tool when its completion is the newest seq", () => {
const rows: AgentTimelineRow[] = [
toolRow(1, "running"),
...Array.from({ length: 499 }, (_, index) => ({
seq: index + 2,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `middle ${index + 2}` },
})),
toolRow(501, "completed"),
];
const page = selectProjectedTimelinePage({ rows, direction: "tail", limit: 100 });
expect(page.entries.some((entry) => entry.item.type === "tool_call")).toBe(true);
expect(page.endSeq).toBe(501);
}); });
}); });
@@ -28,6 +28,14 @@ interface ProjectedWindowSelection {
maxSeq: number | null; maxSeq: number | null;
} }
export interface ProjectedTimelinePageSelection {
entries: TimelineProjectionEntry[];
startSeq: number | null;
endSeq: number | null;
hasOlder: boolean;
hasNewer: boolean;
}
function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeqRange[] { function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeqRange[] {
if (ranges.length === 0) { if (ranges.length === 0) {
return [{ startSeq: seq, endSeq: seq }]; return [{ startSeq: seq, endSeq: seq }];
@@ -266,15 +274,11 @@ export function selectTimelineWindowByProjectedLimit(input: {
rows: readonly AgentTimelineRow[]; rows: readonly AgentTimelineRow[];
direction: TimelineLimitDirection; direction: TimelineLimitDirection;
limit: number; limit: number;
collapseToolLifecycle?: boolean;
}): ProjectedWindowSelection { }): ProjectedWindowSelection {
const { rows, direction } = input; const { rows, direction } = input;
const limit = Math.max(0, Math.floor(input.limit)); const limit = Math.max(0, Math.floor(input.limit));
const collapseTools = input.collapseToolLifecycle ?? true;
const canonical = makeCanonicalEntries(rows); const canonical = makeCanonicalEntries(rows);
const projectedAll = mergeReasoningChunks( const projectedAll = mergeReasoningChunks(mergeAssistantChunks(collapseToolLifecycle(canonical)));
mergeAssistantChunks(collapseTools ? collapseToolLifecycle(canonical) : canonical),
);
if (projectedAll.length === 0) { if (projectedAll.length === 0) {
return { return {
@@ -320,27 +324,25 @@ export function selectTimelineWindowByProjectedLimit(input: {
let { minSeq, maxSeq } = computeWindowBounds(projectedEntries); let { minSeq, maxSeq } = computeWindowBounds(projectedEntries);
let expandedEntries = projectedEntries; let expandedEntries = projectedEntries;
if (collapseTools) { // Expand to include any projected entries that overlap the selected canonical
// Expand to include any projected entries that overlap the selected // range. Tool lifecycle collapse can produce non-monotonic seqEnd values,
// canonical range. Tool lifecycle collapse can produce non-monotonic // which would otherwise create cursor gaps.
// seqEnd values, which would otherwise create cursor gaps. for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) {
for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) { const overlapping = projectedAll.filter(
const overlapping = projectedAll.filter( (entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq,
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq, );
); const nextBounds = computeWindowBounds(overlapping);
const nextBounds = computeWindowBounds(overlapping); if (
if ( overlapping.length === expandedEntries.length &&
overlapping.length === expandedEntries.length && nextBounds.minSeq === minSeq &&
nextBounds.minSeq === minSeq && nextBounds.maxSeq === maxSeq
nextBounds.maxSeq === maxSeq ) {
) {
expandedEntries = overlapping;
break;
}
expandedEntries = overlapping; expandedEntries = overlapping;
minSeq = nextBounds.minSeq; break;
maxSeq = nextBounds.maxSeq;
} }
expandedEntries = overlapping;
minSeq = nextBounds.minSeq;
maxSeq = nextBounds.maxSeq;
} }
const selectedRows = rows.filter((row) => row.seq >= minSeq && row.seq <= maxSeq); const selectedRows = rows.filter((row) => row.seq >= minSeq && row.seq <= maxSeq);
@@ -353,6 +355,94 @@ export function selectTimelineWindowByProjectedLimit(input: {
}; };
} }
function getTimelineBounds(
rows: readonly AgentTimelineRow[],
): { minSeq: number; maxSeq: number } | null {
const first = rows[0];
const last = rows[rows.length - 1];
if (!first || !last) {
return null;
}
return { minSeq: first.seq, maxSeq: last.seq };
}
function selectEntriesOverlappingSeqRange(input: {
entries: readonly TimelineProjectionEntry[];
startSeq: number;
endSeq: number;
}): TimelineProjectionEntry[] {
return input.entries.filter(
(entry) => entry.seqStart <= input.endSeq && entry.seqEnd >= input.startSeq,
);
}
export function selectProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}): ProjectedTimelinePageSelection {
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
const bounds = input.bounds ?? getTimelineBounds(input.rows);
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
if (projectedAll.length === 0 || !bounds) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (input.direction === "tail") {
const selected = selectTimelineWindowByProjectedLimit({
rows: input.rows,
direction: "tail",
limit,
});
return {
entries: selected.projectedEntries,
startSeq: selected.minSeq,
endSeq: selected.maxSeq,
hasOlder: selected.minSeq !== null && selected.minSeq > bounds.minSeq,
hasNewer: false,
};
}
let startSeq: number;
let endSeq: number;
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
endSeq = limit === 0 ? bounds.maxSeq : Math.min(bounds.maxSeq, cursorSeq + limit);
} else {
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
startSeq = limit === 0 ? bounds.minSeq : Math.max(bounds.minSeq, cursorSeq - limit);
}
if (startSeq > endSeq) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: startSeq > bounds.minSeq,
hasNewer: endSeq < bounds.maxSeq,
};
}
const entries = selectEntriesOverlappingSeqRange({ entries: projectedAll, startSeq, endSeq });
return {
entries,
startSeq,
endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: endSeq < bounds.maxSeq,
};
}
/** /**
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata. * Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
* Used by callers that only have items in hand (e.g. MCP tools reading * Used by callers that only have items in hand (e.g. MCP tools reading
@@ -1,273 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { isProviderAvailable } from "./agent-configs.js";
import type { PlanTimelineItem } from "../agent/agent-sdk-types.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-plans-"));
}
function waitForPlanMessage(
collector: MessageCollector,
agentId: string,
timeoutMs: number,
): Promise<PlanTimelineItem> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
const timer = setInterval(() => {
const message = collector.messages.find((candidate) => {
if (candidate.type !== "agent_stream") return false;
if (candidate.payload.agentId !== agentId) return false;
return (
candidate.payload.event.type === "timeline" &&
candidate.payload.event.item.type === "plan"
);
});
if (message?.type === "agent_stream") {
const event = message.payload.event;
if (event.type === "timeline" && event.item.type === "plan") {
clearInterval(timer);
resolve(event.item);
}
}
if (Date.now() > deadline) {
clearInterval(timer);
reject(new Error(`Timed out waiting for plan item after ${timeoutMs}ms`));
}
}, 100);
});
}
describe("daemon E2E - first-class plans", () => {
let ctx: DaemonTestContext;
let collector: MessageCollector;
beforeEach(async () => {
ctx = await createDaemonTestContext();
collector = createMessageCollector(ctx.client);
});
afterEach(async () => {
collector.unsubscribe();
await ctx.cleanup();
}, 60_000);
test("surfaces an actionable plan and routes the response through the daemon", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Plan E2E",
modeId: "full-access",
});
collector.clear();
await ctx.client.sendMessage(agent.id, "Emit an actionable plan.");
const finalState = await ctx.client.waitForFinish(agent.id, 5_000);
expect(finalState.status).toBe("idle");
const planMessage = collector.messages.find((message) => {
if (message.type !== "agent_stream") return false;
if (message.payload.agentId !== agent.id) return false;
return (
message.payload.event.type === "timeline" && message.payload.event.item.type === "plan"
);
});
expect(planMessage?.type).toBe("agent_stream");
if (planMessage?.type !== "agent_stream") {
throw new Error("Expected plan stream message");
}
const event = planMessage.payload.event;
if (event.type !== "timeline" || event.item.type !== "plan") {
throw new Error("Expected normalized plan item");
}
expect(event.item.actions).toEqual([
{ id: "implement", label: "Implement", variant: "primary" },
]);
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(
timeline.entries.some(
(entry) => entry.item.type === "plan" && entry.item.planId === event.item.planId,
),
).toBe(true);
const response = await ctx.client.respondToPlan(agent.id, event.item.planId, {
actionId: "implement",
});
expect(response).toMatchObject({
agentId: agent.id,
planId: event.item.planId,
ok: true,
error: null,
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
test("surfaces a plan file as a non-actionable plan", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd,
title: "Plan File E2E",
modeId: "full-access",
});
collector.clear();
await ctx.client.sendMessage(agent.id, "Emit a plan file.");
await ctx.client.waitForFinish(agent.id, 5_000);
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const plan = timeline.entries.find(
(entry) =>
entry.item.type === "plan" && entry.item.planId === "plan-file:.paseo/plans/fake.md",
);
expect(plan?.item).toEqual({
type: "plan",
planId: "plan-file:.paseo/plans/fake.md",
text: "# File plan\n\n- From disk",
});
const response = await ctx.client.respondToPlan(agent.id, "plan-file:.paseo/plans/fake.md", {
actionId: "implement",
});
expect(response.ok).toBe(false);
expect(response.error).toContain("No pending fake plan");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
});
describe("daemon E2E - first-class plans with real providers", () => {
test("real Codex plan mode surfaces a normalized actionable plan", async (context) => {
if (!(await isProviderAvailable("codex"))) {
context.skip();
}
const cwd = tmpCwd();
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { codex: new CodexAppServerAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "real-codex-plan" } });
const agent = await client.createAgent({
provider: "codex",
cwd,
title: "Real Codex Plan E2E",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "medium",
featureValues: { plan_mode: true },
});
await client.sendMessage(
agent.id,
"You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.",
);
await client.waitForFinish(agent.id, 240_000);
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const plan = timeline.entries.find((entry) => entry.item.type === "plan");
expect(plan?.item.type).toBe("plan");
if (!plan || plan.item.type !== "plan") {
throw new Error("Expected normalized plan item");
}
expect(plan.item.text).toContain("Login");
expect(plan.item.actions?.some((action) => action.id === "implement")).toBe(true);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
test("real Claude plan mode surfaces a normalized actionable plan", async (context) => {
if (!(await isProviderAvailable("claude"))) {
context.skip();
}
const cwd = tmpCwd();
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const collector = createMessageCollector(client);
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "real-claude-plan" } });
const agent = await client.createAgent({
provider: "claude",
cwd,
title: "Real Claude Plan E2E",
modeId: "plan",
model: "haiku",
});
collector.clear();
await client.sendMessage(
agent.id,
[
"Create a short implementation plan for a login screen.",
"Use plan mode and call ExitPlanMode with a markdown plan.",
"Do not edit files.",
].join(" "),
);
const plan = await waitForPlanMessage(collector, agent.id, 120_000);
expect(plan.text).toContain("login");
expect(plan.actions?.some((action) => action.id === "implement")).toBe(true);
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.pendingPermissions ?? []).toEqual([]);
const response = await client.respondToPlan(agent.id, plan.planId, { actionId: "reject" });
expect(response).toMatchObject({
agentId: agent.id,
planId: plan.planId,
ok: true,
error: null,
});
} finally {
collector.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 180_000);
});
@@ -1,4 +1,4 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -17,6 +17,7 @@ describe("daemon E2E - timeline window", () => {
}); });
afterEach(async () => { afterEach(async () => {
vi.restoreAllMocks();
await ctx.cleanup(); await ctx.cleanup();
}, 60_000); }, 60_000);
@@ -66,6 +67,10 @@ describe("daemon E2E - timeline window", () => {
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle"); expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
const expected = "SECOND"; const expected = "SECOND";
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: "next",
});
await ctx.client.sendMessage(agent.id, `Respond with exactly: ${expected}`); await ctx.client.sendMessage(agent.id, `Respond with exactly: ${expected}`);
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle"); expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
@@ -86,4 +91,271 @@ describe("daemon E2E - timeline window", () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, 30_000); }, 30_000);
test("timeline fetch returns one projected in-progress tool call instead of lifecycle deltas", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Tool Projection Test",
modeId: "full-access",
});
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: "run the tool",
});
for (let index = 0; index < 120; index += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "running",
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: { progress: index },
},
});
}
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 100,
projection: "canonical",
});
const toolEntries = timeline.entries.filter((entry) => entry.item.type === "tool_call");
expect(timeline.projection).toBe("projected");
expect(timeline.entries.map((entry) => entry.item.type)).toEqual([
"user_message",
"tool_call",
]);
expect(toolEntries).toHaveLength(1);
expect(toolEntries[0]?.collapsed).toContain("tool_lifecycle");
expect(toolEntries[0]?.sourceSeqRanges).toEqual([{ startSeq: 2, endSeq: 121 }]);
expect(timeline.endCursor?.seq).toBe(121);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("after fetch returns the full projected tool item for a new lifecycle update", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Tool Catch-up Test",
modeId: "full-access",
});
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "running",
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: null,
},
});
for (let seq = 2; seq <= 249; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `background ${seq}`,
});
}
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "completed",
error: null,
detail: {
type: "unknown",
input: { cmd: "sleep 10" },
output: { stdout: "done" },
},
});
const baseline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
});
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch: baseline.epoch, seq: 249 },
limit: 100,
projection: "canonical",
});
expect(timeline.entries).toHaveLength(1);
expect(timeline.startCursor?.seq).toBe(250);
expect(timeline.endCursor?.seq).toBe(250);
expect(timeline.entries[0]?.seqStart).toBe(1);
expect(timeline.entries[0]?.seqEnd).toBe(250);
expect(timeline.entries[0]?.sourceSeqRanges).toEqual([
{ startSeq: 1, endSeq: 1 },
{ startSeq: 250, endSeq: 250 },
]);
expect(timeline.entries[0]?.item.type).toBe("tool_call");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("reset timeline fetch reports older history when the reset slice starts after window min", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Reset HasOlder Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
cursor: { epoch: "stale-epoch", seq: 600 },
limit: 200,
});
expect(timeline.reset).toBe(true);
expect(timeline.staleCursor).toBe(true);
expect(timeline.startCursor?.seq).toBeGreaterThan(timeline.window.minSeq);
expect(timeline.hasOlder).toBe(true);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("tail fetch does not re-fetch full plain chat history", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Tail Bounded Fetch Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 100,
});
expect(timeline.entries).toHaveLength(100);
expect(timeline.startCursor?.seq).toBe(501);
expect(timeline.endCursor?.seq).toBe(600);
expect(timeline.hasOlder).toBe(true);
expect(
fetchSpy.mock.calls.some(
([, options]) => options?.direction === "tail" && options.limit === 0,
),
).toBe(false);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("after fetch does not re-fetch full plain chat history", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline After Bounded Fetch Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const epoch = ctx.daemon.daemon.agentManager.fetchTimeline(agent.id, {
direction: "tail",
limit: 1,
}).epoch;
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch, seq: 300 },
limit: 100,
});
expect(timeline.entries).toHaveLength(100);
expect(timeline.startCursor?.seq).toBe(301);
expect(timeline.endCursor?.seq).toBe(400);
expect(timeline.hasNewer).toBe(true);
expect(
fetchSpy.mock.calls.some(
([, options]) => options?.direction === "tail" && options.limit === 0,
),
).toBe(false);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("before fetch does not re-fetch full plain chat history", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Before Bounded Fetch Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 600; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `row ${seq}`,
});
}
const epoch = ctx.daemon.daemon.agentManager.fetchTimeline(agent.id, {
direction: "tail",
limit: 1,
}).epoch;
const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline");
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "before",
cursor: { epoch, seq: 501 },
limit: 100,
});
expect(timeline.entries).toHaveLength(100);
expect(timeline.startCursor?.seq).toBe(401);
expect(timeline.endCursor?.seq).toBe(500);
expect(timeline.hasOlder).toBe(true);
expect(
fetchSpy.mock.calls.some(
([, options]) => options?.direction === "tail" && options.limit === 0,
),
).toBe(false);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
}); });
@@ -63,49 +63,6 @@ describe("serializeAgentStreamEvent", () => {
expect(serialized.item.messageId).toBe("m1"); expect(serialized.item.messageId).toBe("m1");
}); });
test("accepts normalized plan timeline items", () => {
const event: AgentStreamEvent = {
type: "timeline",
provider: "codex",
item: {
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Ship it",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
};
const serialized = serializeAgentStreamEvent(event);
expect(serialized).toMatchObject({
type: "timeline",
item: {
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Ship it",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
});
});
test("accepts plan response requests", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "agent.plan.respond.request",
agentId: "agent-1",
planId: "plan-1",
actionId: "implement",
requestId: "req-plan-1",
});
expect(parsed).toMatchObject({
type: "agent.plan.respond.request",
agentId: "agent-1",
planId: "plan-1",
actionId: "implement",
requestId: "req-plan-1",
});
});
test("passes canonical tool_call payloads through unchanged", () => { test("passes canonical tool_call payloads through unchanged", () => {
const event: AgentStreamEvent = { const event: AgentStreamEvent = {
type: "timeline", type: "timeline",
+50 -208
View File
@@ -68,7 +68,6 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js";
import { import {
formatSystemNotificationPrompt, formatSystemNotificationPrompt,
sendPromptToAgent, sendPromptToAgent,
startAgentRun,
waitForAgentRunStartWithTimeout, waitForAgentRunStartWithTimeout,
unarchiveAgentState, unarchiveAgentState,
} from "./agent/agent-prompt.js"; } from "./agent/agent-prompt.js";
@@ -100,6 +99,7 @@ import type {
AgentManagerEvent, AgentManagerEvent,
AgentTimelineCursor, AgentTimelineCursor,
AgentTimelineFetchDirection, AgentTimelineFetchDirection,
AgentTimelineFetchResult,
ManagedAgent, ManagedAgent,
} from "./agent/agent-manager.js"; } from "./agent/agent-manager.js";
import { createAgentCommand } from "./agent/create-agent/create.js"; import { createAgentCommand } from "./agent/create-agent/create.js";
@@ -121,8 +121,7 @@ import {
emitLiveTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js"; } from "./agent/timeline-append.js";
import { import {
projectTimelineRows, selectProjectedTimelinePage,
selectTimelineWindowByProjectedLimit,
type TimelineProjectionMode, type TimelineProjectionMode,
} from "./agent/timeline-projection.js"; } from "./agent/timeline-projection.js";
import { import {
@@ -143,8 +142,6 @@ import {
type AgentPromptInput, type AgentPromptInput,
type AgentRunOptions, type AgentRunOptions,
type AgentSessionConfig, type AgentSessionConfig,
type AgentStreamEvent,
type AgentTimelineItem,
type ProviderSnapshotEntry, type ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js";
@@ -719,45 +716,6 @@ function parseClientCapabilities(
return new Set(result); return new Set(result);
} }
function projectTimelineItemForClient(
item: AgentTimelineItem,
capabilities: ReadonlySet<ClientCapability>,
): AgentTimelineItem {
if (item.type !== "plan" || capabilities.has(CLIENT_CAPS.firstClassPlans)) {
return item;
}
// COMPAT(firstClassPlans): added in v0.1.82, remove shim after 2026-11-28.
return {
type: "tool_call",
callId: item.planId,
name: "Plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: item.text,
},
};
}
function projectAgentStreamEventForClient(
event: AgentStreamEvent,
capabilities: ReadonlySet<ClientCapability>,
): AgentStreamEvent {
if (event.type !== "timeline") {
return event;
}
const item = projectTimelineItemForClient(event.item, capabilities);
if (item === event.item) {
return event;
}
return {
...event,
item,
};
}
/** /**
* Session represents a single connected client session. * Session represents a single connected client session.
* It owns all state management, orchestration logic, and message processing. * It owns all state management, orchestration logic, and message processing.
@@ -1377,11 +1335,7 @@ export class Session {
}); });
} }
const projectedEvent = projectAgentStreamEventForClient( const serializedEvent = serializeAgentStreamEvent(event.event);
event.event,
this.clientCapabilities,
);
const serializedEvent = serializeAgentStreamEvent(projectedEvent);
if (!serializedEvent) { if (!serializedEvent) {
return; return;
} }
@@ -1786,7 +1740,6 @@ export class Session {
const promise = const promise =
this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ?? this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentPlanMessage(msg) ??
this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ?? this.dispatchCheckoutMessage(msg) ??
@@ -1798,13 +1751,6 @@ export class Session {
if (promise) await promise; if (promise) await promise;
} }
private dispatchAgentPlanMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "agent.plan.respond.request") {
return this.handleAgentPlanRespondRequest(msg);
}
return undefined;
}
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined { private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) { switch (msg.type) {
case "voice_audio_chunk": case "voice_audio_chunk":
@@ -4615,47 +4561,6 @@ export class Session {
} }
} }
private async handleAgentPlanRespondRequest(
msg: Extract<SessionInboundMessage, { type: "agent.plan.respond.request" }>,
): Promise<void> {
const { agentId, planId, actionId, feedback, requestId } = msg;
try {
const result = await this.agentManager.respondToPlan(agentId, planId, { actionId, feedback });
this.emit({
type: "agent.plan.respond.response",
payload: {
requestId,
agentId,
planId,
ok: true,
error: null,
},
});
if (result?.followUpPrompt) {
startAgentRun(this.agentManager, agentId, result.followUpPrompt, this.sessionLogger, {
replaceRunning: true,
});
}
} catch (error) {
const message = getErrorMessage(error);
this.sessionLogger.error(
{ err: error, agentId, planId, actionId },
"Failed to respond to plan",
);
this.emit({
type: "agent.plan.respond.response",
payload: {
requestId,
agentId,
planId,
ok: false,
error: message,
},
});
}
}
private async handleCheckoutStatusRequest( private async handleCheckoutStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>, msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>,
): Promise<void> { ): Promise<void> {
@@ -7510,84 +7415,33 @@ export class Session {
}); });
} }
private loadProjectedTimelineWindow(params: { private shouldUseFullTimelineForProjectedPage(input: {
agentId: string; timeline: AgentTimelineFetchResult;
direction: AgentTimelineFetchDirection; }): boolean {
cursor: AgentTimelineCursor | undefined; const { timeline } = input;
requestedLimit: number; if (timeline.reset || timeline.rows.length === 0 || !timeline.hasOlder) {
timeline: ReturnType<AgentManager["fetchTimeline"]>; return false;
}): {
timeline: ReturnType<AgentManager["fetchTimeline"]>;
selectedRows: ReturnType<typeof selectTimelineWindowByProjectedLimit>["selectedRows"];
minSeq: number | null;
maxSeq: number | null;
} {
const { agentId, direction, cursor, requestedLimit } = params;
let timeline = params.timeline;
const projectedLimit = Math.max(1, Math.floor(requestedLimit));
let fetchLimit = projectedLimit;
let projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
while (timeline.hasOlder) {
const needsMoreProjectedEntries = projectedWindow.projectedEntries.length < projectedLimit;
const firstLoadedRow = timeline.rows[0];
const firstSelectedRow = projectedWindow.selectedRows[0];
const startsAtLoadedBoundary =
firstLoadedRow != null &&
firstSelectedRow != null &&
firstSelectedRow.seq === firstLoadedRow.seq;
const boundaryIsAssistantChunk =
startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message";
if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) {
break;
}
const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1);
const nextFetchLimit = Math.min(maxRows, fetchLimit * 2);
if (nextFetchLimit <= fetchLimit) {
break;
}
fetchLimit = nextFetchLimit;
timeline = this.agentManager.fetchTimeline(agentId, {
direction,
cursor,
limit: fetchLimit,
});
projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
} }
return { const firstRow = timeline.rows[0];
timeline, if (
selectedRows: projectedWindow.selectedRows, firstRow?.item.type === "assistant_message" ||
minSeq: projectedWindow.minSeq, firstRow?.item.type === "reasoning" ||
maxSeq: projectedWindow.maxSeq, firstRow?.item.type === "tool_call"
}; ) {
return true;
}
return timeline.rows.some((row) => row.item.type === "tool_call");
} }
private async handleFetchAgentTimelineRequest( private async handleFetchAgentTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>, msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
): Promise<void> { ): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection: TimelineProjectionMode = msg.projection ?? "projected"; const projection: TimelineProjectionMode = "projected";
const requestedLimit = msg.limit; const requestedLimit = msg.limit;
const limit = requestedLimit ?? (direction === "after" ? 0 : undefined); const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200);
const shouldLimitByProjectedWindow =
projection === "canonical" &&
direction === "tail" &&
typeof requestedLimit === "number" &&
requestedLimit > 0;
const cursor: AgentTimelineCursor | undefined = msg.cursor const cursor: AgentTimelineCursor | undefined = msg.cursor
? { ? {
epoch: msg.cursor.epoch, epoch: msg.cursor.epoch,
@@ -7603,43 +7457,29 @@ export class Session {
}); });
const agentPayload = await this.buildAgentPayload(snapshot); const agentPayload = await this.buildAgentPayload(snapshot);
let timeline = this.agentManager.fetchTimeline(msg.agentId, { const controlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
direction, direction,
cursor, cursor,
limit: limit: pageLimit,
shouldLimitByProjectedWindow && typeof requestedLimit === "number"
? Math.max(1, Math.floor(requestedLimit))
: limit,
}); });
let hasOlder = timeline.hasOlder; const timeline = this.shouldUseFullTimelineForProjectedPage({
let hasNewer = timeline.hasNewer; timeline: controlTimeline,
let startCursor: { epoch: string; seq: number } | null = null; })
let endCursor: { epoch: string; seq: number } | null = null; ? this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0 })
let entries: ReturnType<typeof projectTimelineRows>; : controlTimeline;
const projectedPage = selectProjectedTimelinePage({
if (shouldLimitByProjectedWindow) { rows: timeline.rows,
const projectedResult = this.loadProjectedTimelineWindow({ bounds: timeline.window,
agentId: msg.agentId, direction: controlTimeline.reset ? "tail" : direction,
direction, ...(cursor ? { cursorSeq: cursor.seq } : {}),
cursor, limit: pageLimit,
requestedLimit, });
timeline, const startCursor =
}); projectedPage.startSeq !== null
timeline = projectedResult.timeline; ? { epoch: timeline.epoch, seq: projectedPage.startSeq }
entries = projectTimelineRows({ rows: projectedResult.selectedRows, mode: projection }); : null;
if (projectedResult.minSeq !== null && projectedResult.maxSeq !== null) { const endCursor =
startCursor = { epoch: timeline.epoch, seq: projectedResult.minSeq }; projectedPage.endSeq !== null ? { epoch: timeline.epoch, seq: projectedPage.endSeq } : null;
endCursor = { epoch: timeline.epoch, seq: projectedResult.maxSeq };
hasOlder = projectedResult.minSeq > timeline.window.minSeq;
hasNewer = false;
}
} else {
const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1];
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null;
entries = projectTimelineRows({ rows: timeline.rows, mode: projection });
}
this.emit({ this.emit({
type: "fetch_agent_timeline_response", type: "fetch_agent_timeline_response",
@@ -7650,17 +7490,19 @@ export class Session {
direction, direction,
projection, projection,
epoch: timeline.epoch, epoch: timeline.epoch,
reset: timeline.reset, reset: controlTimeline.reset,
staleCursor: timeline.staleCursor, staleCursor: controlTimeline.staleCursor,
gap: timeline.gap, gap: controlTimeline.gap,
window: timeline.window, window: timeline.window,
startCursor, startCursor,
endCursor, endCursor,
hasOlder, hasOlder:
hasNewer, projectedPage.hasOlder ||
entries: entries.map((entry) => ({ (projectedPage.startSeq !== null && projectedPage.startSeq > timeline.window.minSeq),
hasNewer: projectedPage.hasNewer,
entries: projectedPage.entries.map((entry) => ({
provider: snapshot.provider, provider: snapshot.provider,
item: projectTimelineItemForClient(entry.item, this.clientCapabilities), item: entry.item,
timestamp: entry.timestamp, timestamp: entry.timestamp,
seqStart: entry.seqStart, seqStart: entry.seqStart,
seqEnd: entry.seqEnd, seqEnd: entry.seqEnd,
@@ -10,8 +10,6 @@ import type {
AgentLaunchContext, AgentLaunchContext,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentPlanResponse,
AgentPlanResult,
AgentPersistenceHandle, AgentPersistenceHandle,
AgentPromptInput, AgentPromptInput,
AgentRunOptions, AgentRunOptions,
@@ -306,7 +304,6 @@ class FakeAgentSession implements AgentSession {
private memoryMarker: string | null = null; private memoryMarker: string | null = null;
private pendingPermissions: AgentPermissionRequest[] = []; private pendingPermissions: AgentPermissionRequest[] = [];
private permissionGate: Deferred<AgentPermissionResponse> | null = null; private permissionGate: Deferred<AgentPermissionResponse> | null = null;
private pendingPlans = new Map<string, { text: string }>();
private readonly historyPath: string; private readonly historyPath: string;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0; private nextTurnOrdinal = 0;
@@ -525,55 +522,6 @@ class FakeAgentSession implements AgentSession {
this.notifySubscribers(completed); this.notifySubscribers(completed);
} }
private async emitActionablePlanTurn(text: string): Promise<void> {
const planId = `fake-plan-${randomUUID()}`;
const planText = text.includes("custom plan body") ? "custom plan body" : "# Plan\n\n- Test it";
this.pendingPlans.set(planId, { text: planText });
const planEvent: AgentStreamEvent = {
type: "timeline",
provider: this.providerName,
item: {
type: "plan",
planId,
text: planText,
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
};
await this.appendHistoryEvent(planEvent);
this.notifySubscribers(planEvent);
const completed: AgentStreamEvent = {
type: "turn_completed",
provider: this.providerName,
usage: { inputTokens: 1, outputTokens: 1 },
};
await this.appendHistoryEvent(completed);
this.notifySubscribers(completed);
}
private async emitPlanFileTurn(): Promise<void> {
const planEvent: AgentStreamEvent = {
type: "timeline",
provider: this.providerName,
item: {
type: "plan",
planId: "plan-file:.paseo/plans/fake.md",
text: "# File plan\n\n- From disk",
},
};
await this.appendHistoryEvent(planEvent);
this.notifySubscribers(planEvent);
const completed: AgentStreamEvent = {
type: "turn_completed",
provider: this.providerName,
usage: { inputTokens: 1, outputTokens: 1 },
};
await this.appendHistoryEvent(completed);
this.notifySubscribers(completed);
}
private async resolveToolPermission(tool: { private async resolveToolPermission(tool: {
name: string; name: string;
input?: Record<string, unknown>; input?: Record<string, unknown>;
@@ -781,16 +729,6 @@ class FakeAgentSession implements AgentSession {
return; return;
} }
if (textPrompt.toLowerCase().includes("emit an actionable plan")) {
await this.emitActionablePlanTurn(textPrompt);
return;
}
if (textPrompt.toLowerCase().includes("emit a plan file")) {
await this.emitPlanFileTurn();
return;
}
const tool = buildToolCallForPrompt(this.providerName, textPrompt); const tool = buildToolCallForPrompt(this.providerName, textPrompt);
if (tool) { if (tool) {
const returnedEarly = await this.emitToolCallTurn(tool, textPrompt); const returnedEarly = await this.emitToolCallTurn(tool, textPrompt);
@@ -896,20 +834,6 @@ class FakeAgentSession implements AgentSession {
this.permissionGate = null; this.permissionGate = null;
} }
async respondToPlan(
planId: string,
response: AgentPlanResponse,
): Promise<AgentPlanResult | void> {
const pending = this.pendingPlans.get(planId);
if (!pending) {
throw new Error(`No pending fake plan with id '${planId}'`);
}
this.pendingPlans.delete(planId);
if (response.actionId === "implement") {
return { followUpPrompt: `Implement fake plan:\n${pending.text}` };
}
}
describePersistence(): AgentPersistenceHandle | null { describePersistence(): AgentPersistenceHandle | null {
return buildPersistence( return buildPersistence(
this.providerName, this.providerName,
@@ -1043,8 +1043,6 @@ export class VoiceAssistantWebSocketServer {
providersSnapshot: true, providersSnapshot: true,
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13. // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
checkoutGithubSetAutoMerge: true, checkoutGithubSetAutoMerge: true,
// COMPAT(firstClassPlans): added in v0.1.82, remove gate after 2026-11-28.
firstClassPlans: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: true, daemonStatusRpc: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
@@ -73,18 +73,6 @@ const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({
capabilities: LegacyAgentCapabilityFlagsSchema, capabilities: LegacyAgentCapabilityFlagsSchema,
}); });
const LegacyPlanToolCallSchema = z.object({
type: z.literal("tool_call"),
callId: z.string(),
name: z.string(),
status: z.enum(["running", "completed", "failed", "canceled"]),
error: z.unknown().nullable(),
detail: z.object({
type: z.literal("plan"),
text: z.string(),
}),
});
interface SessionInternals { interface SessionInternals {
handleFetchAgentTimelineRequest: ( handleFetchAgentTimelineRequest: (
message: Extract< message: Extract<
@@ -244,16 +232,6 @@ function createSessionForWireCompatTest(options?: {
timestamp: "2026-05-02T00:00:00.200Z", timestamp: "2026-05-02T00:00:00.200Z",
item: { type: "assistant_message", text: "done" }, item: { type: "assistant_message", text: "done" },
}, },
{
seq: 4,
timestamp: "2026-05-02T00:00:00.300Z",
item: {
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Do the thing",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
},
]; ];
const session = new Session({ const session = new Session({
@@ -389,38 +367,6 @@ describe("wire compatibility", () => {
expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge"); expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge");
}); });
test("downgrades plan timeline items for clients that do not declare the capability", async () => {
const response = await emitTimelineResponse();
const entry = response.payload.entries.find((item) => item.seqStart === 4);
expect(entry?.item).toEqual({
type: "tool_call",
callId: "plan-1",
name: "Plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "# Plan\n\n- Do the thing",
},
});
expect(() => LegacyPlanToolCallSchema.parse(entry?.item)).not.toThrow();
});
test("preserves plan timeline items for clients that declare the capability", async () => {
const response = await emitTimelineResponse({
[CLIENT_CAPS.firstClassPlans]: true,
});
const entry = response.payload.entries.find((item) => item.seqStart === 4);
expect(entry?.item).toEqual({
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Do the thing",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
});
});
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => { test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
const parsed = LegacySubAgentToolCallSchema.parse({ const parsed = LegacySubAgentToolCallSchema.parse({
type: "tool_call", type: "tool_call",
@@ -96,6 +96,19 @@ it("creates separate terminals for different cwds", async () => {
expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id); expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id);
}); });
it("lists subdirectory terminals when querying the workspace root", async () => {
manager = createTerminalManager();
const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-subdir-root-"));
const subdirCwd = join(rootCwd, "apps", "mobile");
mkdirSync(subdirCwd, { recursive: true });
temporaryDirs.push(rootCwd);
const created = await manager.createTerminal({ cwd: subdirCwd, name: "Mobile" });
const rootTerminals = await manager.getTerminals(rootCwd);
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]);
});
it("creates additional terminal with auto-incrementing name", async () => { it("creates additional terminal with auto-incrementing name", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const cwd = realpathSync(tmpdir()); const cwd = realpathSync(tmpdir());
@@ -6,6 +6,7 @@ import {
} from "./terminal.js"; } from "./terminal.js";
import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js"; import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js";
import { resolve, sep, win32, posix } from "node:path"; import { resolve, sep, win32, posix } from "node:path";
import { isSameOrDescendantPath } from "../server/path-utils.js";
export interface TerminalListItem { export interface TerminalListItem {
id: string; id: string;
@@ -169,7 +170,16 @@ export function createTerminalManager(): TerminalManager {
async getTerminals(cwd: string): Promise<TerminalSession[]> { async getTerminals(cwd: string): Promise<TerminalSession[]> {
assertAbsolutePath(cwd); assertAbsolutePath(cwd);
return terminalsByCwd.get(cwd) ?? []; // Terminals are bucketed by exact cwd, but an agent can open a terminal in
// a subdirectory of the workspace. A query for the workspace root must
// surface those too, so aggregate every bucket at or below `cwd`.
const sessions: TerminalSession[] = [];
for (const [bucketCwd, bucketSessions] of terminalsByCwd) {
if (isSameOrDescendantPath(cwd, bucketCwd)) {
sessions.push(...bucketSessions);
}
}
return sessions;
}, },
async createTerminal(options: { async createTerminal(options: {
@@ -10,7 +10,8 @@ import {
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js"; import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import { TerminalSessionController } from "./terminal-session-controller.js"; import { TerminalSessionController } from "./terminal-session-controller.js";
import type { TerminalManager } from "./terminal-manager.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
import { isSameOrDescendantPath } from "../server/path-utils.js";
function deferred<T>(): { function deferred<T>(): {
promise: Promise<T>; promise: Promise<T>;
@@ -140,3 +141,97 @@ describe("terminal-session-controller restore", () => {
expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe("restore-after\n"); expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe("restore-after\n");
}); });
}); });
function listSession(input: { id: string; name: string; cwd: string }): TerminalSession {
return {
id: input.id,
name: input.name,
cwd: input.cwd,
send: vi.fn(),
subscribe: () => vi.fn(),
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState(""),
getStateSnapshot: () => ({ state: terminalState(""), revision: 0 }),
getReplayPreamble: () => "",
getTitle: () => undefined,
setTitle: vi.fn(),
getExitInfo: () => null,
kill: vi.fn(),
killAndWait: vi.fn(),
};
}
async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("terminal-session-controller subdirectory aggregation", () => {
test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => {
const rootCwd = "/work/repo";
const subdirCwd = "/work/repo/apps/mobile";
// Aggregating subdirectory buckets into the root query is the manager's
// contract, covered by terminal-manager.test.ts. Here we only assert the
// controller re-fetches by root and keys the snapshot by root, so the fake
// returns a fixed aggregated list for the root and nothing otherwise.
const aggregatedRootTerminals = [
listSession({ id: "root-term", name: "Terminal 1", cwd: rootCwd }),
listSession({ id: "subdir-term", name: "Mobile", cwd: subdirCwd }),
];
let changedListener: ((event: TerminalsChangedEvent) => void) | null = null;
const terminalManager: TerminalManager = {
getTerminals: vi.fn(async (cwd: string) => (cwd === rootCwd ? aggregatedRootTerminals : [])),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
setTerminalTitle: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
captureTerminal: vi.fn(),
listDirectories: vi.fn(() => [rootCwd, subdirCwd]),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn((listener) => {
changedListener = listener;
return vi.fn();
}),
};
const outboundMessages: SessionOutboundMessage[] = [];
const controller = new TerminalSessionController({
terminalManager,
emit: (message) => outboundMessages.push(message),
emitBinary: vi.fn(),
hasBinaryChannel: () => true,
isPathWithinRoot: isSameOrDescendantPath,
sessionLogger: createLogger(),
});
controller.start();
controller.dispatch({ type: "subscribe_terminals_request", cwd: rootCwd });
await flushMicrotasks();
outboundMessages.length = 0;
changedListener?.({
cwd: subdirCwd,
terminals: [{ id: "subdir-term", name: "Mobile", cwd: subdirCwd }],
});
await flushMicrotasks();
expect(outboundMessages).toEqual([
{
type: "terminals_changed",
payload: {
cwd: rootCwd,
terminals: [
{ id: "root-term", name: "Terminal 1" },
{ id: "subdir-term", name: "Mobile" },
],
},
},
]);
});
});
@@ -125,9 +125,9 @@ export class TerminalSessionController {
if (!this.terminalManager) { if (!this.terminalManager) {
return; return;
} }
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => {
this.handleTerminalsChanged(event), void this.handleTerminalsChanged(event);
); });
} }
getMetrics(): TerminalSessionControllerMetrics { getMetrics(): TerminalSessionControllerMetrics {
@@ -293,31 +293,30 @@ export class TerminalSessionController {
}; };
} }
private handleTerminalsChanged(event: TerminalsChangedEvent): void { private async handleTerminalsChanged(event: TerminalsChangedEvent): Promise<void> {
if (!this.subscribedDirectories.has(event.cwd)) { // A terminal can live in a subdirectory of a subscribed workspace root (an
return; // agent can open one there). Deliver the change to every subscribed root at
// or above the terminal's cwd, keyed by that root, carrying the full
// aggregated list — so the client's cache replacement doesn't drop the
// terminals that live directly at the root.
const matchingRoots = Array.from(this.subscribedDirectories).filter((root) =>
this.isPathWithinRoot(root, event.cwd),
);
for (const root of matchingRoots) {
await this.emitTerminalsSnapshotForRoot(root);
} }
this.emitTerminalsChangedSnapshot({
cwd: event.cwd,
terminals: event.terminals.map((terminal) =>
Object.assign(
{ id: terminal.id, name: terminal.name },
terminal.title ? { title: terminal.title } : {},
),
),
});
} }
private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void { private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void {
this.subscribedDirectories.add(msg.cwd); this.subscribedDirectories.add(msg.cwd);
void this.emitInitialTerminalsChangedSnapshot(msg.cwd); void this.emitTerminalsSnapshotForRoot(msg.cwd);
} }
private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void { private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void {
this.subscribedDirectories.delete(msg.cwd); this.subscribedDirectories.delete(msg.cwd);
} }
private async emitInitialTerminalsChangedSnapshot(cwd: string): Promise<void> { private async emitTerminalsSnapshotForRoot(cwd: string): Promise<void> {
if (!this.terminalManager || !this.subscribedDirectories.has(cwd)) { if (!this.terminalManager || !this.subscribedDirectories.has(cwd)) {
return; return;
} }
@@ -1,6 +1,6 @@
import { afterEach, expect, it } from "vitest"; import { afterEach, expect, it } from "vitest";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { createWorkerTerminalManager } from "./worker-terminal-manager.js"; import { createWorkerTerminalManager } from "./worker-terminal-manager.js";
@@ -332,6 +332,24 @@ it("starts the default shell through the worker and accepts quoted commands", as
expect(readFileSync(markerPath, "utf8")).toBe("shell-ok"); expect(readFileSync(markerPath, "utf8")).toBe("shell-ok");
}); });
it("lists subdirectory terminals when querying the workspace root", async () => {
const rootCwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-subdir-root-"));
const subdirCwd = join(rootCwd, "apps", "mobile");
mkdirSync(subdirCwd, { recursive: true });
temporaryDirs.push(rootCwd);
manager = createWorkerTerminalManager();
const created = trackTerminal(
await manager.createTerminal({
cwd: subdirCwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
}),
);
const rootTerminals = await manager.getTerminals(rootCwd);
expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]);
});
it("removes worker terminals after killAndWait", async () => { it("removes worker terminals after killAndWait", async () => {
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-")); const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-"));
temporaryDirs.push(cwd); temporaryDirs.push(cwd);