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:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness.
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. This always returns full projected timeline items, never lifecycle deltas.
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.
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`.
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,
openAddProviderModal,
openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings";
const ACP_PROVIDER = {
@@ -18,6 +19,8 @@ test.describe("ACP provider catalog", () => {
await gotoAppShell(page);
await openSettings(page);
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 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 { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost } from "./settings";
import { openSettingsHost, openSettingsHostSection } from "./settings";
interface DaemonApiStatus {
version: string;
@@ -208,6 +208,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
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({
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 & {
fetchAgentTimeline: (
agentId: string,
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
options?: { direction?: "head" | "tail"; projection?: "projected"; limit?: number },
) => Promise<{ epoch?: string }>;
};
const timeline = await client.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "canonical",
projection: "projected",
limit: 0,
});
return timeline.epoch;
+68 -21
View File
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { TEST_HOST_LABEL } from "./daemon-registry";
import { escapeRegex } from "./regex";
import { getServerId } from "./server-id";
@@ -13,6 +14,8 @@ const SECTION_LABELS = {
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
type HostSection = "connections" | "orchestration" | "providers" | "daemon";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
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> {
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
// Host sections are now flat top-level rows under the Host group. Navigate by
// 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> {
@@ -37,6 +54,9 @@ export async function expectSettingsHeader(page: Page, title: string): Promise<v
}
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 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.getByText("Theme", { exact: true })).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> {
@@ -105,7 +125,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
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> {
const card = page.getByTestId("host-page-connections-card");
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(
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
@@ -193,14 +217,29 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
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-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-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> {
await page.route(url, async (route) => {
await route.fulfill({
@@ -244,27 +283,35 @@ export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
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);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
// App group rows remain top-level.
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: "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> {
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
export async function expectHostPageVisible(page: Page, _serverId: string): Promise<void> {
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");
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
// 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
// exercise.
const picker = sidebar.getByTestId("settings-host-picker");
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 {
expectSettingsHeader,
openSettingsHost,
openHostSection,
expectHostLabelDisplayed,
clickEditHostLabel,
expectHostLabelEditMode,
expectHostConnectionsCard,
expectHostInjectMcpCard,
expectHostActionCards,
expectHostProvidersCard,
expectHostNoLocalOnlyRows,
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
@@ -19,9 +21,7 @@ import {
} from "./helpers/settings";
test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
}) => {
test("connections section shows the seeded connection endpoint", async ({ page }) => {
const serverId = getServerId();
const port = getE2EDaemonPort();
@@ -29,11 +29,44 @@ test.describe("Settings host page", () => {
await openSettings(page);
await openSettingsHost(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectHostLabelDisplayed(page);
await expectSettingsHeader(page, "Connections");
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 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 }) => {
@@ -42,13 +75,14 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
await expectHostLabelDisplayed(page);
await clickEditHostLabel(page);
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,
}) => {
const serverId = getServerId();
@@ -56,19 +90,20 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
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 openSettings(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,
}) => {
const serverId = getServerId();
@@ -77,9 +112,10 @@ test.describe("Settings host page", () => {
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expectHostPageVisible(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectSettingsHeader(page, "Connections");
await openHostSection(page, serverId, "daemon");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page);
await expectHostActionCards(page, serverId);
});
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);
});
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 verifyLegacyHostSettingsRedirect(page);
});
@@ -132,7 +132,9 @@ test.describe("Settings — compact master-detail", () => {
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 openCompactSettings(page);
@@ -32,11 +32,30 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
autoscrollToTopThreshold: 0,
});
const HISTORY_START_THRESHOLD_PX = 96;
const SCROLL_TO_INDEX_RETRY_DELAY_MS = 80;
interface NativeScrollToIndexFailedInfo {
index: number;
highestMeasuredFrameIndex: number;
averageItemLength: number;
}
interface NativeScrollIndexFallbackInput {
index: number;
averageItemLength: number;
}
function keyExtractor(item: { id: string }): string {
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 }) {
const {
agentId,
@@ -69,6 +88,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const scrollOffsetYRef = useRef(0);
const programmaticScrollEventBudgetRef = useRef(0);
const scrollToIndexRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
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(() => {
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
@@ -149,6 +176,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollToBottom,
});
const scrollToHistoryIndex = useCallback(
(index: number) => {
programmaticScrollEventBudgetRef.current = 3;
clearScrollToIndexRetry();
flatListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0.5,
});
},
[clearScrollToIndexRetry],
);
const handleScrollToIndexFailed = useStableEvent((info: NativeScrollToIndexFailedInfo) => {
programmaticScrollEventBudgetRef.current = 3;
flatListRef.current?.scrollToOffset({
offset: getNativeScrollToIndexFallbackOffset(info),
animated: true,
});
clearScrollToIndexRetry();
scrollToIndexRetryTimeoutRef.current = setTimeout(() => {
scrollToIndexRetryTimeoutRef.current = null;
programmaticScrollEventBudgetRef.current = 3;
flatListRef.current?.scrollToIndex({
index: info.index,
animated: true,
viewPosition: 0.5,
});
}, SCROLL_TO_INDEX_RETRY_DELAY_MS);
});
useEffect(() => {
streamViewportMetricsRef.current = {
containerKey: "native-virtualized",
@@ -161,6 +219,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
scrollOffsetYRef.current = 0;
clearNativeViewportSettling();
clearScrollToIndexRetry();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
const frame = requestAnimationFrame(() => {
@@ -169,7 +228,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
};
}, [agentId, clearNativeViewportSettling]);
}, [agentId, clearNativeViewportSettling, clearScrollToIndexRetry]);
useEffect(() => {
const keyboardEvents = [
@@ -190,8 +249,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
subscription.remove();
}
clearNativeViewportSettling();
clearScrollToIndexRetry();
};
}, [clearNativeViewportSettling, markNativeViewportSettling]);
}, [clearNativeViewportSettling, clearScrollToIndexRetry, markNativeViewportSettling]);
useEffect(() => {
bottomAnchorController.prepareForStickyContentChange();
@@ -209,6 +269,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling();
},
scrollToStreamItem: (target) => {
programmaticScrollEventBudgetRef.current = 3;
if (target.source === "liveHead") {
flatListRef.current?.scrollToOffset({
offset: 0,
animated: true,
});
return;
}
scrollToHistoryIndex(target.index);
},
};
viewportRef.current = handle;
return () => {
@@ -216,7 +287,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef.current = null;
}
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
}, [
agentId,
bottomAnchorController,
markNativeViewportSettling,
scrollToHistoryIndex,
viewportRef,
]);
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
@@ -360,6 +437,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
onScrollToIndexFailed={handleScrollToIndexFailed}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={40}
maxToRenderPerBatch={40}
+33 -5
View File
@@ -1,5 +1,4 @@
import React, {
Fragment,
type CSSProperties,
useCallback,
useEffect,
@@ -67,6 +66,10 @@ function scrollElementToBottom(
});
}
function getStreamItemElementId(itemId: string): string {
return `agent-stream-row-${itemId}`;
}
function syncNearBottom(
scrollContainer: HTMLElement | null,
onNearBottomChange: (value: boolean) => void,
@@ -468,6 +471,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
scheduleStickToBottom();
},
scrollToStreamItem: (target) => {
setFollowOutput(false);
cancelPendingStickToBottom();
if (target.source === "historyVirtualized") {
rowVirtualizer.scrollToIndex(target.index, { align: "center" });
return;
}
const row = document.getElementById(getStreamItemElementId(target.itemId));
row?.scrollIntoView({ block: "center", behavior: "auto" });
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
syncNearBottom(scrollContainer, onNearBottomChange);
}
},
};
viewportRef.current = handle;
return () => {
@@ -476,7 +494,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
cancelPendingStickToBottom();
};
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]);
}, [
cancelPendingStickToBottom,
forceStickToBottom,
onNearBottomChange,
rowVirtualizer,
scheduleStickToBottom,
viewportRef,
]);
const contentContainerStyle = useMemo((): CSSProperties => {
return {
@@ -520,14 +545,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
<div id={getStreamItemElementId(item.id)} key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
</div>
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
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]);
const liveAuxiliary = useMemo(() => {
@@ -570,6 +597,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
<div
key={virtualRow.key}
data-index={virtualRow.index}
id={getStreamItemElementId(item.id)}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
@@ -42,6 +42,13 @@ export interface StreamEdgeSlotProps {
export interface StreamViewportHandle {
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
scrollToStreamItem: (target: StreamScrollTarget) => void;
}
export interface StreamScrollTarget {
source: "historyVirtualized" | "historyMounted" | "liveHead";
index: number;
itemId: string;
}
export interface StreamSegmentRenderers {
+240 -94
View File
@@ -36,12 +36,12 @@ import {
CompactionMarker,
MessageOuterSpacingProvider,
type InlinePathTarget,
type MessageFindHighlight,
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type {
AgentPlanAction,
AgentPermissionAction,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
@@ -59,6 +59,11 @@ import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
import { layoutStream, type StreamLayoutItem } from "./layout";
import {
buildAgentStreamSearchModel,
findAgentStreamSearchMatches,
type AgentStreamSearchMatch,
} from "@/components/agent-stream-search-model";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
@@ -76,6 +81,12 @@ import {
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
FindBar,
type PaneFindMatchState,
type UsePaneFindResult,
usePaneFind,
} from "@/panels/pane-find";
import { isWeb } from "@/constants/platform";
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: {
content: ReactNode;
layoutItem: StreamLayoutItem;
@@ -278,6 +209,69 @@ function renderLiveHeadStreamItem(input: {
return input.renderStreamItem(layoutItem);
}
function createAgentStreamFindMatchState(input: {
query: string;
matches: AgentStreamSearchMatch[];
currentMatchId: string | null;
}): PaneFindMatchState {
if (input.query.length === 0) {
return { status: "empty" };
}
if (input.matches.length === 0) {
return { status: "no-match" };
}
const currentIndex = Math.max(
0,
input.matches.findIndex((match) => match.id === input.currentMatchId),
);
return {
status: "matched",
current: currentIndex + 1,
total: input.matches.length,
};
}
function createAgentStreamFindHighlightsByItemId(input: {
matches: AgentStreamSearchMatch[];
currentMatchId: string | null;
}): Map<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 {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -454,6 +448,154 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
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(
ref,
() => ({
@@ -504,10 +646,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
findHighlights={findHighlightsByItemId.get(item.id)}
/>
);
},
[agent.capabilities, agentId, client, resolvedServerId],
[agent.capabilities, agentId, client, findHighlightsByItemId, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -527,11 +670,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
findHighlights={findHighlightsByItemId.get(item.id)}
/>
</AssistantFileLinkResolverProvider>
);
},
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
[
client,
findHighlightsByItemId,
handleInlinePathPress,
resolvedServerId,
toast,
workspaceRoot,
],
);
const renderThoughtItem = useCallback(
@@ -630,9 +781,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
case "todo_list":
return <TodoListCard items={item.items} />;
case "plan":
return <PlanTimelineCard item={item} agentId={agentId} client={client} />;
case "compaction":
return (
<CompactionMarker
@@ -646,14 +794,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return null;
}
},
[
agentId,
client,
renderUserMessageItem,
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
],
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
);
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
@@ -791,6 +932,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
<AgentStreamFindBarSlot paneFind={paneFind} />
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
agentId,
@@ -839,6 +981,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function AgentStreamFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) {
return paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null;
}
interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange"
+2 -1
View File
@@ -867,7 +867,8 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<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>
);
}
@@ -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,
useEffect,
useMemo,
@@ -20,9 +20,12 @@ import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import {
getDesktopHost,
isElectronRuntime,
type DesktopBrowserFoundInPageResult,
type DesktopBrowserFindAction,
type DesktopBrowserShortcutEvent,
} from "@/desktop/host";
import { isDev } from "@/constants/platform";
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
type ElectronWebview = HTMLElement & {
@@ -50,6 +53,15 @@ type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted"> & {
const ERR_ABORTED = -3;
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 {
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";
}
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: {
webview: ElectronWebview;
onSelection: (selection: BrowserElementSelection) => void;
@@ -295,7 +319,13 @@ export function BrowserPane({
browserRef.current = browser;
const pendingNavigationUrlRef = useRef<string | null>(null);
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 [browserFindMatchState, setBrowserFindMatchState] =
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
const [draftUrl, setDraftUrl] = useState(browser?.url ?? "https://example.com");
const workspaceAttachmentScopeKey = useMemo(
() => 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(() => {
if (!isElectronRuntime()) {
return;
@@ -403,6 +542,7 @@ export function BrowserPane({
webview.style.background = "transparent";
const handleStartLoading = () => {
clearBrowserFindSelection();
updateBrowser(browserId, { isLoading: true, lastError: null });
syncNavigationState({ syncUrl: false });
};
@@ -429,6 +569,7 @@ export function BrowserPane({
syncNavigationState();
};
const handleWillNavigate = (event: Event) => {
clearBrowserFindSelection();
const nextUrl =
typeof (event as Event & { url?: unknown }).url === "string"
? ((event as Event & { url?: string }).url ?? "")
@@ -471,6 +612,9 @@ export function BrowserPane({
const handleDomReady = () => {
domReadyRef.current = true;
syncNavigationState();
if (browserFindOpenRef.current && browserFindQueryRef.current) {
runBrowserFind(browserFindQueryRef.current, "next", { reset: true });
}
};
const handleWebviewFocus = () => {
onFocusPane?.();
@@ -488,6 +632,29 @@ export function BrowserPane({
webview.addEventListener("focus", 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);
if (initialUnsafeNavigationMessage) {
updateBrowserRef.current(browserIdRef.current, {
@@ -506,8 +673,16 @@ export function BrowserPane({
webview.removeEventListener("page-favicon-updated", handleFaviconUpdated);
webview.removeEventListener("did-fail-load", handleLoadFailed);
webview.removeEventListener("dom-ready", handleDomReady);
didCleanupFoundInPage = true;
unsubscribeFoundInPage?.();
webview.removeEventListener("focus", handleWebviewFocus);
webview.removeEventListener("mousedown", handleWebviewFocus);
if (domReadyRef.current) {
stopBrowserFindInPage({
browserId: browserIdRef.current,
action: "clearSelection",
});
}
if (host.contains(webview)) {
host.removeChild(webview);
}
@@ -519,6 +694,35 @@ export function BrowserPane({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
const webview = webviewRef.current;
@@ -939,6 +1143,7 @@ export function BrowserPane({
return (
<View style={styles.container}>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<View style={styles.chromeRow}>
<View style={styles.chromeLeft}>
<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 type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
@@ -15,7 +15,7 @@ import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import type { HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
@@ -26,15 +26,33 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import {
createFilePaneFindTokenSegments,
createFilePaneLineFindHighlightMap,
createFilePaneTextRenderData,
findFilePaneTextMatches,
type FilePaneFindLineHighlight,
type FilePaneFindMatch,
type FilePaneFindTokenSegment,
} from "@/components/file-pane-text-render-data";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import {
FindBar,
type PaneFindMatchState,
type UsePaneFindResult,
usePaneFind,
} from "@/panels/pane-find";
interface CodeLineProps {
tokens: HighlightToken[];
segments: FilePaneFindTokenSegment[];
lineNumber: number;
gutterWidth: number;
highlighted: boolean;
matchBackgroundColor: string;
currentMatchBackgroundColor: string;
onLineRef?: (lineNumber: number, node: View | null) => void;
}
interface FilePreviewBodyProps {
@@ -46,6 +64,50 @@ interface FilePreviewBodyProps {
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 {
if (typeof value !== "string") {
return null;
@@ -117,11 +179,20 @@ function clampLineSelection(input: {
}
const CodeLine = React.memo(function CodeLine({
tokens,
segments,
lineNumber,
gutterWidth,
highlighted,
matchBackgroundColor,
currentMatchBackgroundColor,
onLineRef,
}: CodeLineProps) {
const setLineRef = useCallback(
(node: View | null) => {
onLineRef?.(lineNumber, node);
},
[lineNumber, onLineRef],
);
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
@@ -131,19 +202,28 @@ const CodeLine = React.memo(function CodeLine({
[highlighted],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
() => segments.map((segment, index) => ({ key: `${index}-${segment.text}`, segment })),
[segments],
);
return (
<View style={lineStyle}>
<View ref={setLineRef} style={lineStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)}
</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => (
<CodeLineToken key={key} token={token} />
{keyedTokens.map(({ key, segment }) => (
<CodeLineToken
key={key}
backgroundColor={getFindSegmentBackgroundColor({
currentMatchBackgroundColor,
matchBackgroundColor,
segment,
})}
style={segment.style}
text={segment.text}
/>
))}
</Text>
</View>
@@ -151,11 +231,237 @@ const CodeLine = React.memo(function CodeLine({
});
interface CodeLineTokenProps {
token: HighlightToken;
backgroundColor?: string;
style: HighlightToken["style"];
text: string;
}
function CodeLineToken({ token }: CodeLineTokenProps) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
function CodeLineToken({ backgroundColor, style, text }: CodeLineTokenProps) {
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) => ({
@@ -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({
preview,
isLoading,
@@ -196,40 +671,50 @@ function FilePreviewBody({
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const filePath = location.path;
const isDark = theme.colorScheme === "dark";
const matchBackgroundColor = isDark ? "rgba(250, 204, 21, 0.32)" : "rgba(250, 204, 21, 0.38)";
const currentMatchBackgroundColor = isDark
? "rgba(251, 146, 60, 0.58)"
: "rgba(251, 146, 60, 0.48)";
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile =
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
const previewScrollRef = useRef<RNScrollView>(null);
const fallbackScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => {
const textRenderData = useMemo(() => {
if (!preview || preview.kind !== "text" || isMarkdownFile) {
return null;
}
return highlightCode(preview.content ?? "", filePath);
return createFilePaneTextRenderData(preview.content ?? "", filePath);
}, [isMarkdownFile, preview, filePath]);
const textLineNumbers = useMemo(
() => textRenderData?.lines.map((line) => line.lineNumber) ?? null,
[textRenderData],
);
const textScrollRefs = useFilePaneTextScrollRefs(textLineNumbers);
const previewScrollRef = textRenderData ? textScrollRefs.previewScrollRef : fallbackScrollRef;
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
}, [highlightedLines, theme.fontSize.code]);
if (!textRenderData) return 0;
return lineNumberGutterWidth(textRenderData.lines.length, theme.fontSize.code);
}, [textRenderData, theme.fontSize.code]);
const lineHeight = theme.fontSize.code * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
if (!textRenderData) {
return null;
}
return clampLineSelection({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
lineCount: highlightedLines.length,
lineCount: textRenderData.lines.length,
});
}, [highlightedLines, location.lineEnd, location.lineStart]);
}, [textRenderData, location.lineEnd, location.lineStart]);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
@@ -247,141 +732,83 @@ function FilePreviewBody({
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection]);
}, [lineHeight, lineSelection, previewScrollRef]);
let content: React.ReactNode;
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
content = (
<FilePaneCenterState>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
</FilePaneCenterState>
);
}
if (!preview) {
return (
<View style={styles.centerState}>
} else if (!preview) {
content = (
<FilePaneCenterState>
<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") {
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>
);
return <View style={styles.previewScrollContainer}>{content}</View>;
}
export function FilePane({
+198 -18
View File
@@ -124,6 +124,7 @@ interface UserMessageProps {
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
disableOuterSpacing?: boolean;
findHighlights?: MessageFindHighlight[];
}
const MessageOuterSpacingContext = createContext(false);
@@ -158,6 +159,19 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
] as const;
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <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 {
children: ReactNode;
style: ReturnType<typeof createMarkdownStyles>;
@@ -209,6 +223,95 @@ const SCROLL_EDGE_EPSILON = 0.5;
export const STREAM_METADATA_FONT_SIZE = 13;
type ScrollAxis = "x" | "y";
function normalizeFindHighlights(
textLength: number,
highlights: MessageFindHighlight[] | undefined,
): MessageFindHighlight[] {
if (!highlights || highlights.length === 0) {
return [];
}
return highlights
.map((highlight) => ({
...highlight,
start: Math.max(0, Math.min(textLength, highlight.start)),
end: Math.max(0, Math.min(textLength, highlight.end)),
}))
.filter((highlight) => highlight.start < highlight.end)
.sort((left, right) => left.start - right.start || left.end - right.end);
}
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() {
if (isNative) {
return;
@@ -369,6 +472,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
lineHeight: 22,
overflowWrap: "anywhere",
},
findMatchText: {
backgroundColor:
theme.colorScheme === "dark" ? "rgba(250, 204, 21, 0.32)" : "rgba(250, 204, 21, 0.38)",
},
findCurrentMatchText: {
backgroundColor:
theme.colorScheme === "dark" ? "rgba(251, 146, 60, 0.58)" : "rgba(251, 146, 60, 0.48)",
},
imagePreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
@@ -436,6 +547,28 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
function MessageFindHighlightedText({ segments }: { segments: MessageFindTextSegment[] }) {
return (
<>
{segments.map((segment) => (
<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 }) {
const uri = useAttachmentPreviewUrl(image);
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]);
@@ -475,6 +608,7 @@ export const UserMessage = memo(function UserMessage({
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
findHighlights,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
@@ -498,6 +632,10 @@ export const UserMessage = memo(function UserMessage({
},
[rewindMutation],
);
const highlightedMessageSegments = useMemo(
() => createMessageFindTextSegments(message, findHighlights),
[findHighlights, message],
);
const containerStyle = useMemo(
() => [
@@ -567,7 +705,7 @@ export const UserMessage = memo(function UserMessage({
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
{message}
<MessageFindHighlightedText segments={highlightedMessageSegments} />
</Text>
) : null}
</View>
@@ -752,6 +890,7 @@ interface AssistantMessageProps {
serverId?: string;
client?: DaemonClient | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
findHighlights?: MessageFindHighlight[];
}
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
@@ -1487,6 +1626,7 @@ interface MemoizedMarkdownBlockProps {
rules: RenderRules;
parser: MarkdownIt;
onLinkPress: (url: string) => boolean;
findHighlights?: MessageFindHighlight[];
}
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
@@ -1494,26 +1634,61 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
rules,
parser,
onLinkPress,
findHighlights,
}: MemoizedMarkdownBlockProps) {
const textCursorRef = useRef(0);
textCursorRef.current = 0;
const renderFindHighlightedText = useCallback(
(content: string) => {
if (!findHighlights || findHighlights.length === 0 || content.length === 0) {
return content;
}
const start = text.indexOf(content, textCursorRef.current);
if (start < 0) {
return content;
}
textCursorRef.current = start + content.length;
const end = start + content.length;
const contentHighlights = findHighlights
.filter((highlight) => highlight.start < end && highlight.end > start)
.map((highlight) => ({
id: highlight.id,
start: Math.max(0, highlight.start - start),
end: Math.min(content.length, highlight.end - start),
isCurrent: highlight.isCurrent,
}));
const segments = createMessageFindTextSegments(content, contentHighlights);
return <MessageFindHighlightedText segments={segments} />;
},
[findHighlights, text],
);
return (
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
<MarkdownFindHighlightContext.Provider value={renderFindHighlightedText}>
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
</MarkdownFindHighlightContext.Provider>
);
});
const MarkdownFindHighlightContext = createContext<((content: string) => ReactNode) | null>(null);
interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle;
textStyle: TextStyle;
style?: StyleProp<TextStyle>;
children: ReactNode;
disableFindHighlight?: boolean;
}
function MarkdownInheritedText({
@@ -1521,12 +1696,18 @@ function MarkdownInheritedText({
textStyle,
style: overrideStyle,
children,
disableFindHighlight = false,
}: MarkdownInheritedTextProps) {
const renderFindHighlightedText = useContext(MarkdownFindHighlightContext);
const style = useMemo(
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>;
const renderedChildren =
!disableFindHighlight && typeof children === "string" && renderFindHighlightedText
? renderFindHighlightedText(children)
: children;
return <MarkdownTextSpan style={style}>{renderedChildren}</MarkdownTextSpan>;
}
interface MarkdownListItemContentProps {
@@ -1559,6 +1740,7 @@ export const AssistantMessage = memo(function AssistantMessage({
serverId,
client,
spacing = "default",
findHighlights,
}: AssistantMessageProps) {
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
@@ -1696,6 +1878,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.code_inline}
disableFindHighlight
>
{content}
</MarkdownInheritedText>
@@ -1803,11 +1986,7 @@ export const AssistantMessage = memo(function AssistantMessage({
};
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(
() => blocks.map((block, index) => ({ key: `${index}:${block.slice(0, 32)}`, block })),
[blocks],
);
const keyedBlocks = useMemo(() => createMarkdownBlocksWithOffsets(message), [message]);
const assistantContainerStyle = useMemo(
() => [
@@ -1822,7 +2001,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<View testID="assistant-message" style={assistantContainerStyle}>
{keyedBlocks.map(({ key, block }, index) => (
{keyedBlocks.map(({ key, block, startOffset }, index) => (
<AssistantMessageBlockContainer
key={key}
block={block}
@@ -1833,6 +2012,7 @@ export const AssistantMessage = memo(function AssistantMessage({
rules={markdownRules}
parser={markdownParser}
onLinkPress={handleMarkdownLinkPress}
findHighlights={createBlockFindHighlights(startOffset, block.length, findHighlights)}
/>
</AssistantMessageBlockContainer>
))}
@@ -47,7 +47,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
: undefined;
await input.client.fetchAgentTimeline(input.agentId, {
direction: "tail",
projection: "canonical",
projection: "projected",
...(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 {
TerminalEmulatorRuntime,
type TerminalFindResultChangeEvent,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import type {
@@ -49,6 +50,10 @@ export interface TerminalEmulatorHandle {
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
blur: () => void;
findNext: (input: { query: string }) => boolean;
findPrevious: (input: { query: string }) => boolean;
clearFindDecorations: () => void;
onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => () => void;
}
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
@@ -328,6 +333,18 @@ export default function TerminalEmulator({
blur: () => {
runtimeRef.current?.blur();
},
findNext: (input: { query: string }) => {
return runtimeRef.current?.findNext(input) ?? false;
},
findPrevious: (input: { query: string }) => {
return runtimeRef.current?.findPrevious(input) ?? false;
},
clearFindDecorations: () => {
runtimeRef.current?.clearFindDecorations();
},
onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => {
return runtimeRef.current?.onFindResultsChanged(listener) ?? (() => {});
},
}),
[],
);
@@ -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 {
ActivityIndicator,
Pressable,
@@ -47,6 +47,8 @@ import {
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
import type { TerminalFindResultChangeEvent } from "@/terminal/runtime/terminal-emulator-runtime";
interface TerminalPaneProps {
serverId: string;
@@ -106,10 +108,25 @@ const EMPTY_MODIFIERS: ModifierState = {
alt: false,
};
const EMPTY_FIND_MATCH_STATE: PaneFindMatchState = { status: "empty" };
const NO_FIND_MATCH_STATE: PaneFindMatchState = { status: "no-match" };
const PENDING_FIND_MATCH_STATE: PaneFindMatchState = { status: "pending" };
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
function terminalFindStateFromResult(event: TerminalFindResultChangeEvent): PaneFindMatchState {
if (event.resultCount <= 0 || event.resultIndex < 0) {
return NO_FIND_MATCH_STATE;
}
return {
status: "matched",
current: event.resultIndex + 1,
total: event.resultCount,
};
}
interface ModifierButtonProps {
modifier: keyof ModifierState;
active: boolean;
@@ -212,9 +229,16 @@ export function TerminalPane({
win32InputMode: false,
});
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const terminalFindQueryRef = useRef("");
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
const [terminalFindMatchState, setTerminalFindMatchStateValue] =
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
const setTerminalFindMatchState = useCallback((nextState: PaneFindMatchState) => {
setTerminalFindMatchStateValue(nextState);
}, []);
useEffect(() => {
terminalIdRef.current = terminalId;
@@ -224,6 +248,19 @@ export function TerminalPane({
};
}, [terminalId]);
useEffect(() => {
if (!isWorkspaceFocused || !terminalId) {
return;
}
return emulatorRef.current?.onFindResultsChanged((event) => {
if (terminalFindQueryRef.current.length === 0) {
return;
}
setTerminalFindMatchState(terminalFindStateFromResult(event));
});
}, [isWorkspaceFocused, setTerminalFindMatchState, terminalId]);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
@@ -251,6 +288,43 @@ export function TerminalPane({
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
);
const clearTerminalFind = useCallback(() => {
terminalFindQueryRef.current = "";
emulatorRef.current?.clearFindDecorations();
setTerminalFindMatchState(EMPTY_FIND_MATCH_STATE);
}, [setTerminalFindMatchState]);
const runTerminalFind = useCallback(
(query: string, direction: "next" | "previous"): PaneFindMatchState => {
terminalFindQueryRef.current = query;
if (query.length === 0) {
clearTerminalFind();
return EMPTY_FIND_MATCH_STATE;
}
const found =
direction === "previous"
? (emulatorRef.current?.findPrevious({ query }) ?? false)
: (emulatorRef.current?.findNext({ query }) ?? false);
if (!found) {
setTerminalFindMatchState(NO_FIND_MATCH_STATE);
return NO_FIND_MATCH_STATE;
}
setTerminalFindMatchState(PENDING_FIND_MATCH_STATE);
return PENDING_FIND_MATCH_STATE;
},
[clearTerminalFind, setTerminalFindMatchState],
);
const paneFind = usePaneFind({
matchState: terminalFindMatchState,
onQuery: (query) => runTerminalFind(query, "next"),
onNext: () => runTerminalFind(terminalFindQueryRef.current, "next"),
onPrev: () => runTerminalFind(terminalFindQueryRef.current, "previous"),
onClose: clearTerminalFind,
});
useEffect(() => {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
@@ -748,6 +822,7 @@ export function TerminalPane({
return (
<Animated.View style={containerStyle}>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<View style={styles.outputContainer}>
{isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}>
+25
View File
@@ -92,10 +92,35 @@ export interface DesktopBrowserShortcutEvent {
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 {
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
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 {
@@ -34,7 +34,7 @@ afterEach(() => {
});
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();
useSessionStore.getState().initializeSession(serverId, client as never);
useSessionStore
@@ -56,12 +56,12 @@ describe("ensureAgentIsInitialized", () => {
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
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();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -75,7 +75,7 @@ describe("ensureAgentIsInitialized", () => {
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
@@ -107,7 +107,7 @@ describe("ensureAgentIsInitialized", () => {
});
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();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -121,7 +121,7 @@ describe("refreshAgent", () => {
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
});
@@ -27,6 +27,25 @@ import {
navigateToLastWorkspace,
useActiveWorkspaceSelection,
} 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({
enabled,
@@ -94,6 +113,8 @@ export function useKeyboardShortcuts({
return false;
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "pane-find-open":
return handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
case "navigate-workspace":
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true;
@@ -182,10 +203,7 @@ export function useKeyboardShortcuts({
chordStateRef.current = result.nextChordState;
if (result.preventDefault) {
event.preventDefault();
event.stopPropagation();
}
applyPendingChordEventHandling(event, result);
if (!result.match) {
return;
@@ -208,12 +226,7 @@ export function useKeyboardShortcuts({
return;
}
if (result.match.preventDefault) {
event.preventDefault();
}
if (result.match.stopPropagation) {
event.stopPropagation();
}
applyHandledShortcutEventHandling(event, result);
};
const handleKeyUp = (event: KeyboardEvent) => {
@@ -147,7 +147,7 @@ describe("loadOlderAgentHistory", () => {
direction: "before",
cursor: { epoch: "epoch-1", seq: 10 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
},
},
]);
@@ -10,7 +10,7 @@ export interface LoadOlderAgentHistoryClient {
direction: "before";
cursor: { epoch: string; seq: number };
limit: number;
projection: "canonical";
projection: "projected";
},
) => Promise<unknown>;
}
+1
View File
@@ -20,6 +20,7 @@ export type KeyboardActionId =
| "agent.new"
| "workspace.tab.new"
| "workspace.tab.close.current"
| "workspace.find.open"
| "workspace.tab.navigate.index"
| "workspace.tab.navigate.relative"
| "workspace.pane.split.right"
@@ -206,6 +206,18 @@ describe("keyboard-shortcuts", () => {
context: { isMac: true, isDesktop: true },
action: "workspace.tab.close.current",
},
{
name: "matches Cmd+F to open find in the focused pane",
event: { key: "f", code: "KeyF", metaKey: true },
context: { isMac: true, focusScope: "terminal" },
action: "workspace.find.open",
},
{
name: "matches Ctrl+F to open find in the focused pane on non-mac",
event: { key: "f", code: "KeyF", ctrlKey: true },
context: { isMac: false, focusScope: "terminal" },
action: "workspace.find.open",
},
{
name: "matches Ctrl+W to close current tab on non-mac desktop",
event: { key: "w", code: "KeyW", ctrlKey: true },
@@ -540,6 +552,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
"workspace-tab-close-current": ["alt", "shift", "W"],
"workspace-find-open": ["mod", "F"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
@@ -553,6 +566,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"],
"workspace-find-open": ["mod", "F"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
@@ -250,6 +250,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Find in pane ---
{
id: "workspace-find-open-cmd-f-mac",
action: "workspace.find.open",
combo: "Cmd+F",
when: { mac: true, commandCenter: false },
help: {
id: "workspace-find-open",
section: "tabs-panes",
label: "Find in pane",
keys: ["mod", "F"],
},
},
{
id: "workspace-find-open-ctrl-f-non-mac",
action: "workspace.find.open",
combo: "Ctrl+F",
when: { mac: false, commandCenter: false },
help: {
id: "workspace-find-open",
section: "tabs-panes",
label: "Find in pane",
keys: ["mod", "F"],
},
},
// --- Workspace index jump ---
{
id: "workspace-navigate-index-cmd-digit-mac",
+4 -1
View File
@@ -37,7 +37,8 @@ export type ShortcutAction =
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
| { kind: "command-center-toggle"; nextOpen: boolean }
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean }
| { kind: "pane-find-open" };
const NONE: ShortcutAction = { kind: "none" };
@@ -201,6 +202,8 @@ export function routeKeyboardShortcut(
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
case "shortcuts.dialog.toggle":
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
case "workspace.find.open":
return { kind: "pane-find-open" };
default:
return NONE;
}
+1
View File
@@ -6,6 +6,7 @@ import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue {
serverId: string;
workspaceId: string;
paneInstanceId: string | null;
tabId: string;
target: WorkspaceTabTarget;
openTab: (target: WorkspaceTabTarget) => void;
@@ -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 { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
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 { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
@@ -62,7 +62,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
);
const handleOpenProviders = useCallback(() => {
router.push(buildSettingsHostRoute(serverId));
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
}, [router, serverId]);
return (
+382 -87
View File
@@ -1,5 +1,13 @@
import { Fragment, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import {
Alert,
Pressable,
@@ -22,6 +30,9 @@ import {
ChevronDown,
Settings,
Server,
Network,
Workflow,
Boxes,
Keyboard,
Stethoscope,
Info,
@@ -72,6 +83,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { isElectronRuntime } from "@/desktop/host";
@@ -81,7 +93,12 @@ import { resolveAppVersion } from "@/utils/app-version";
import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
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 ProjectSettingsScreen from "@/screens/project-settings-screen";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -90,8 +107,9 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import {
buildHostOpenProjectRoute,
buildProjectsSettingsRoute,
buildSettingsHostRoute,
buildSettingsHostSectionRoute,
buildSettingsSectionRoute,
type HostSectionSlug,
type SettingsSectionSlug,
} from "@/utils/host-routes";
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -103,7 +121,7 @@ import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-st
export type SettingsView =
| { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string }
| { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "projects" }
| { kind: "project"; projectKey: string };
@@ -123,6 +141,19 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ 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)
// ---------------------------------------------------------------------------
@@ -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 {
itemId: SettingsSectionSlug;
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 {
isSelected: boolean;
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;
label: string;
isSelected: 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 handlePress = useCallback(() => {
onSelect(serverId);
}, [onSelect, serverId]);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
const leadingSlot = useMemo(
() => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
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 ? (
// The local host carries a "Local" marker; the active host is conveyed by the
// row's selected check, so both can coexist on one row.
const trailingSlot = useMemo(
() =>
isLocal ? (
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
Local
</Text>
) : null}
</Pressable>
) : undefined,
[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 {
view: SettingsView;
onSelectSection: (section: SettingsSectionSlug) => void;
onSelectHostSection: (section: HostSectionSlug) => void;
onSelectHost: (serverId: string) => void;
onSelectProjects: () => void;
onAddHost: () => void;
onBackToWorkspace: () => void;
activeHostServerId: string | null;
layout: "desktop" | "mobile";
}
function SettingsSidebar({
view,
onSelectSection,
onSelectHostSection,
onSelectHost,
onSelectProjects,
onAddHost,
onBackToWorkspace,
activeHostServerId,
layout,
}: SettingsSidebarProps) {
const { theme } = useUnistyles();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const sortedHosts = 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]);
const sortedHosts = useSortedHosts(hosts, localServerId);
const hasHosts = sortedHosts.length > 0;
const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
const insets = useSafeAreaInsets();
@@ -872,7 +1096,7 @@ function SettingsSidebar({
[insets.top, isDesktop],
);
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 paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
@@ -893,6 +1117,7 @@ function SettingsSidebar({
/>
) : null}
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>App</Text>
{items.map((item) => (
<Fragment key={item.id}>
<SidebarSectionButton
@@ -909,30 +1134,43 @@ function SettingsSidebar({
))}
</View>
<SidebarSeparator />
<View style={sidebarStyles.list}>
{sortedHosts.map((host) => (
<SidebarHostItem
key={host.serverId}
serverId={host.serverId}
label={host.label}
isSelected={selectedServerId === host.serverId}
isLocal={localServerId !== null && host.serverId === localServerId}
onSelect={onSelectHost}
{hasHosts ? (
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>Host</Text>
<HostPicker
activeServerId={activeHostServerId}
sortedHosts={sortedHosts}
localServerId={localServerId}
onSelectHost={onSelectHost}
onAddHost={onAddHost}
/>
))}
<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>
{HOST_SECTION_ITEMS.map((item) => (
<SidebarHostSectionButton
key={item.id}
itemId={item.id}
label={item.label}
icon={item.icon}
isSelected={selectedHostSection === item.id}
onSelect={onSelectHostSection}
/>
))}
</View>
) : (
<View style={sidebarStyles.list}>
<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>
);
}
@@ -967,9 +1205,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[webScrollbarStyle],
);
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId);
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
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(
(nextTheme: AppSettings["theme"]) => {
void updateSettings({ theme: nextTheme });
@@ -1055,7 +1302,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const handleHostAdded = useCallback(
({ serverId }: { serverId: string }) => {
const target = buildSettingsHostRoute(serverId);
const target = buildSettingsHostSectionRoute(serverId, "connections");
if (isCompactLayout) {
router.push(target);
} else {
@@ -1077,16 +1324,34 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[isCompactLayout, router],
);
// Picker: swap the host but keep the section the user is already looking at.
const handleSelectHost = useCallback(
(serverId: string) => {
const target = buildSettingsHostRoute(serverId);
const section: HostSectionSlug = view.kind === "host" ? view.section : "connections";
const target = buildSettingsHostSectionRoute(serverId, section);
if (isCompactLayout) {
router.push(target);
} else {
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(() => {
@@ -1140,13 +1405,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
titleAccessory?: ReactNode;
} | null => {
if (view.kind === "host") {
const host = hosts.find((h) => h.serverId === view.serverId);
if (!host) return null;
return {
title: host.label,
Icon: Server,
titleAccessory: <HostRenameButton host={host} />,
};
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
if (!item) return null;
return { title: item.label, Icon: item.icon };
}
if (view.kind === "section") {
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
@@ -1161,7 +1422,16 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const content = (() => {
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") {
return <ProjectsScreen view={view} />;
@@ -1251,10 +1521,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<SettingsSidebar
view={view}
onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="mobile"
/>
</ScrollView>
@@ -1293,10 +1565,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<SettingsSidebar
view={view}
onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="desktop"
/>
<View style={desktopStyles.contentPane}>
@@ -1448,6 +1722,13 @@ const sidebarStyles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
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: {
flexDirection: "row",
alignItems: "center",
@@ -1472,11 +1753,25 @@ const sidebarStyles = StyleSheet.create((theme) => ({
localMarker: {
fontSize: theme.fontSize.xs,
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],
paddingVertical: 2,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
borderRadius: theme.borderRadius.lg,
},
pickerTriggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
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_HOST_HEADER: SheetHeader = { title: "Remove host" };
export interface HostPageProps {
serverId: string;
onHostRemoved?: () => void;
function useHostProfile(serverId: string): HostProfile | null {
const daemons = useHosts();
return daemons.find((entry) => entry.serverId === serverId) ?? null;
}
export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
const daemons = useHosts();
const host = daemons.find((entry) => entry.serverId === serverId) ?? null;
function HostNotFound() {
return (
<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 snapshot = useHostRuntimeSnapshot(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const daemonVersion = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.version ?? null,
);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
const activeConnection = snapshot?.activeConnection ?? null;
const lastError = snapshot?.lastError ?? null;
const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus);
let statusColor: string;
@@ -117,8 +122,6 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
}
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme);
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const statusPillStyle = useMemo(
() => [styles.statusPill, { backgroundColor: statusPillBg }],
@@ -130,46 +133,125 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
);
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
if (!host) {
return (
<View testID={`settings-host-page-${serverId}`}>
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>Host not found</Text>
</View>
return (
<View style={styles.identityBadges} testID="host-page-identity">
<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>
);
}
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 (
<View testID={`settings-host-page-${serverId}`}>
<View style={styles.identityBadges} testID="host-page-identity">
<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}
<View>
<HostConnectionError serverId={serverId} />
<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} />
</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} />
</View>
@@ -337,7 +419,7 @@ function ConnectionRow({
if (latencyLoading) return "...";
if (latencyError) return "Timeout";
if (latencyMs != null) return formatLatency(latencyMs);
return "\u2014";
return "—";
})();
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) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
@@ -612,7 +677,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<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>
<Button
variant="outline"
@@ -803,6 +868,18 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[1],
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: {
flexDirection: "row",
alignItems: "center",
@@ -113,6 +113,7 @@ describe("WorkspacePaneContent", () => {
isInteractive: false,
focusPane: expect.any(Function),
});
expect(snapshots[0]?.paneContextValue.paneInstanceId).toBeNull();
expect(snapshots[1]?.focus).toEqual({
isWorkspaceFocused: true,
isPaneFocused: true,
@@ -1,4 +1,4 @@
import React, { useMemo, type ComponentType } from "react";
import React, { useEffect, useMemo, type ComponentType } from "react";
import invariant from "tiny-invariant";
import {
createPaneFocusContextValue,
@@ -10,6 +10,11 @@ import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import {
clearActivePaneFindPaneId,
createPaneFindPaneId,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
export interface WorkspacePaneContentModel {
key: string;
@@ -19,6 +24,7 @@ export interface WorkspacePaneContentModel {
export interface BuildWorkspacePaneContentModelInput {
tab: WorkspaceTabDescriptor;
paneId?: string | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
@@ -30,6 +36,7 @@ export interface BuildWorkspacePaneContentModelInput {
export function buildWorkspacePaneContentModel({
tab,
paneId,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab,
@@ -41,12 +48,20 @@ export function buildWorkspacePaneContentModel({
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
const paneInstanceId = paneId
? createPaneFindPaneId({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneId,
})
: null;
return {
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
Component: registration.component,
paneContextValue: {
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneInstanceId,
tabId: tab.tabId,
target: tab.target,
openTab: onOpenTab,
@@ -81,6 +96,16 @@ export function WorkspacePaneContent({
}),
[isPaneFocused, isWorkspaceFocused, onFocusPane],
);
useEffect(() => {
if (!paneContextValue.paneInstanceId || !isWorkspaceFocused || !isPaneFocused) {
return;
}
const paneInstanceId = paneContextValue.paneInstanceId;
setActivePaneFindPaneId(paneInstanceId);
return () => {
clearActivePaneFindPaneId(paneInstanceId);
};
}, [isPaneFocused, isWorkspaceFocused, paneContextValue.paneInstanceId]);
return (
<PaneProvider value={paneContextValue}>
@@ -2465,7 +2465,7 @@ function WorkspaceScreenContent({
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await client.fetchAgentTimeline(agentId, {
direction: "tail",
projection: "canonical",
projection: "projected",
...(currentCursor
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
: {}),
@@ -2827,6 +2827,7 @@ function WorkspaceScreenContent({
}) =>
buildWorkspacePaneContentModel({
tab: input.tab,
paneId: input.paneId,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab: (target) => {
@@ -26,6 +26,16 @@ vi.mock("@xterm/addon-ligatures/lib/addon-ligatures.mjs", () => ({
vi.mock("@xterm/addon-search", () => ({
SearchAddon: class SearchAddon {
findNext(): boolean {
return false;
}
findPrevious(): boolean {
return false;
}
clearDecorations(): void {}
onDidChangeResults(): { dispose: () => void } {
return { dispose: () => {} };
}
dispose(): void {}
},
}));
@@ -91,6 +101,15 @@ interface StubTerminal {
cols?: number;
}
interface StubSearchAddon {
findNext: (term: string, options?: unknown) => boolean;
findPrevious: (term: string, options?: unknown) => boolean;
clearDecorations: () => void;
onDidChangeResults: (listener: (event: { resultIndex: number; resultCount: number }) => void) => {
dispose: () => void;
};
}
function createRuntimeWithTerminal(): {
runtime: TerminalEmulatorRuntime;
terminal: StubTerminal & {
@@ -145,6 +164,55 @@ function decodeTerminalOutput(data: string | Uint8Array): string {
return new TextDecoder().decode(data);
}
function createRuntimeWithSearchAddon(input?: {
findNextResult?: boolean;
findPreviousResult?: boolean;
}): {
runtime: TerminalEmulatorRuntime;
searchAddon: StubSearchAddon;
calls: {
findNext: Array<{ term: string; options: unknown }>;
findPrevious: Array<{ term: string; options: unknown }>;
clearDecorations: number;
subscribedListeners: Array<(event: { resultIndex: number; resultCount: number }) => void>;
disposeSubscription: number;
};
} {
const runtime = new TerminalEmulatorRuntime();
const calls = {
findNext: [] as Array<{ term: string; options: unknown }>,
findPrevious: [] as Array<{ term: string; options: unknown }>,
clearDecorations: 0,
subscribedListeners: [] as Array<(event: { resultIndex: number; resultCount: number }) => void>,
disposeSubscription: 0,
};
const searchAddon: StubSearchAddon = {
findNext: (term, options) => {
calls.findNext.push({ term, options });
return input?.findNextResult ?? true;
},
findPrevious: (term, options) => {
calls.findPrevious.push({ term, options });
return input?.findPreviousResult ?? true;
},
clearDecorations: () => {
calls.clearDecorations += 1;
},
onDidChangeResults: (listener) => {
calls.subscribedListeners.push(listener);
return {
dispose: () => {
calls.disposeSubscription += 1;
},
};
},
};
(runtime as unknown as { searchAddon: StubSearchAddon }).searchAddon = searchAddon;
return { runtime, searchAddon, calls };
}
describe("terminal-emulator-runtime", () => {
const originalWindow = (globalThis as { window?: unknown }).window;
@@ -418,4 +486,71 @@ describe("terminal-emulator-runtime", () => {
expect(fitAndEmitResize).not.toHaveBeenCalled();
});
it("delegates find navigation and clearing to the stored xterm search addon", () => {
const { runtime, calls } = createRuntimeWithSearchAddon({
findNextResult: true,
findPreviousResult: false,
});
expect(runtime.findNext({ query: "needle" })).toBe(true);
expect(runtime.findPrevious({ query: "needle" })).toBe(false);
runtime.clearFindDecorations();
expect(calls.findNext).toEqual([
{
term: "needle",
options: {
caseSensitive: false,
decorations: {
matchBackground: "#facc15",
matchOverviewRuler: "#facc15",
activeMatchBackground: "#38bdf8",
activeMatchColorOverviewRuler: "#38bdf8",
},
},
},
]);
expect(calls.findPrevious).toEqual([
{
term: "needle",
options: {
caseSensitive: false,
decorations: {
matchBackground: "#facc15",
matchOverviewRuler: "#facc15",
activeMatchBackground: "#38bdf8",
activeMatchColorOverviewRuler: "#38bdf8",
},
},
},
]);
expect(calls.clearDecorations).toBe(1);
});
it("forwards xterm search result changes and disposes the subscription", () => {
const { runtime, calls } = createRuntimeWithSearchAddon();
const events: Array<{ resultIndex: number; resultCount: number }> = [];
const unsubscribe = runtime.onFindResultsChanged((event) => {
events.push(event);
});
calls.subscribedListeners[0]?.({ resultIndex: 2, resultCount: 5 });
unsubscribe();
expect(events).toEqual([{ resultIndex: 2, resultCount: 5 }]);
expect(calls.disposeSubscription).toBe(1);
});
it("keeps find methods inert before the search addon is mounted", () => {
const runtime = new TerminalEmulatorRuntime();
const listener = vi.fn();
expect(runtime.findNext({ query: "needle" })).toBe(false);
expect(runtime.findPrevious({ query: "needle" })).toBe(false);
runtime.clearFindDecorations();
runtime.onFindResultsChanged(listener)();
expect(listener).not.toHaveBeenCalled();
});
});
@@ -2,11 +2,12 @@ import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search";
import type { ISearchOptions, ISearchResultChangeEvent } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme } from "@xterm/xterm";
import { Terminal, type IDisposable, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
type TerminalInputModeState,
@@ -61,6 +62,12 @@ export interface TerminalEmulatorRuntimeCallbacks {
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
}
export type TerminalFindResultChangeEvent = ISearchResultChangeEvent;
export interface TerminalFindQueryInput {
query: string;
}
interface TerminalEmulatorRuntimeDisposables {
disposeInput: () => void;
disconnectResizeObserver: () => void;
@@ -128,6 +135,16 @@ function prependTerminalOutput(
return output;
}
const TERMINAL_FIND_SEARCH_OPTIONS: ISearchOptions = {
caseSensitive: false,
decorations: {
matchBackground: "#facc15",
matchOverviewRuler: "#facc15",
activeMatchBackground: "#38bdf8",
activeMatchColorOverviewRuler: "#38bdf8",
},
};
const DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
"JetBrains Mono",
@@ -164,6 +181,7 @@ export class TerminalEmulatorRuntime {
};
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private searchAddon: SearchAddon | null = null;
private fitAndEmitResize: ((force: boolean) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
@@ -223,6 +241,7 @@ export class TerminalEmulatorRuntime {
theme: withOverviewRulerBorderHidden(input.theme),
});
const fitAddon = new FitAddon();
const searchAddon = new SearchAddon({ highlightLimit: 20_000 });
const unicode11Addon = new Unicode11Addon();
let webglAddon: WebglAddon | null = null;
let imageAddon: ImageAddon | null = null;
@@ -245,7 +264,7 @@ export class TerminalEmulatorRuntime {
},
}),
);
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(searchAddon);
terminal.loadAddon(new ClipboardAddon());
try {
terminal.loadAddon(new LigaturesAddon());
@@ -334,6 +353,7 @@ export class TerminalEmulatorRuntime {
this.terminal = terminal;
this.fitAddon = fitAddon;
this.searchAddon = searchAddon;
window.__paseoTerminal = terminal;
const fitAndEmitResize = (force: boolean): void => {
@@ -687,6 +707,25 @@ export class TerminalEmulatorRuntime {
this.terminal?.blur();
}
findNext(input: TerminalFindQueryInput): boolean {
return this.searchAddon?.findNext(input.query, TERMINAL_FIND_SEARCH_OPTIONS) ?? false;
}
findPrevious(input: TerminalFindQueryInput): boolean {
return this.searchAddon?.findPrevious(input.query, TERMINAL_FIND_SEARCH_OPTIONS) ?? false;
}
clearFindDecorations(): void {
this.searchAddon?.clearDecorations();
}
onFindResultsChanged(listener: (event: TerminalFindResultChangeEvent) => void): () => void {
const disposable: IDisposable | undefined = this.searchAddon?.onDidChangeResults(listener);
return () => {
disposable?.dispose();
};
}
private refreshVisibleRows(): void {
const terminal = this.terminal;
if (!terminal || terminal.rows <= 0) {
@@ -739,6 +778,7 @@ export class TerminalEmulatorRuntime {
}
this.terminal = null;
this.fitAddon = null;
this.searchAddon = null;
this.fitAndEmitResize = null;
this.lastSize = null;
this.themeBackgroundElements = [];
@@ -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", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
@@ -53,9 +53,16 @@ type SessionTimelineSeqCursor =
type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
interface TimelineSeqRange {
startSeq: number;
endSeq: number;
}
interface TimelineResponseEntry {
seqStart: number;
seqEnd: number;
sourceSeqRanges?: TimelineSeqRange[];
collapsed?: string[];
provider: string;
item: Record<string, unknown>;
timestamp: string;
@@ -96,6 +103,7 @@ export interface ProcessTimelineResponseOutput {
interface TimelineUnit {
seq: number;
seqEnd: number;
sourceSeqRanges: TimelineSeqRange[];
event: AgentStreamEventPayload;
timestamp: Date;
}
@@ -376,44 +384,56 @@ function acceptIncrementalTimelineUnits(args: {
currentCursor: TimelineCursor | undefined;
}): IncrementalAcceptResult {
const { timelineUnits, payload, currentCursor } = args;
const acceptedUnits: TimelineUnit[] = [];
let cursor: TimelineCursor | undefined = currentCursor;
let gapCursor: { epoch: string; endSeq: number } | null = null;
const firstUnit = timelineUnits[0];
const lastUnit = timelineUnits[timelineUnits.length - 1];
const responseStartSeq = payload.startCursor?.seq ?? firstUnit?.seq;
const responseEndSeq = payload.endCursor?.seq ?? lastUnit?.seqEnd;
for (const unit of timelineUnits) {
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
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 };
if (responseStartSeq === undefined || responseEndSeq === undefined) {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
}
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: {
@@ -426,16 +446,21 @@ function acceptOlderTimelineUnits(args: {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
}
const acceptedUnits = timelineUnits.filter((unit) => unit.seqEnd < currentCursor.startSeq);
if (acceptedUnits.length === 0) {
return { acceptedUnits, cursor: currentCursor, gapCursor: null };
const firstUnit = timelineUnits[0];
const lastUnit = timelineUnits[timelineUnits.length - 1];
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 {
acceptedUnits,
cursor: { ...currentCursor, startSeq },
acceptedUnits: timelineUnits,
cursor: { ...currentCursor, startSeq: responseStartSeq },
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: {
timelineUnits: TimelineUnit[];
payload: ProcessTimelineResponseInput["payload"];
@@ -523,6 +574,15 @@ function applyTimelineIncrementalPath(args: {
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
} else if (currentHead.length > 0) {
for (const { event, timestamp } of acceptedUnits) {
const replacedHead = replaceLiveAssistantWithProjectedText({
head: nextHead,
event,
timestamp,
});
if (replacedHead) {
nextHead = replacedHead;
continue;
}
const applied = applyStreamEvent({
tail: nextTail,
head: nextHead,
@@ -597,6 +657,10 @@ export function processTimelineResponse(
const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seqStart,
seqEnd: entry.seqEnd,
sourceSeqRanges:
entry.sourceSeqRanges && entry.sourceSeqRanges.length > 0
? entry.sourceSeqRanges
: [{ startSeq: entry.seqStart, endSeq: entry.seqEnd }],
event: {
type: "timeline",
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;
@@ -18,7 +18,7 @@ describe("timeline sync planning", () => {
expect(plan).toEqual({
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
@@ -32,7 +32,7 @@ describe("timeline sync planning", () => {
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
@@ -45,7 +45,7 @@ describe("timeline sync planning", () => {
direction: "after",
cursor: { epoch: "epoch-1", seq: 100 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
@@ -55,7 +55,7 @@ describe("timeline sync planning", () => {
expect(plan).toEqual({
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
@@ -66,7 +66,7 @@ describe("timeline sync planning", () => {
direction: "before",
cursor: { epoch: "epoch-1", seq: 25 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
@@ -82,7 +82,7 @@ describe("timeline sync planning", () => {
direction: "after",
cursor: { epoch: "epoch-1", seq: 200 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
+19 -19
View File
@@ -11,39 +11,39 @@ export interface AgentTimelineCursorRange {
endSeq: number;
}
export interface CanonicalTimelineTailFetchPlan {
export interface ProjectedTimelineTailFetchPlan {
direction: "tail";
limit: number;
projection: "canonical";
projection: "projected";
}
export interface CanonicalTimelineAfterFetchPlan {
export interface ProjectedTimelineAfterFetchPlan {
direction: "after";
cursor: TimelineSyncCursor;
limit: number;
projection: "canonical";
projection: "projected";
}
export interface CanonicalTimelineBeforeFetchPlan {
export interface ProjectedTimelineBeforeFetchPlan {
direction: "before";
cursor: TimelineSyncCursor;
limit: number;
projection: "canonical";
projection: "projected";
}
export type CanonicalTimelineFetchPlan =
| CanonicalTimelineTailFetchPlan
| CanonicalTimelineAfterFetchPlan
| CanonicalTimelineBeforeFetchPlan;
export type ProjectedTimelineFetchPlan =
| ProjectedTimelineTailFetchPlan
| ProjectedTimelineAfterFetchPlan
| ProjectedTimelineBeforeFetchPlan;
export type CanonicalTimelineForwardFetchPlan =
| CanonicalTimelineTailFetchPlan
| CanonicalTimelineAfterFetchPlan;
export type ProjectedTimelineForwardFetchPlan =
| ProjectedTimelineTailFetchPlan
| ProjectedTimelineAfterFetchPlan;
export function planInitialAgentTimelineSync(input: {
cursor: AgentTimelineCursorRange | undefined;
hasAuthoritativeHistory: boolean;
}): CanonicalTimelineForwardFetchPlan {
}): ProjectedTimelineForwardFetchPlan {
if (input.hasAuthoritativeHistory && input.cursor) {
return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq });
}
@@ -53,7 +53,7 @@ export function planInitialAgentTimelineSync(input: {
export function planResumeTimelineSync(input: {
cursor: AgentTimelineCursorRange | undefined;
}): CanonicalTimelineForwardFetchPlan {
}): ProjectedTimelineForwardFetchPlan {
if (input.cursor) {
return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq });
}
@@ -66,7 +66,7 @@ export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) {
direction: "after",
cursor,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
} as const;
}
@@ -74,7 +74,7 @@ export function planTimelineTailFetch() {
return {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
} as const;
}
@@ -83,7 +83,7 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
direction: "before",
cursor,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
} as const;
}
@@ -92,7 +92,7 @@ export function planTimelineCatchUpFollowUp(input: {
hasNewer: boolean;
endCursor: TimelineSyncCursor | null;
error: string | null;
}): CanonicalTimelineAfterFetchPlan | null {
}): ProjectedTimelineAfterFetchPlan | null {
if (input.error || input.direction !== "after" || !input.hasNewer || !input.endCursor) {
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 {
return state.find(
(item): item is AgentToolCallItem =>
@@ -706,26 +693,6 @@ describe("stream reducer canonical tool calls", () => {
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", () => {
const state = hydrateStreamState([
{
+1 -46
View File
@@ -1,8 +1,4 @@
import type {
AgentPlanAction,
AgentProvider,
ToolCallDetail,
} from "@getpaseo/protocol/agent-types";
import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { AttachmentMetadata } from "@/attachments/types";
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
@@ -52,7 +48,6 @@ export type StreamItem =
| AssistantMessageItem
| ThoughtItem
| ToolCallItem
| PlanItem
| TodoListItem
| ActivityLogItem
| CompactionItem;
@@ -173,16 +168,6 @@ export interface TodoListItem {
items: TodoEntry[];
}
export interface PlanItem {
kind: "plan";
id: string;
timestamp: Date;
provider: AgentProvider;
planId: string;
text: string;
actions?: AgentPlanAction[];
}
export type StreamUpdateSource = "live" | "canonical";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -668,34 +653,6 @@ function appendTodoList(
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(
state: StreamItem[],
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
@@ -817,8 +774,6 @@ function reduceTimelineEvent(
}));
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
}
case "plan":
return finalizeActiveThoughts(appendPlan(state, event.provider, item, timestamp));
case "error": {
const activity: ActivityLogItem = {
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);
}
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() {
return "/settings" as const;
}
@@ -406,6 +414,14 @@ export function buildSettingsHostRoute(serverId: string) {
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() {
return "/settings/projects" as const;
}
@@ -255,7 +255,6 @@ test("advertises client capabilities in hello", async () => {
protocolVersion: 1,
capabilities: {
custom_mode_icons: true,
first_class_plans: true,
reasoning_merge_enum: true,
},
});
-38
View File
@@ -84,7 +84,6 @@ import type {
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentPlanResponse,
AgentPersistenceHandle,
AgentProvider,
AgentSessionConfig,
@@ -364,10 +363,6 @@ type DictationFinishAcceptedPayload = Extract<
{ type: "dictation_stream_finish_accepted" }
>["payload"];
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
type AgentPlanRespondPayload = Extract<
SessionOutboundMessage,
{ type: "agent.plan.respond.response" }
>["payload"];
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["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
// ============================================================================
@@ -4315,7 +4278,6 @@ export class DaemonClient {
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.customModeIcons]: true,
[CLIENT_CAPS.firstClassPlans]: true,
[CLIENT_CAPS.reasoningMergeEnum]: true,
},
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
@@ -1,16 +1,71 @@
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 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;
export function listRegisteredPaseoBrowserIds(): string[] {
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);
webContentsIdsByBrowserId.set(browserId, contents.id);
if (!ownerContents.isDestroyed()) {
ownerWebContentsIdsByBrowserId.set(browserId, ownerContents.id);
ensureOwnerFoundInPageListener(ownerContents);
}
contents.once("destroyed", () => {
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) {
workspaceActiveBrowserId = null;
}
@@ -29,14 +84,35 @@ export function setWorkspaceActivePaseoBrowserId(browserId: string | null): void
}
export function getPaseoBrowserWebContents(browserId: string): WebContents | null {
for (const [contentsId, registeredBrowserId] of browserIdsByWebContentsId) {
if (registeredBrowserId !== browserId) continue;
const contents = allWebContents.fromId(contentsId);
if (contents && !contents.isDestroyed()) {
return contents;
}
const contentsId = webContentsIdsByBrowserId.get(browserId);
if (!contentsId) {
return null;
}
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 {
+45 -1
View File
@@ -33,10 +33,12 @@ import {
import { registerOpenerHandlers } from "./features/opener.js";
import { setupApplicationMenu } from "./features/menu.js";
import {
clearActivePaseoBrowserFind,
getPaseoBrowserIdForWebContents,
getPaseoBrowserWebContents,
listRegisteredPaseoBrowserIds,
registerPaseoBrowserWebContents,
setActivePaseoBrowserFind,
setWorkspaceActivePaseoBrowserId,
} from "./features/browser-webviews.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();
});
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([
{ 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) => {
const browserId = pendingBrowserWebviewIds.shift() ?? null;
if (browserId) {
registerPaseoBrowserWebContents(contents, browserId);
registerPaseoBrowserWebContents(contents, browserId, mainWindow.webContents);
log.info("[browser-webview] registered", {
browserId,
webContentsId: contents.id,
+34
View File
@@ -1,6 +1,14 @@
import { contextBridge, ipcRenderer, webUtils } from "electron";
type EventHandler = (payload: unknown) => void;
type BrowserFindAction = "clearSelection" | "keepSelection" | "activateSelection";
interface BrowserFoundInPageResult {
requestId: number;
activeMatchOrdinal: number;
matches: number;
finalUpdate: boolean;
}
contextBridge.exposeInMainWorld("paseoDesktop", {
platform: process.platform,
@@ -69,5 +77,31 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
ipcRenderer.invoke("paseo:browser:open-devtools", browserId),
clearPartition: (browserId: string) =>
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;
}
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 =
| { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
| { type: "todo"; items: { text: string; completed: boolean }[] }
| PlanTimelineItem
| { type: "error"; message: string }
| CompactionTimelineItem;
@@ -1,5 +1,4 @@
export const CLIENT_CAPS = {
firstClassPlans: "first_class_plans",
reasoningMergeEnum: "reasoning_merge_enum",
// COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to
// a closed enum and crash rendering unknown values; daemon downgrades icons
@@ -179,13 +179,11 @@ describe("checkout PR schemas", () => {
features: {
providersSnapshot: true,
checkoutGithubSetAutoMerge: true,
firstClassPlans: true,
},
}).features,
).toEqual({
providersSnapshot: true,
checkoutGithubSetAutoMerge: true,
firstClassPlans: true,
});
});
});
-38
View File
@@ -153,7 +153,6 @@ import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPlanAction,
ProviderStatus,
AgentRuntimeInfo,
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<
AgentPermissionRequest,
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({
type: z.literal("error"),
message: z.string(),
@@ -1337,15 +1324,6 @@ export const AgentPermissionResponseMessageSchema = z.object({
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([
"NOT_GIT_REPO",
"NOT_ALLOWED",
@@ -1923,7 +1901,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentFeatureRequestMessageSchema,
AgentRewindRequestMessageSchema,
AgentPermissionResponseMessageSchema,
AgentPlanRespondRequestMessageSchema,
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema,
@@ -2161,8 +2138,6 @@ export const ServerInfoStatusPayloadSchema = z
.object({
providersSnapshot: 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.
daemonStatusRpc: z.boolean().optional(),
// 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({
type: z.literal("wait_for_finish_response"),
payload: z.object({
@@ -3729,7 +3693,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema,
SendAgentMessageResponseMessageSchema,
AgentPlanRespondResponseMessageSchema,
SetVoiceModeResponseMessageSchema,
DaemonGetStatusResponseSchema,
DaemonGetPairingOfferResponseSchema,
@@ -4143,7 +4106,6 @@ export const WSHelloMessageSchema = z.object({
.object({
voice: z.boolean().optional(),
pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.firstClassPlans]: z.boolean().optional(),
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
})
@@ -19,8 +19,6 @@ import {
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
type AgentPlanResponse,
type AgentPlanResult,
type AgentPermissionRequest,
type AgentPermissionResponse,
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> {
const agent = this.requireSessionAgent(agentId);
const pendingRun = this.foregroundRuns.getPendingRun(agentId);
@@ -342,27 +342,12 @@ export interface CompactionTimelineItem {
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 =
| { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
| { type: "todo"; items: { text: string; completed: boolean }[] }
| PlanTimelineItem
| { type: "error"; message: string }
| CompactionTimelineItem;
@@ -566,15 +551,6 @@ export interface AgentPermissionResult {
followUpPrompt?: AgentPromptInput;
}
export interface AgentPlanResponse {
actionId: string;
feedback?: string;
}
export interface AgentPlanResult {
followUpPrompt?: AgentPromptInput;
}
export interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
@@ -593,7 +569,6 @@ export interface AgentSession {
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
respondToPlan?(planId: string, response: AgentPlanResponse): Promise<AgentPlanResult | void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): 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),
getPendingPermissions: () => inner.getPendingPermissions(),
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
respondToPlan: inner.respondToPlan?.bind(inner),
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(),
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 })));
sdkQueryFactory.mockImplementation(() => queryMock);
@@ -1023,36 +1023,44 @@ test("plan item exposes a resume-bypass action and can return to bypassPermissio
{},
);
const planEvent = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "plan",
const requestEvent = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(planEvent).toBeDefined();
expect(planEvent?.item.type === "plan" ? planEvent.item.actions : undefined).toEqual([
expect(requestEvent).toBeDefined();
expect(requestEvent?.request.actions).toEqual([
{
id: "reject",
label: "Reject",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
{
id: "implement_resume",
label: "Implement with Bypass",
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
},
]);
if (!planEvent || planEvent.item.type !== "plan") {
throw new Error("Expected plan item");
if (!requestEvent) {
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({
behavior: "allow",
@@ -57,8 +57,6 @@ import {
type AgentMetadata,
type AgentMode,
type AgentModelDefinition,
type AgentPlanAction,
type AgentPlanResponse,
type AgentPermissionRequest,
type AgentPermissionRequestKind,
type AgentPermissionResponse,
@@ -899,14 +897,6 @@ function buildClaudePlanPermissionActions(
return actions;
}
function buildClaudePlanActions(resumeMode: PermissionMode | null): AgentPlanAction[] {
return buildClaudePlanPermissionActions(resumeMode).map(({ id, label, variant }) => ({
id,
label,
variant,
}));
}
interface TimelineFragment {
kind: "assistant" | "reasoning";
text: string;
@@ -1594,7 +1584,6 @@ class ClaudeAgentSession implements AgentSession {
private toolUseIndexToId = new Map<number, string>();
private toolUseInputBuffers = new Map<string, string>();
private pendingPermissions = new Map<string, PendingPermission>();
private pendingPlans = new Map<string, string>();
private activeForegroundTurnId: string | null = null;
private autonomousTurn: AutonomousTurnState | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
@@ -1930,140 +1919,82 @@ class ClaudeAgentSession implements AgentSession {
}
getPendingPermissions(): AgentPermissionRequest[] {
const hiddenPlanRequestIds = new Set(this.pendingPlans.values());
return Array.from(this.pendingPermissions.values())
.filter((entry) => !hiddenPlanRequestIds.has(entry.request.id))
.map((entry) => entry.request);
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
}
private clearPendingPlanForPermission(requestId: string): 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> {
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
const pending = this.pendingPermissions.get(requestId);
if (!pending) {
throw new Error(`No pending permission request with id '${requestId}'`);
}
this.pendingPermissions.delete(requestId);
this.clearPendingPlanForPermission(requestId);
pending.cleanup?.();
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 {
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({
type: "permission_resolved",
provider: "claude",
requestId,
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),
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId,
resolution: response,
});
}
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 {
if (this.persistence) {
return this.persistence;
@@ -3843,26 +3774,11 @@ class ClaudeAgentSession implements AgentSession {
metadata: Object.keys(metadata).length ? metadata : undefined,
};
if (kind === "plan" && typeof input.plan === "string") {
const planId = `plan-${randomUUID()}`;
this.pendingPlans.set(planId, requestId);
this.pushEvent({
type: "timeline",
provider: "claude",
item: {
type: "plan",
planId,
text: input.plan,
actions: buildClaudePlanActions(this.planResumeMode),
},
});
} else {
this.pushEvent({
type: "permission_requested",
provider: "claude",
request,
});
}
this.pushEvent({
type: "permission_requested",
provider: "claude",
request,
});
return await new Promise<PermissionResult>((resolve, reject) => {
const cleanupFns: Array<() => void> = [];
@@ -3879,11 +3795,6 @@ class ClaudeAgentSession implements AgentSession {
const abortHandler = () => {
this.pendingPermissions.delete(requestId);
for (const [planId, pendingRequestId] of this.pendingPlans) {
if (pendingRequestId === requestId) {
this.pendingPlans.delete(planId);
}
}
cleanup();
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({
featureValues: { plan_mode: true, fast_mode: true },
});
@@ -2018,20 +2018,27 @@ describe("Codex app-server provider", () => {
),
).toBe(false);
expect(events.at(-2)).toEqual({
type: "timeline",
type: "permission_requested",
provider: "codex",
turnId: "test-turn",
item: expect.objectContaining({
type: "plan",
text: "- Inspect the existing auth flow\n- Implement the button behavior",
request: expect.objectContaining({
provider: "codex",
name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
input: {
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
},
actions: [
expect.objectContaining({
id: "reject",
label: "Reject",
behavior: "deny",
}),
expect.objectContaining({
id: "implement",
label: "Implement",
behavior: "allow",
}),
],
}),
@@ -2075,12 +2082,16 @@ describe("Codex app-server provider", () => {
}),
);
expect(events.at(-2)).toEqual({
type: "timeline",
type: "permission_requested",
provider: "codex",
turnId: "test-turn",
item: expect.objectContaining({
type: "plan",
text: "- Inspect README\n- Add a short note",
request: expect.objectContaining({
provider: "codex",
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({
featureValues: { plan_mode: true, fast_mode: true },
});
@@ -2475,16 +2486,19 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null },
});
const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "plan",
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(plan).toBeDefined();
if (!plan || plan.item.type !== "plan") {
throw new Error("Expected plan item");
expect(request).toBeDefined();
if (!request) {
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).planModeEnabled).toBe(false);
@@ -2498,10 +2512,18 @@ describe("Codex app-server provider", () => {
expect(result!.followUpPrompt).toEqual(
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({
featureValues: { plan_mode: true, fast_mode: false },
});
@@ -2518,16 +2540,19 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null },
});
const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "plan",
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(plan).toBeDefined();
if (!plan || plan.item.type !== "plan") {
throw new Error("Expected plan item");
expect(request).toBeDefined();
if (!request) {
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).planModeEnabled).toBe(false);
@@ -2583,16 +2608,19 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null },
});
const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "plan",
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(plan).toBeDefined();
if (!plan || plan.item.type !== "plan") {
throw new Error("Expected plan item");
expect(permissionRequest).toBeDefined();
if (!permissionRequest) {
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));
await session.startTurn(result!.followUpPrompt!);
@@ -8,9 +8,6 @@ import {
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
type AgentPlanAction,
type AgentPlanResponse,
type AgentPlanResult,
type McpServerConfig,
type AgentPersistenceHandle,
type AgentPermissionRequest,
@@ -44,7 +41,6 @@ import os from "node:os";
import path from "node:path";
import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { planItemFromToolCall } from "../plan-files.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js";
import {
@@ -945,10 +941,6 @@ function buildPlanPermissionActions(options?: {
return actions;
}
function buildPlanActions(): AgentPlanAction[] {
return buildPlanPermissionActions().map(({ id, label, variant }) => ({ id, label, variant }));
}
function buildCodexPlanImplementationPrompt(planText: string): string {
const normalizedPlan = normalizePlanMarkdown(planText);
if (!normalizedPlan) {
@@ -2933,7 +2925,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
private readonly userMessageTurnIndexes = new Map<string, number>();
private readonly userMessageTurnIds: string[] = [];
private pendingPlans = new Map<string, { text: string }>();
private pendingManualCompactionStarts = 0;
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
// Codex can report one completed compaction through both channels:
@@ -3196,36 +3187,30 @@ export class CodexAppServerAgentSession implements AgentSession {
};
}
private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem): void {
void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() })
.then((planItem) => {
if (planItem) {
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",
private emitSyntheticPlanApprovalRequest(planText: string): void {
const requestId = `permission-${randomUUID()}`;
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
item: {
type: "plan",
planId,
text,
actions: buildPlanActions(),
name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
description: "Review the proposed plan before implementation starts.",
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: {} });
}
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: {
requestId: string;
response: AgentPermissionResponse;
@@ -4607,7 +4569,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else {
if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitPlanApprovalItem(this.latestPlanResult.text);
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
}
this.emitEvent({
type: "turn_completed",
@@ -4921,9 +4883,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem.type === "tool_call") {
this.emitPlanFileItemFromToolCall(timelineItem);
}
if (timelineItem.type === "assistant_message") {
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 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();
if (!planItem || planItem.type !== "plan") {
throw new Error("Expected a normalized plan item");
expect(planCall).toBeDefined();
if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") {
throw new Error("Expected a plan tool call");
}
expect(planItem.text).toContain("Login");
expect(planItem.text).toContain("- ");
expect(planItem.actions?.some((action) => action.id === "implement")).toBe(true);
expect(planCall.detail.text).toContain("Login");
expect(planCall.detail.text).toContain("- ");
expect(result.finalText).toBe(planCall.detail.text);
} finally {
await session.close();
}
@@ -57,7 +57,6 @@ import {
resolveProviderLaunch,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { isPlanFilePath, planItemFromToolCall } from "../plan-files.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
@@ -2155,22 +2154,6 @@ function appendOpenCodeToolCallTimelineItem(
provider: "opencode",
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) {
flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events);
}
@@ -3230,28 +3213,9 @@ class OpenCodeAgentSession implements AgentSession {
return;
}
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(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string,
@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import type { AgentTimelineRow } from "./agent-manager.js";
import {
projectTimelineRows,
selectProjectedTimelinePage,
selectTimelineWindowByProjectedLimit,
} 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[] = [
{
seq: 1,
timestamp: "2026-02-13T00:00:00.000Z",
...Array.from({ length: 6 }, (_, index) => ({
seq: index + 1,
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: {
type: "tool_call",
type: "tool_call" as const,
callId: "call_1",
name: "shell",
status: "running",
status: "running" as const,
error: null,
detail: {
type: "unknown",
input: { cmd: "pwd" },
output: null,
type: "unknown" as const,
input: { cmd: "sleep 10" },
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({
rows,
direction: "tail",
limit: 1,
collapseToolLifecycle: false,
limit: 100,
});
expect(selected.minSeq).toBe(3);
expect(selected.maxSeq).toBe(3);
expect(selected.selectedRows.map((row) => row.seq)).toEqual([3]);
expect(selected.projectedEntries).toHaveLength(1);
expect(selected.projectedEntries[0]?.item.type).toBe("tool_call");
const tools = selected.projectedEntries.filter((entry) => entry.item.type === "tool_call");
expect(tools).toHaveLength(1);
expect(tools[0]?.collapsed).toContain("tool_lifecycle");
expect(selected.projectedEntries).toHaveLength(2);
});
});
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;
}
export interface ProjectedTimelinePageSelection {
entries: TimelineProjectionEntry[];
startSeq: number | null;
endSeq: number | null;
hasOlder: boolean;
hasNewer: boolean;
}
function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeqRange[] {
if (ranges.length === 0) {
return [{ startSeq: seq, endSeq: seq }];
@@ -266,15 +274,11 @@ export function selectTimelineWindowByProjectedLimit(input: {
rows: readonly AgentTimelineRow[];
direction: TimelineLimitDirection;
limit: number;
collapseToolLifecycle?: boolean;
}): ProjectedWindowSelection {
const { rows, direction } = input;
const limit = Math.max(0, Math.floor(input.limit));
const collapseTools = input.collapseToolLifecycle ?? true;
const canonical = makeCanonicalEntries(rows);
const projectedAll = mergeReasoningChunks(
mergeAssistantChunks(collapseTools ? collapseToolLifecycle(canonical) : canonical),
);
const projectedAll = mergeReasoningChunks(mergeAssistantChunks(collapseToolLifecycle(canonical)));
if (projectedAll.length === 0) {
return {
@@ -320,27 +324,25 @@ export function selectTimelineWindowByProjectedLimit(input: {
let { minSeq, maxSeq } = computeWindowBounds(projectedEntries);
let expandedEntries = projectedEntries;
if (collapseTools) {
// Expand to include any projected entries that overlap the selected
// canonical range. Tool lifecycle collapse can produce non-monotonic
// seqEnd values, which would otherwise create cursor gaps.
for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) {
const overlapping = projectedAll.filter(
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq,
);
const nextBounds = computeWindowBounds(overlapping);
if (
overlapping.length === expandedEntries.length &&
nextBounds.minSeq === minSeq &&
nextBounds.maxSeq === maxSeq
) {
expandedEntries = overlapping;
break;
}
// Expand to include any projected entries that overlap the selected canonical
// range. Tool lifecycle collapse can produce non-monotonic seqEnd values,
// which would otherwise create cursor gaps.
for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) {
const overlapping = projectedAll.filter(
(entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq,
);
const nextBounds = computeWindowBounds(overlapping);
if (
overlapping.length === expandedEntries.length &&
nextBounds.minSeq === minSeq &&
nextBounds.maxSeq === maxSeq
) {
expandedEntries = overlapping;
minSeq = nextBounds.minSeq;
maxSeq = nextBounds.maxSeq;
break;
}
expandedEntries = overlapping;
minSeq = nextBounds.minSeq;
maxSeq = nextBounds.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.
* 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 { tmpdir } from "node:os";
import path from "node:path";
@@ -17,6 +17,7 @@ describe("daemon E2E - timeline window", () => {
});
afterEach(async () => {
vi.restoreAllMocks();
await ctx.cleanup();
}, 60_000);
@@ -66,6 +67,10 @@ describe("daemon E2E - timeline window", () => {
expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle");
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}`);
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 });
}
}, 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");
});
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", () => {
const event: AgentStreamEvent = {
type: "timeline",
+50 -208
View File
@@ -68,7 +68,6 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js";
import {
formatSystemNotificationPrompt,
sendPromptToAgent,
startAgentRun,
waitForAgentRunStartWithTimeout,
unarchiveAgentState,
} from "./agent/agent-prompt.js";
@@ -100,6 +99,7 @@ import type {
AgentManagerEvent,
AgentTimelineCursor,
AgentTimelineFetchDirection,
AgentTimelineFetchResult,
ManagedAgent,
} from "./agent/agent-manager.js";
import { createAgentCommand } from "./agent/create-agent/create.js";
@@ -121,8 +121,7 @@ import {
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import {
projectTimelineRows,
selectTimelineWindowByProjectedLimit,
selectProjectedTimelinePage,
type TimelineProjectionMode,
} from "./agent/timeline-projection.js";
import {
@@ -143,8 +142,6 @@ import {
type AgentPromptInput,
type AgentRunOptions,
type AgentSessionConfig,
type AgentStreamEvent,
type AgentTimelineItem,
type ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
@@ -719,45 +716,6 @@ function parseClientCapabilities(
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.
* It owns all state management, orchestration logic, and message processing.
@@ -1377,11 +1335,7 @@ export class Session {
});
}
const projectedEvent = projectAgentStreamEventForClient(
event.event,
this.clientCapabilities,
);
const serializedEvent = serializeAgentStreamEvent(projectedEvent);
const serializedEvent = serializeAgentStreamEvent(event.event);
if (!serializedEvent) {
return;
}
@@ -1786,7 +1740,6 @@ export class Session {
const promise =
this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentPlanMessage(msg) ??
this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
@@ -1798,13 +1751,6 @@ export class Session {
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 {
switch (msg.type) {
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(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>,
): Promise<void> {
@@ -7510,84 +7415,33 @@ export class Session {
});
}
private loadProjectedTimelineWindow(params: {
agentId: string;
direction: AgentTimelineFetchDirection;
cursor: AgentTimelineCursor | undefined;
requestedLimit: number;
timeline: ReturnType<AgentManager["fetchTimeline"]>;
}): {
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,
});
private shouldUseFullTimelineForProjectedPage(input: {
timeline: AgentTimelineFetchResult;
}): boolean {
const { timeline } = input;
if (timeline.reset || timeline.rows.length === 0 || !timeline.hasOlder) {
return false;
}
return {
timeline,
selectedRows: projectedWindow.selectedRows,
minSeq: projectedWindow.minSeq,
maxSeq: projectedWindow.maxSeq,
};
const firstRow = timeline.rows[0];
if (
firstRow?.item.type === "assistant_message" ||
firstRow?.item.type === "reasoning" ||
firstRow?.item.type === "tool_call"
) {
return true;
}
return timeline.rows.some((row) => row.item.type === "tool_call");
}
private async handleFetchAgentTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection: TimelineProjectionMode = msg.projection ?? "projected";
const projection: TimelineProjectionMode = "projected";
const requestedLimit = msg.limit;
const limit = requestedLimit ?? (direction === "after" ? 0 : undefined);
const shouldLimitByProjectedWindow =
projection === "canonical" &&
direction === "tail" &&
typeof requestedLimit === "number" &&
requestedLimit > 0;
const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200);
const cursor: AgentTimelineCursor | undefined = msg.cursor
? {
epoch: msg.cursor.epoch,
@@ -7603,43 +7457,29 @@ export class Session {
});
const agentPayload = await this.buildAgentPayload(snapshot);
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
const controlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit:
shouldLimitByProjectedWindow && typeof requestedLimit === "number"
? Math.max(1, Math.floor(requestedLimit))
: limit,
limit: pageLimit,
});
let hasOlder = timeline.hasOlder;
let hasNewer = timeline.hasNewer;
let startCursor: { epoch: string; seq: number } | null = null;
let endCursor: { epoch: string; seq: number } | null = null;
let entries: ReturnType<typeof projectTimelineRows>;
if (shouldLimitByProjectedWindow) {
const projectedResult = this.loadProjectedTimelineWindow({
agentId: msg.agentId,
direction,
cursor,
requestedLimit,
timeline,
});
timeline = projectedResult.timeline;
entries = projectTimelineRows({ rows: projectedResult.selectedRows, mode: projection });
if (projectedResult.minSeq !== null && projectedResult.maxSeq !== null) {
startCursor = { epoch: timeline.epoch, seq: projectedResult.minSeq };
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 });
}
const timeline = this.shouldUseFullTimelineForProjectedPage({
timeline: controlTimeline,
})
? this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0 })
: controlTimeline;
const projectedPage = selectProjectedTimelinePage({
rows: timeline.rows,
bounds: timeline.window,
direction: controlTimeline.reset ? "tail" : direction,
...(cursor ? { cursorSeq: cursor.seq } : {}),
limit: pageLimit,
});
const startCursor =
projectedPage.startSeq !== null
? { epoch: timeline.epoch, seq: projectedPage.startSeq }
: null;
const endCursor =
projectedPage.endSeq !== null ? { epoch: timeline.epoch, seq: projectedPage.endSeq } : null;
this.emit({
type: "fetch_agent_timeline_response",
@@ -7650,17 +7490,19 @@ export class Session {
direction,
projection,
epoch: timeline.epoch,
reset: timeline.reset,
staleCursor: timeline.staleCursor,
gap: timeline.gap,
reset: controlTimeline.reset,
staleCursor: controlTimeline.staleCursor,
gap: controlTimeline.gap,
window: timeline.window,
startCursor,
endCursor,
hasOlder,
hasNewer,
entries: entries.map((entry) => ({
hasOlder:
projectedPage.hasOlder ||
(projectedPage.startSeq !== null && projectedPage.startSeq > timeline.window.minSeq),
hasNewer: projectedPage.hasNewer,
entries: projectedPage.entries.map((entry) => ({
provider: snapshot.provider,
item: projectTimelineItemForClient(entry.item, this.clientCapabilities),
item: entry.item,
timestamp: entry.timestamp,
seqStart: entry.seqStart,
seqEnd: entry.seqEnd,
@@ -10,8 +10,6 @@ import type {
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
AgentPlanResponse,
AgentPlanResult,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
@@ -306,7 +304,6 @@ class FakeAgentSession implements AgentSession {
private memoryMarker: string | null = null;
private pendingPermissions: AgentPermissionRequest[] = [];
private permissionGate: Deferred<AgentPermissionResponse> | null = null;
private pendingPlans = new Map<string, { text: string }>();
private readonly historyPath: string;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
@@ -525,55 +522,6 @@ class FakeAgentSession implements AgentSession {
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: {
name: string;
input?: Record<string, unknown>;
@@ -781,16 +729,6 @@ class FakeAgentSession implements AgentSession {
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);
if (tool) {
const returnedEarly = await this.emitToolCallTurn(tool, textPrompt);
@@ -896,20 +834,6 @@ class FakeAgentSession implements AgentSession {
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 {
return buildPersistence(
this.providerName,
@@ -1043,8 +1043,6 @@ export class VoiceAssistantWebSocketServer {
providersSnapshot: true,
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
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.
daemonStatusRpc: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
@@ -73,18 +73,6 @@ const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({
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 {
handleFetchAgentTimelineRequest: (
message: Extract<
@@ -244,16 +232,6 @@ function createSessionForWireCompatTest(options?: {
timestamp: "2026-05-02T00:00:00.200Z",
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({
@@ -389,38 +367,6 @@ describe("wire compatibility", () => {
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", () => {
const parsed = LegacySubAgentToolCallSchema.parse({
type: "tool_call",
@@ -96,6 +96,19 @@ it("creates separate terminals for different cwds", async () => {
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 () => {
manager = createTerminalManager();
const cwd = realpathSync(tmpdir());
@@ -6,6 +6,7 @@ import {
} from "./terminal.js";
import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js";
import { resolve, sep, win32, posix } from "node:path";
import { isSameOrDescendantPath } from "../server/path-utils.js";
export interface TerminalListItem {
id: string;
@@ -169,7 +170,16 @@ export function createTerminalManager(): TerminalManager {
async getTerminals(cwd: string): Promise<TerminalSession[]> {
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: {
@@ -10,7 +10,8 @@ import {
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.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>(): {
promise: Promise<T>;
@@ -140,3 +141,97 @@ describe("terminal-session-controller restore", () => {
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) {
return;
}
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
this.handleTerminalsChanged(event),
);
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => {
void this.handleTerminalsChanged(event);
});
}
getMetrics(): TerminalSessionControllerMetrics {
@@ -293,31 +293,30 @@ export class TerminalSessionController {
};
}
private handleTerminalsChanged(event: TerminalsChangedEvent): void {
if (!this.subscribedDirectories.has(event.cwd)) {
return;
private async handleTerminalsChanged(event: TerminalsChangedEvent): Promise<void> {
// A terminal can live in a subdirectory of a subscribed workspace root (an
// 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 {
this.subscribedDirectories.add(msg.cwd);
void this.emitInitialTerminalsChangedSnapshot(msg.cwd);
void this.emitTerminalsSnapshotForRoot(msg.cwd);
}
private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void {
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)) {
return;
}
@@ -1,6 +1,6 @@
import { afterEach, expect, it } from "vitest";
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 { tmpdir } from "node:os";
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");
});
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 () => {
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-"));
temporaryDirs.push(cwd);