Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] aa06701c9d fix(server): preserve worktree include cleanup 2026-09-09 23:48:56 +02:00
Michael WuandMohamed Boudra a29729ed48 Carry local files and shared state into new worktrees (#2419)
* feat(server): support symlink worktree includes

* refactor(server): simplify worktree include handling

* fix(server): constrain worktree include traversal

* fix(server): clean up failed worktree branches

* fix(server): skip missing worktree include entries

* fix(server): make worktree includes best effort

* fix(server): skip failed worktree includes

* fix(server): harden worktree include planning

* fix(server): preserve reused worktree result shape

Materialization reports describe one creation attempt, not durable worktree identity. Carry them beside the worktree so newly created and reused results keep the same stable shape.

* fix(server): harden worktree include boundaries

Replace directory snapshots exactly, keep canonical Git metadata protected, report recovery skips, and only roll back branches owned before worktree creation.

* fix(server): follow safe include aliases

Traverse canonical in-checkout directory links during glob planning and treat coded revalidation failures as per-entry skips.

* fix(server): make include preflight race safe

Create fetched checkout refs atomically, retain overlapping copy entries for independent fallback, and protect the full managed-worktree base.

* fix(server): preserve staged recovery state

Validate completed directory snapshots, retain backups after failed restoration, and derive atomic ref guards from the repository object ID width.

* fix(server): preserve partial include progress

Keep safe glob matches, enforce recursive-directory types, validate staged roots, and retain OID guards through rollback.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-09 23:48:56 +02:00
Mohamed Boudra 92504cd525 docs(hub): document configurable startup timeout (#4601)
* docs(hub): document configurable startup timeout

* docs: keep startup timeout guidance in one place
2026-09-09 23:37:54 +02:00
Tom GringauzandTom Gringauz 726067b437 fix(claude): open a single compaction marker per compaction (#4391)
Suppress repeated Claude compaction heartbeat markers at the provider and re-arm marker creation at compaction and turn boundaries. Verify emitted events through the public session interface, including subsequent compactions after interrupted or abandoned turns.

Closes #4390.

Co-authored-by: Tom Gringauz <17593920+tomgrin10@users.noreply.github.com>
2026-09-09 22:45:30 +02:00
Mohamed Boudra ec4e81ce0f Show Codex questions while the agent keeps working (#4587)
* fix(codex): show asynchronous questions and preserve their lifecycle

* fix(server): reject concurrent replies to the same question

* test(codex): use native temporary paths in question fixtures
2026-09-09 20:32:21 +02:00
Christoph Leiter bc385b5930 fix(app): don't steal focus when workspace creation finishes late (#2986)
Preserve the selected workspace when delayed creation finishes, while still starting the requested agent or terminal. Keep normal navigation when the initiating screen remains active, and preserve newer draft text and context.

Verified with before/after recordings, ten passing real-browser journeys using isolated daemons, focused regression tests, green CI, and independent review.

QA: https://github.com/getpaseo/paseo/tree/qa/workspace-focus-2986

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-09-09 19:45:24 +02:00
104 changed files with 4411 additions and 4725 deletions
-8
View File
@@ -152,14 +152,6 @@ jobs:
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Install ripgrep (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Install ripgrep (Windows)
if: runner.os == 'Windows'
run: choco install ripgrep --yes --no-progress
- name: Build server dependencies
run: npm run build:server-deps
+2
View File
@@ -52,6 +52,8 @@ Connected clients are trusted operators of the daemon user. File previews follow
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
An explicit `symlink <path>` entry in a repository's `.worktreeinclude` gives a Paseo-created worktree live access to that source-checkout file or directory. It is useful for local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle scripts can modify the source through the link. Paseo validates entries and refuses traversal or destination-link escapes, but the linked source is a deliberate shared-data boundary.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root
-2
View File
@@ -50,13 +50,11 @@ RUN set -eux; \
ca-certificates \
curl \
git \
ripgrep \
gosu \
lbzip2 \
openssh-client \
procps \
tini; \
rg --version; \
rm -rf /var/lib/apt/lists/*
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
+1
View File
@@ -50,6 +50,7 @@ than downloading a published desktop release.
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
@@ -102,8 +102,8 @@ test("dropping the files scope leaves the search row the same height", async ({
const header = panel.getByTestId("command-center-header");
const scoped = await header.boundingBox();
await page.getByTestId("command-center-scope").click();
await expect(page.getByTestId("command-center-scope")).toHaveCount(0);
await page.getByTestId("command-center-files-scope").click();
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
const unscoped = await header.boundingBox();
expect(scoped?.height).toBe(unscoped?.height);
@@ -0,0 +1,22 @@
import { test } from "../support/fixtures";
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
test.describe("Delayed workspace creation", () => {
test.describe.configure({ timeout: 120_000 });
for (const launch of ["chat", "terminal", "empty"] as const) {
test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "leave");
});
test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree");
});
test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, launch, "stay");
});
}
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
});
});
-430
View File
@@ -1,430 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
function source(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function openSource(page: Page, filename: string) {
await openFileExplorer(page);
await openFileFromExplorer(page, filename);
await expect(source(page)).toBeVisible();
}
async function findInSource(page: Page, text: string) {
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill(text);
}
async function expectQuerySelected(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue(text);
await expect
.poll(() =>
query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]),
)
.toEqual([0, text.length]);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(source(page)).toBeFocused();
}
async function expectActiveMatchUncovered(page: Page) {
const match = page.locator(".cm-searchMatch-selected").filter({ visible: true }).first();
await expect(match).toBeVisible();
const widget = page.getByLabel("Find", { exact: true }).filter({ visible: true });
await expect
.poll(async () => {
const m = await match.boundingBox();
const w = await widget.boundingBox();
if (!m || !w) return "missing";
const covered =
m.x < w.x + w.width && m.x + m.width > w.x && m.y < w.y + w.height && m.y + m.height > w.y;
return covered ? "covered by Find" : "visible";
})
.toBe("visible");
}
async function findBoxes(page: Page) {
const pane = (await page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.boundingBox())!;
const widget = (await page
.getByLabel("Find", { exact: true })
.filter({ visible: true })
.boundingBox())!;
return { pane, widget };
}
async function expectFindInsidePane(page: Page) {
const { pane, widget } = await findBoxes(page);
expect(widget.x).toBeGreaterThanOrEqual(pane.x);
expect(widget.x + widget.width).toBeLessThanOrEqual(pane.x + pane.width);
expect(widget.y).toBeGreaterThanOrEqual(pane.y);
expect(widget.y + widget.height).toBeLessThanOrEqual(pane.y + pane.height + 1);
}
async function expectFindAtTopRight(page: Page) {
await expectFindInsidePane(page);
const { pane, widget } = await findBoxes(page);
expect(widget.y).toBeLessThan(pane.y + 70);
}
test("finds literal text at the top of editable source and returns focus at the inspected match", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-" });
const file = path.join(workspace.repoPath, "source.txt");
await writeFile(file, "first a.b\nsecond axb\nthird a.b\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "a.b");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("A.B");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("a.b");
await expectFindAtTopRight(page);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await query(page).press("Shift+Enter");
await expect(status(page)).toHaveText("1 of 2");
await page.screenshot({ path: testInfo.outputPath("editable-find.png") });
await closeFind(page);
await expect(query(page)).toBeHidden();
await expect(page.getByLabel("Line 1, column 10")).toBeVisible();
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
await test.step("reopen with the shortcut, preserve replace and Undo/save", async () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("literal");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("first literal");
await expect(status(page)).toHaveText("1 of 1");
await page.screenshot({ path: testInfo.outputPath("replace.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+z");
await expect(source(page)).toContainText("first a.b");
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first a.b\nsecond axb\nthird a.b\n");
await findInSource(page, "a.b");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("all");
await page.getByRole("button", { name: "Replace all", exact: true }).click();
await expect(status(page)).toHaveText("No matches");
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first all\nsecond axb\nthird all\n");
});
});
test("searches the unsaved buffer while disconnected", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "pane-find-draft-" });
const file = path.join(workspace.repoPath, "draft.txt");
await writeFile(file, "saved on disk\n");
await workspace.navigateTo();
await openSource(page, "draft.txt");
await gate.drop();
await source(page).fill("unsaved needle\nsecond needle\n");
await findInSource(page, "needle");
await expect(status(page)).toHaveText("1 of 2");
expect(await readFile(file, "utf8")).toBe("saved on disk\n");
gate.restore();
});
test("searches read-only source beyond the viewport without replace controls", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-readonly-" });
await writeFile(
path.join(workspace.repoPath, "large.txt"),
"needle first\n" + "plain source line\n".repeat(70_000) + "needle last\n",
);
await workspace.navigateTo();
await openSource(page, "large.txt");
await findInSource(page, "needle");
await expect(source(page)).toHaveAttribute("contenteditable", "false");
await expect(status(page)).toHaveText("1 of 2");
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await expect(source(page)).toContainText("needle last");
await page.screenshot({ path: testInfo.outputPath("readonly-find.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("plain");
await expect(status(page)).toHaveText("1 of 10000+");
});
test("targets the focused source in split panes and refocuses an open query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left needle\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "right needle\nright needle\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const left = source(page).filter({ hasText: "left needle" });
const right = source(page).filter({ hasText: "right needle" });
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await left.click();
await left.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await query(page).press("Escape");
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await right.click();
await right.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("needle");
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
});
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("keeps Find controls inside a narrow source pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("touch-find-closed.png") });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectFindInsidePane(page);
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
async function selectFirstTwoLines(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+Home");
await source(page).press("Shift+ArrowDown");
await source(page).press("Shift+End");
}
async function selectLastWord(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+End");
await source(page).press("ArrowUp");
await source(page).press("Home");
await source(page).press("Shift+End");
}
test("declines multiline selection seeds and keeps replacement aligned with the visible query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-single-line-" });
const file = path.join(workspace.repoPath, "selection.txt");
await writeFile(file, "alpha\nbeta\nalphabeta\n");
await workspace.navigateTo();
await openSource(page, "selection.txt");
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await query(page).press("Enter");
await expect(query(page)).toHaveValue("");
await closeFind(page);
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+g");
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await closeFind(page);
await selectLastWord(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await closeFind(page);
await expect(page.getByLabel("Line 3, column 10")).toBeVisible();
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("replaced");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("alpha");
await expect(source(page)).toContainText("beta");
await expect(status(page)).toHaveText("No matches");
await page.screenshot({ path: testInfo.outputPath("single-line-query-replacement.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("alpha\nbeta\nreplaced\n");
});
for (const startingField of ["Find in pane", "Replace with"] as const) {
test(`repeated Find from ${startingField} selects the query for new typing`, async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-repeat-input-" });
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "needle");
await page.getByRole("button", { name: "Toggle replace" }).click();
const replacement = page.getByRole("textbox", { name: "Replace with" });
await replacement.fill("replacement");
await test.step("repeat Find from the input and type a fresh query", async () => {
const startingInput = page.getByRole("textbox", { name: startingField, exact: true });
await startingInput.press("End");
await startingInput.press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await page.keyboard.type("first");
await expect(query(page)).toHaveValue("first");
await expect(replacement).toHaveValue("replacement");
await expect(status(page)).toHaveText("1 of 1");
});
await page.screenshot({ path: testInfo.outputPath("repeat-find-input.png") });
await closeFind(page);
await expect(page.getByLabel("Line 1, column 13")).toBeVisible();
});
}
test.describe("the active match stays visible under the floating widget", () => {
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("reveals a short file's match that the widget would cover", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await expectActiveMatchUncovered(page);
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
await expect(query(page)).toHaveValue("needle");
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left filler\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "needle one\nplain\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const right = source(page).filter({ hasText: "needle one" });
await expect(right).toBeVisible();
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("split-match-uncovered.png") });
await test.step("stays visible when Replace expands the widget", async () => {
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("hay");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("split-replace-uncovered.png") });
});
await test.step("navigation and query focus still work", async () => {
await query(page).press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await expectActiveMatchUncovered(page);
});
await query(page).press("Escape");
await expect(right).toBeFocused();
});
});
test("Go to line keeps its dialog with Find closed and open", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-goto-" });
const lines = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n");
await writeFile(path.join(workspace.repoPath, "lines.txt"), `${lines}\n`);
await workspace.navigateTo();
await openSource(page, "lines.txt");
const gotoInput = page.getByRole("textbox", { name: "Go to line" });
await test.step("with Find closed", async () => {
await source(page).focus();
await source(page).press("ControlOrMeta+Alt+g");
await expect(gotoInput).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("goto-line-find-closed.png") });
await gotoInput.fill("12");
await gotoInput.press("Enter");
await expect(page.getByLabel("Line 12, column 1")).toBeVisible();
await expect(gotoInput).toBeHidden();
});
await test.step("with Find open", async () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("line 3");
await source(page).focus();
await source(page).press("ControlOrMeta+Alt+g");
await expect(gotoInput).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("goto-line-find-open.png") });
await gotoInput.fill("30");
await gotoInput.press("Enter");
await expect(page.getByLabel("Line 30, column 1")).toBeVisible();
await expect(query(page)).toHaveValue("line 3");
await expect(status(page)).toHaveText("11 matches");
});
});
@@ -1,63 +0,0 @@
import { mkdtemp, rm, symlink } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { test as base, expect } from "../support/fixtures";
import { prepareContentSearch } from "../support/helpers/workspace-content-search";
// The missing-rg fixture builds a PATH from `which` and POSIX symlinks. Windows needs its own
// PATH construction, which belongs to the CI gate rather than to this journey.
base.skip(process.platform === "win32", "the missing-ripgrep PATH fixture is POSIX-shaped");
const test = base.extend<{}, { searchHostPath: string }>({
searchHostPath: [
async ({ browserName }, provide) => {
void browserName;
const bin = await mkdtemp(path.join(os.tmpdir(), "search-host-path-"));
await symlink(
execFileSync("which", ["git"], { encoding: "utf8" }).trim(),
path.join(bin, "git"),
);
await symlink(process.execPath, path.join(bin, "node"));
try {
await provide(bin);
} finally {
await rm(bin, { recursive: true, force: true });
}
},
{ scope: "worker" },
],
e2eDaemonEnvironment: [
async ({ searchHostPath }, provide) => {
await provide({ PATH: searchHostPath });
},
{ scope: "worker" },
],
});
test("missing host ripgrep is actionable and Retry works after installation", async ({
page,
withWorkspace,
searchHostPath,
}, testInfo) => {
const workspace = await withWorkspace();
await prepareContentSearch(workspace);
await page.keyboard.press("Control+Shift+F");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("needle");
await expect(
page.getByText("Install ripgrep on this host and make rg available on its PATH, then retry", {
exact: true,
}),
).toBeVisible();
await page
.getByTestId("command-center-panel")
.screenshot({ path: testInfo.outputPath("missing-rg.png") });
await symlink(
execFileSync("which", ["rg"], { encoding: "utf8" }).trim(),
path.join(searchHostPath, "rg"),
);
await page.getByRole("button", { name: "Retry", exact: true }).click();
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
});
@@ -1,46 +0,0 @@
import { writeFile, readFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "../support/fixtures";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { waitForTerminalContent } from "../support/helpers/terminal-perf";
test("content-search entry and dismissal preserve live shell input and terminal focus", async ({
page,
}, testInfo) => {
const harness = await TerminalE2EHarness.create({ tempPrefix: "content-search-terminal-" });
try {
const terminal = await harness.createTerminal({ name: "Search shortcut QA" });
await writeFile(
path.join(harness.tempRepo.path, "source.ts"),
'export const greeting = "needle";\n',
);
await harness.openTerminal(page, { terminalId: terminal.id });
await harness.setupPrompt(page);
await searchFromPendingShellCommand(page, harness, testInfo.outputPath("terminal-search.png"));
expect(await readFile(path.join(harness.tempRepo.path, "shell-input.txt"), "utf8")).toBe(
"untouched",
);
} finally {
await harness.cleanup();
}
});
async function searchFromPendingShellCommand(
page: import("@playwright/test").Page,
harness: TerminalE2EHarness,
screenshot: string,
) {
const terminal = harness.terminalSurface(page);
await terminal.pressSequentially("printf untouched > shell-input.txt", { delay: 0 });
await page.keyboard.press("Control+Shift+F");
const query = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await expect(query).toBeFocused();
await query.pressSequentially("needle", { delay: 0 });
await expect(page.getByRole("button", { name: /source.ts:1:26/ })).toBeVisible();
await page.screenshot({ path: screenshot });
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).toBeHidden();
await page.keyboard.press("Enter");
await terminal.pressSequentially("printf '\\nSEARCH_SHELL_RESUMED\\n'\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes("SEARCH_SHELL_RESUMED"), 10_000);
}
@@ -1,195 +0,0 @@
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import {
prepareContentSearch,
searchContents,
previewAndOpenSecondOccurrence,
openCompactPreview,
preserveDirtyBufferAndUndo,
recoverChangedAndMissingPreview,
searchContentsByTouch,
} from "../support/helpers/workspace-content-search";
test.use({
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
});
test("searches unopened saved contents, previews keyboard selection and opens the exact occurrence", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await prepareContentSearch(workspace);
await searchContents(page, "needle");
await previewAndOpenSecondOccurrence(page, testInfo.outputPath("content-search-wide.png"));
await preserveDirtyBufferAndUndo(page, workspace);
});
test("compact search keeps list, preview and Open in the existing popup", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await prepareContentSearch(workspace);
await page.setViewportSize({ width: 390, height: 844 });
await searchContentsByTouch(page, "needle");
await openCompactPreview(page, testInfo.outputPath("content-search-compact.png"));
});
test("changed saved files, empty results and occurrence limits stay explicit", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await prepareContentSearch(workspace);
await searchContents(page, "needle");
await recoverChangedAndMissingPreview(
page,
workspace,
testInfo.outputPath("content-search-limited.png"),
);
});
test("a host without content search advertises an update requirement without sending the new RPC", async ({
page,
withWorkspace,
}) => {
const gate = await installDaemonWebSocketGate(page);
gate.setWorkspaceContentSearchStripped();
const workspace = await withWorkspace();
await prepareContentSearch(workspace);
await page.keyboard.press("Meta+Shift+F");
await expect(
page.getByText("Update this host to search file contents", { exact: true }),
).toBeVisible();
await page.keyboard.press("Enter");
await expect(
page.getByText("Update this host to search file contents", { exact: true }),
).toBeVisible();
expect(gate.getClientRequestCount("fs.content.search.request")).toBe(0);
await page.keyboard.press("Backspace");
await expect(
page.getByRole("textbox", {
name: "Search commands, files, workspaces, and agents...",
exact: true,
}),
).toBeVisible();
await page.keyboard.press("Escape");
await page.keyboard.press("Meta+p");
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
});
import { openLiteralSourceOccurrence } from "../support/helpers/workspace-content-search";
test("SVG image opening stays visual while a saved occurrence opens exact source", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await openLiteralSourceOccurrence(
page,
workspace,
{
path: "icon.svg",
content: '<svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
result: 'icon.svg:1:48 <svg xmlns="http://www.w3.org/2000/svg"><title>needle</title></svg>',
imageFirst: true,
},
testInfo.outputPath("svg-source-open.png"),
);
});
test("literal leading-space file identity survives preview and exact tab activation", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await openLiteralSourceOccurrence(
page,
workspace,
{
path: " leading.txt",
content: "needle",
result: " leading.txt:1:1 needle",
cursor: "Line 1, column 7",
},
testInfo.outputPath("literal-path-open.png"),
);
});
test("CR-only saved occurrences use the source editor line and exact selection", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace();
await openLiteralSourceOccurrence(
page,
workspace,
{
path: "cr-only.txt",
content: "first\rneedle",
result: "cr-only.txt:2:1 needle",
cursor: "Line 2, column 7",
},
testInfo.outputPath("cr-source-open.png"),
);
});
import {
openLiteralBackslashFile,
reportSkippedLargeFiles,
} from "../support/helpers/workspace-content-search";
test("an empty result says which files the host never opened", async ({ page, withWorkspace }) => {
await reportSkippedLargeFiles(page, await withWorkspace());
});
// "a\b.txt" is one file on POSIX and a nested path on Windows, so only this fixture is unavailable
// there. The skip belongs to this test: at file scope it annotates the whole suite and takes every
// other journey in this file with it. Windows separator handling is covered by
// resolveWorkspaceFilePaths and toWorkspaceRelativePath.
test("a file name containing a backslash opens itself, not a same-named nested path", async ({
page,
withWorkspace,
}) => {
test.skip(
process.platform === "win32",
"a file name containing a backslash is not a valid Windows fixture",
);
await openLiteralBackslashFile(page, await withWorkspace());
});
import { reopenSameOccurrenceAfterMovingAway } from "../support/helpers/workspace-content-search";
test("opening the same occurrence again navigates the pane it is already showing", async ({
page,
withWorkspace,
}) => {
await reopenSameOccurrenceAfterMovingAway(page, await withWorkspace());
});
import { revealExactPathOnHover } from "../support/helpers/workspace-content-search";
test("a result's tooltip paints the exact path, however deep", async ({
page,
withWorkspace,
}, testInfo) => {
const { rows, tip } = await revealExactPathOnHover(page, await withWorkspace());
await rows.first().hover();
await expect(tip).toBeVisible();
await page.waitForTimeout(400);
await page.screenshot({ path: testInfo.outputPath("tooltip-exact-path.png") });
// Pointing must not move the keyboard selection: Enter still opens the first result.
await page.keyboard.press("Enter");
await expect(page.getByTestId("command-center-panel")).toBeHidden();
await expect(page.getByTestId("file-source-editor")).toContainText("SHARED_UTIL");
});
import { keepDesktopOverlayChrome } from "../support/helpers/workspace-content-search";
test("the desktop panel keeps its backdrop, Escape and focus return in every scope", async ({
page,
withWorkspace,
}) => {
await keepDesktopOverlayChrome(page, await withWorkspace());
});
@@ -26,7 +26,7 @@ const SURFACE_SELECTORS = {
input: '[data-testid="command-center-input"]',
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
results: '[data-testid="command-center-results"]',
scope: '[data-testid="command-center-scope"]',
scope: '[data-testid="command-center-files-scope"]',
fileRow: '[data-testid^="command-center-file-row-"]',
fileIcon: '[data-testid="command-center-file-icon"]',
fileLine: '[data-testid="command-center-file-line"]',
@@ -104,20 +104,19 @@ function stripAssistantMessageId(
return JSON.stringify(envelope);
}
function stripServerFeatures(
function stripCanonicalSubmittedPrompts(
message: string | Buffer,
omittedFeatures: Set<string>,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!omittedFeatures.size || messageType !== "status" || typeof message !== "string")
return message;
if (!enabled || messageType !== "status" || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: { status?: unknown; features?: Record<string, unknown> } };
payload?: { status?: unknown; features?: Record<string, unknown> };
};
const payload = envelope.message?.payload ?? envelope.payload;
if (payload?.status !== "server_info" || !payload.features) return message;
for (const feature of omittedFeatures) delete payload.features[feature];
delete payload.features.canonicalSubmittedPrompts;
return JSON.stringify(envelope);
}
@@ -300,7 +299,7 @@ export async function installDaemonWebSocketGate(page: Page) {
let suppressAgentStream = false;
let forceTimelineEpochReset = false;
let stripAssistantMessageIds = false;
const omittedFeatures = new Set<string>();
let stripCanonicalSubmittedPromptsFeature = false;
let shellToolCommandOverride: string | null = null;
let failingTimelineAgentId: string | null = null;
let holdingTimelineAgentId: string | null = null;
@@ -510,7 +509,11 @@ export async function installDaemonWebSocketGate(page: Page) {
serverMessage?.type,
);
outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride);
outboundMessage = stripServerFeatures(outboundMessage, omittedFeatures, serverMessage?.type);
outboundMessage = stripCanonicalSubmittedPrompts(
outboundMessage,
stripCanonicalSubmittedPromptsFeature,
serverMessage?.type,
);
const isTimelineResponse = serverMessage?.type === "fetch_agent_timeline_response";
if (isTimelineResponse) {
outboundMessage = failTimelineResponse(outboundMessage, failingTimelineAgentId);
@@ -820,12 +823,8 @@ export async function installDaemonWebSocketGate(page: Page) {
setAssistantMessageIdsStripped(stripped: boolean): void {
stripAssistantMessageIds = stripped;
},
setWorkspaceContentSearchStripped(): void {
omittedFeatures.add("workspaceContentSearch");
},
setCanonicalSubmittedPromptsStripped(stripped: boolean): void {
if (stripped) omittedFeatures.add("canonicalSubmittedPrompts");
else omittedFeatures.delete("canonicalSubmittedPrompts");
stripCanonicalSubmittedPromptsFeature = stripped;
},
setShellToolCommandOverride(command: string | null): void {
shellToolCommandOverride = command;
@@ -0,0 +1,197 @@
import { expect, test, type Page } from "../fixtures";
import { readFile } from "node:fs/promises";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoAppShell } from "./app";
import { scrollTimelineToOldestLoadedEdge } from "./timeline-pagination";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
countWorkspaceAgents,
countWorkspaceTerminals,
delayBrowserWorkspaceCreatedResponse,
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
openProjectViaDaemon,
selectWorkspaceIsolation,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
waitForCreatedWorkspace,
} from "./new-workspace";
import { createTempGitRepo } from "./workspace";
import { expectAppRoute } from "./route-assertions";
import { getServerId } from "./server-id";
import {
expectTerminalOutputContains,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
seedTerminalProfiles,
selectLaunchOption,
submitTerminalLaunch,
type TerminalProfile,
} from "./new-workspace-launch";
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./workspace-ui";
import { dropFileOnComposer, expectAttachmentPill } from "./composer";
const PROMPT = "Hello from the navigation guard";
const NEXT_PROMPT = "Keep this newer draft";
const CONTEXT = {
name: "context.json",
mimeType: "application/json",
buffer: Buffer.from('{"preserve":"attachment"}'),
};
const PROFILE: TerminalProfile = {
id: "e2e-nav-guard-echo",
name: "Nav Guard Echo",
command: "/bin/sh",
args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"],
};
export async function verifyDelayedWorkspaceCreation(
page: Page,
launch: "chat" | "terminal" | "empty",
destination: "leave" | "stay" | "new-draft",
isolation: "local" | "worktree" = "local",
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("workspace-focus-");
const profileSeed = await seedTerminalProfiles([PROFILE]);
const delay = await delayBrowserWorkspaceCreatedResponse(page);
const serverId = getServerId();
let localWorkspaceId: string | undefined;
let createdDirectory: string | undefined;
let newDraftRoute = "";
try {
const project = await openProjectViaDaemon(client, repo.path);
localWorkspaceId = project.workspaceId;
const knownIds = new Set((await client.fetchWorkspaces()).entries.map((entry) => entry.id));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
await test.step("Submit workspace creation with the requested content", async () => {
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation);
if (launch === "chat") {
await dropFileOnComposer(page, CONTEXT);
await expectAttachmentPill(page, "composer-file-attachment-pill");
await submitNewWorkspacePrompt(page, PROMPT);
} else if (launch === "terminal") {
await selectLaunchOption(page, PROFILE.id);
await fillTerminalPrompt(page, PROMPT);
await submitTerminalLaunch(page);
} else {
await submitNewWorkspaceEmpty(page);
}
await delay.waitForCreateRequest();
});
await test.step("Choose where to work while the creation response is held", async () => {
if (destination !== "stay") {
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
}
if (destination === "new-draft") {
await openNewWorkspaceComposer(page, project);
await fillNewWorkspaceDraft(page, NEXT_PROMPT);
const url = new URL(page.url());
newDraftRoute = url.pathname + url.search;
}
// Recording pacing only; the response is held deterministically above.
if (process.env.E2E_RECORD_VIDEO === "1") {
await page.mouse.move(1100, 80);
await page.waitForTimeout(1500);
}
delay.release();
});
const created = await waitForCreatedWorkspace(client, knownIds);
createdDirectory = created.workspaceDirectory;
expect(created.workspaceKind).toBe(isolation === "worktree" ? "worktree" : "local_checkout");
await test.step("Completion creates exactly the requested resource and preserves focus", async () => {
await expect
.poll(() => countWorkspaceAgents(client, created.id))
.toBe(launch === "chat" ? 1 : 0);
await expect
.poll(() => countWorkspaceTerminals(client, created.id))
.toBe(launch === "terminal" ? 1 : 0);
// A negative navigation assertion needs an observation window after completion.
await page.waitForTimeout(5000);
const expectedRoute =
destination === "new-draft"
? newDraftRoute
: buildHostWorkspaceRoute(
serverId,
destination === "stay" ? created.id : project.workspaceId,
);
await expectAppRoute(page, expectedRoute);
if (destination === "new-draft") await expectNewWorkspaceDraft(page, NEXT_PROMPT);
if (launch === "chat") {
expect(delay.agentRequests).toHaveLength(1);
const upload = delay.agentRequests[0]?.attachments?.find(
(item) => item.type === "uploaded_file",
);
if (!upload) throw new Error("The create request did not contain the submitted file");
expect(await readFile(upload.path)).toEqual(CONTEXT.buffer);
expect(delay.agentRequests[0]).toMatchObject({
workspaceId: created.id,
initialPrompt: PROMPT,
config: {
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
thinkingOptionId: "low",
cwd: created.workspaceDirectory,
},
attachments: [
expect.objectContaining({
fileName: CONTEXT.name,
mimeType: CONTEXT.mimeType,
size: CONTEXT.buffer.length,
}),
],
});
}
});
await test.step("Visit the new workspace and return without submitting a second draft", async () => {
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
if (launch === "chat") {
await scrollTimelineToOldestLoadedEdge(page);
await expect(page.getByText(PROMPT, { exact: true }).first()).toBeVisible();
} else if (launch === "terminal") {
await expectWorkspaceOpensWithTerminalTab(page);
await expectTerminalOutputContains(page, `captured: ${PROMPT}`);
}
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
await expect
.poll(() => countWorkspaceAgents(client, created.id))
.toBe(launch === "chat" ? 1 : 0);
await expect
.poll(() => countWorkspaceTerminals(client, created.id))
.toBe(launch === "terminal" ? 1 : 0);
if (launch === "chat") {
expect(delay.agentRequests).toHaveLength(1);
await openNewWorkspaceComposer(page, project);
await expectNewWorkspaceDraft(page, destination === "new-draft" ? NEXT_PROMPT : "");
}
if (process.env.E2E_RECORD_VIDEO === "1") {
await page.mouse.move(1100, 80);
await page.waitForTimeout(1500);
}
});
} catch (error) {
await page.screenshot({ path: test.info().outputPath("before-cleanup.png") });
console.error(error);
throw error;
} finally {
delay.release();
if (createdDirectory) await archiveWorkspaceFromDaemon(client, createdDirectory);
if (localWorkspaceId) await archiveLocalWorkspaceFromDaemon(client, localWorkspaceId);
await profileSeed.restore();
await client.close();
await repo.cleanup();
}
}
@@ -1,4 +1,5 @@
import { expect, type BrowserContext, type Page } from "@playwright/test";
import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
@@ -16,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
@@ -24,6 +26,7 @@ type NewWorkspaceDaemonClient = Pick<
| "enablePlugin"
| "inspectWorkspaceRecovery"
| "listProjects"
| "listTerminals"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -622,3 +625,120 @@ export async function delayBrowserAgentCreatedStatus(
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
export interface WorkspaceCreatedDelayControl {
agentRequests: readonly CreateAgentRequestMessage[];
release(): void;
waitForCreateRequest(): Promise<void>;
}
/**
* Holds `workspace.create.response` so a test can act as the user does while the daemon is still
* running `git worktree add` — most importantly, navigate somewhere else.
*/
export async function delayBrowserWorkspaceCreatedResponse(
page: Page,
): Promise<WorkspaceCreatedDelayControl> {
const daemonPortPattern = daemonWsRoutePattern();
const agentRequests: CreateAgentRequestMessage[] = [];
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
const createRequestSeen = new Promise<void>((resolve) => {
resolveCreateRequest = resolve;
});
await page.routeWebSocket(daemonPortPattern, (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request")
agentRequests.push(sessionMessage as CreateAgentRequestMessage);
if (sessionMessage?.type === "workspace.create.request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "workspace.create.response" &&
typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (requestId && createRequestIds.has(requestId) && !releaseRequested) {
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
agentRequests,
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
};
}
type WorkspaceEntry = Awaited<
ReturnType<NewWorkspaceDaemonClient["fetchWorkspaces"]>
>["entries"][number];
/**
* Waits for a workspace the caller did not already know about, and returns it. Tests that create a
* workspace without navigating to it have no route to assert on, so the daemon's own list is the
* signal that creation finished.
*/
export async function waitForCreatedWorkspace(
client: NewWorkspaceDaemonClient,
knownWorkspaceIds: ReadonlySet<string>,
options?: { timeout?: number },
): Promise<WorkspaceEntry> {
let created: WorkspaceEntry | undefined;
await expect
.poll(
async () => {
const payload = await client.fetchWorkspaces();
created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id));
return created !== undefined;
},
{ timeout: options?.timeout ?? 60_000 },
)
.toBe(true);
if (!created) {
throw new Error("Workspace list reported a new workspace but it could not be read back");
}
return created;
}
export async function countWorkspaceAgents(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<number> {
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length;
}
export async function countWorkspaceTerminals(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<number> {
const payload = await client.listTerminals(undefined, undefined, { workspaceId });
return payload.terminals.length;
}
@@ -1,347 +0,0 @@
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { composerLocator } from "./composer";
import type { CreatedWorkspace } from "./with-workspace";
export async function prepareContentSearch(workspace: CreatedWorkspace) {
await writeFile(
path.join(workspace.repoPath, "search-a.ts"),
'export const greeting = "NEEDLE";\n',
);
await writeFile(
path.join(workspace.repoPath, "search-b.ts"),
'const emoji = "é🙂 needle needle";\n',
);
await workspace.navigateTo();
}
export async function searchContents(page: Page, query: string) {
await page.keyboard.press("Meta+Shift+F");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await expect(input).toBeVisible();
await input.fill(query);
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
}
export async function previewAndOpenSecondOccurrence(page: Page, screenshot: string) {
const panel = page.getByTestId("command-center-panel");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await expect(panel.getByTestId("file-source-editor")).toContainText("export const greeting");
await page.keyboard.press("ArrowDown");
await expect(panel.getByTestId("file-source-editor")).toContainText("const emoji");
await expect(input).toBeFocused();
// The query field is the Command Center's own header input, above the results and preview.
const inputBox = await input.boundingBox();
const previewBox = await panel.getByTestId("file-source-editor").boundingBox();
expect(inputBox!.y + inputBox!.height).toBeLessThan(previewBox!.y);
await panel.screenshot({ path: screenshot });
await page.keyboard.press("Enter");
await expect(panel).toBeHidden();
await expect(page.getByTestId("file-source-editor")).toContainText("const emoji");
// The source view uses a real CodeMirror document; select-all is unnecessary and would hide a bad jump.
await expect(page.getByLabel("Line 1, column 26", { exact: true })).toBeVisible();
}
export async function openCompactPreview(page: Page, screenshot: string) {
await page.getByRole("button", { name: /search-a.ts:1:26/ }).click();
await expect(page.getByRole("button", { name: "Back to results", exact: true })).toBeVisible();
await expect(
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
).toContainText("export const greeting");
await page.getByTestId("command-center-panel").screenshot({ path: screenshot });
await page.getByRole("button", { name: "Open", exact: true }).click();
await expect(page.getByTestId("command-center-panel")).toBeHidden();
await expect(page.getByTestId("file-source-editor")).toContainText("export const greeting");
}
export async function preserveDirtyBufferAndUndo(page: Page, workspace: CreatedWorkspace) {
const source = page.getByTestId("file-source-editor").locator(".cm-content");
await source.fill("const localUnsaved = 42;\n");
await writeFile(
path.join(workspace.repoPath, "search-b.ts"),
'const emoji = "é🙂 needle needle";\n// changed on disk\n',
);
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await searchContents(page, "needle");
await page.keyboard.press("ArrowDown");
await expect(
page.getByTestId("command-center-panel").getByTestId("file-source-editor"),
).toContainText("changed on disk");
await page.keyboard.press("Enter");
await expect(source).toContainText("localUnsaved");
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toContainText(
"saved match is no longer at this location",
);
await source.press("Control+z");
await expect(source).toContainText('const emoji = "é🙂 needle needle";');
await expect(source).not.toContainText("localUnsaved");
}
export async function recoverChangedAndMissingPreview(
page: Page,
workspace: CreatedWorkspace,
screenshot: string,
) {
const panel = page.getByTestId("command-center-panel");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await writeFile(path.join(workspace.repoPath, "search-b.ts"), "changed after search\n");
await page.keyboard.press("ArrowDown");
await expect(panel.getByRole("status")).toContainText(
"saved match is no longer at this location",
);
await unlink(path.join(workspace.repoPath, "search-a.ts"));
await page.keyboard.press("ArrowUp");
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
await writeFile(
path.join(workspace.repoPath, "search-a.ts"),
'export const greeting = "NEEDLE";\n',
);
await panel.getByRole("button", { name: "Retry", exact: true }).click();
await expect(panel.getByTestId("file-source-editor")).toContainText("NEEDLE");
await input.fill("not-present-anywhere");
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
await writeFile(path.join(workspace.repoPath, "many.txt"), "needle\n".repeat(250));
await input.fill("needle");
await expect(panel.getByText(/Results limited/)).toBeVisible();
await panel.screenshot({ path: screenshot });
}
export async function searchContentsByTouch(page: Page, query: string) {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await page.getByRole("button", { name: "Search", exact: true }).click();
await page
.getByRole("textbox", {
name: "Search commands, files, workspaces, and agents...",
exact: true,
})
.fill("Search file contents");
await page.getByText("Search file contents", { exact: true }).click();
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill(query);
await expect(page.getByRole("button", { name: /search-a.ts:1:26/ })).toBeVisible();
}
export async function openLiteralSourceOccurrence(
page: Page,
workspace: CreatedWorkspace,
input: { path: string; content: string; result: string; cursor?: string; imageFirst?: boolean },
screenshot: string,
) {
await writeFile(path.join(workspace.repoPath, input.path), input.content);
await writeFile(path.join(workspace.repoPath, "leading.txt"), "different file");
await workspace.navigateTo();
if (input.imageFirst) {
await page.keyboard.press("Meta+p");
await page.getByRole("textbox", { name: "Search files...", exact: true }).fill(input.path);
await page.getByTestId("command-center-panel").getByText(input.path, { exact: true }).click();
await expect(page.getByTestId("image-file-preview")).toBeVisible();
}
await page.keyboard.press("Meta+Shift+F");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("needle");
const panel = page.getByTestId("command-center-panel");
await expect(panel.getByRole("button", { name: input.result, exact: true })).toBeVisible();
await expect(panel.getByTestId("file-source-editor")).toContainText("needle");
await expect(panel.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
await page.keyboard.press("Enter");
await expect(panel).toBeHidden();
const source = page.getByTestId("file-source-editor");
await expect(source).toContainText("needle");
await expect(source).not.toContainText("different file");
await expect(source.locator(".cm-selectionBackground")).toBeVisible();
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
if (input.cursor) await expect(page.getByLabel(input.cursor, { exact: true })).toBeVisible();
await page.screenshot({ path: screenshot });
}
export async function reportSkippedLargeFiles(page: Page, workspace: CreatedWorkspace) {
// rg never opens a file above the host ceiling, so an unqualified "No matches" would claim the
// whole workspace was read.
await writeFile(path.join(workspace.repoPath, "large.txt"), `needle${" ".repeat(1024 * 1024)}`);
await workspace.navigateTo();
await page.keyboard.press("Meta+Shift+F");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
await input.fill("needle");
const panel = page.getByTestId("command-center-panel");
await expect(panel.getByText("No matches", { exact: true })).toBeVisible();
await expect(
panel.getByText("Files over 1.0 MB were not searched", { exact: true }),
).toBeVisible();
// The occurrence-cap notice is a different claim and must not appear here.
await expect(panel.getByText(/Results limited/)).toHaveCount(0);
}
export async function openLiteralBackslashFile(page: Page, workspace: CreatedWorkspace) {
// A backslash is an ordinary character in a Unix file name; "a\b.txt" is not "a/b.txt".
await writeFile(path.join(workspace.repoPath, "a\\b.txt"), "needle LITERAL FILE");
await mkdir(path.join(workspace.repoPath, "a"), { recursive: true });
await writeFile(path.join(workspace.repoPath, "a", "b.txt"), "THIS IS THE OTHER FILE");
await workspace.navigateTo();
await page.keyboard.press("Meta+Shift+F");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("needle");
const panel = page.getByTestId("command-center-panel");
await expect(panel.getByRole("button", { name: /a\\b\.txt:1:1/ })).toBeVisible();
await expect(panel.getByTestId("file-source-editor")).toContainText("LITERAL FILE");
await page.keyboard.press("Enter");
await expect(panel).toBeHidden();
const source = page.getByTestId("file-source-editor");
await expect(source).toContainText("LITERAL FILE");
await expect(source).not.toContainText("THIS IS THE OTHER FILE");
await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0);
}
/**
* Opening the same occurrence twice has to navigate the pane both times. The tab target is
* deliberately identity-stable, so the second activation looks like a no-op unless it asks the
* file-opening owner to navigate again.
*/
export async function reopenSameOccurrenceAfterMovingAway(page: Page, workspace: CreatedWorkspace) {
const lines = Array.from(
{ length: 1200 },
(_value, index) => `export const filler${index} = ${index};`,
);
lines.push('const marker = "REOPEN_TARGET";');
await writeFile(path.join(workspace.repoPath, "reopen.ts"), `${lines.join("\n")}\n`);
await workspace.navigateTo();
const scroller = page.getByTestId("file-source-editor").locator(".cm-scroller");
const offset = () => scroller.evaluate((element) => element.scrollTop);
await openReopenTarget(page);
const revealed = await offset();
expect(revealed).toBeGreaterThan(0);
await expect(
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
).toBeInViewport();
// Move the caret to the top of the same file, inside the same editor instance.
await page.getByTestId("file-source-editor").locator(".cm-content").click();
await page.keyboard.press("ControlOrMeta+Home");
await expect.poll(offset).toBeLessThan(revealed / 2);
await openReopenTarget(page);
await expect
.poll(offset, { message: "reopening the same occurrence must navigate the pane again" })
.toBeGreaterThan(revealed / 2);
await expect(
page.getByTestId("file-source-editor").locator(".cm-selectionBackground"),
).toBeInViewport();
// The editor instance is reused, so its history survives the second navigation.
await expect(page.getByTestId("file-source-editor")).toContainText("REOPEN_TARGET");
}
async function openReopenTarget(page: Page) {
await page.keyboard.press("Meta+Shift+F");
const panel = page.getByTestId("command-center-panel");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("REOPEN_TARGET");
await expect(panel.getByRole("button", { name: /reopen\.ts:1201:/ })).toBeVisible();
await page.keyboard.press("Enter");
await expect(panel).toBeHidden();
}
/**
* Two files can share a base name and their nearest parent, and a real path is often wider than
* the result column, so the exact workspace-relative path lives in the tooltip. Being in the DOM
* is not enough: the tooltip has to be the thing painted at its own coordinates.
*/
export async function revealExactPathOnHover(page: Page, workspace: CreatedWorkspace) {
const deep =
"packages/app/src/command-center/workspace-content-search/internal/deeply/nested/result-path-presentation.ts";
for (const relative of [
"packages/app/src/utils/index.ts",
"packages/server/src/utils/index.ts",
deep,
]) {
const target = path.join(workspace.repoPath, relative);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, `export const shared = "SHARED_UTIL";\n`);
}
await workspace.navigateTo();
await page.keyboard.press("Meta+Shift+F");
await page
.getByRole("textbox", { name: "Search saved file contents...", exact: true })
.fill("SHARED_UTIL");
const panel = page.getByTestId("command-center-panel");
const rows = panel.getByRole("button", { name: /:1:24/ });
await expect(rows).toHaveCount(3);
const tip = page.getByTestId("content-search-row-path");
async function expectPaintedPath(row: Locator, expected: string) {
await row.hover();
await expect(tip).toHaveText(expected);
// The reader must actually see it: whatever is painted at the tooltip's own coordinates has
// to be the tooltip, not the panel that used to cover it.
// A tooltip is pointer-transparent, so elementFromPoint reports the row underneath either
// way, and its surface is the same white as the panel. What settles it is whether the pixels
// in its own rectangle change when it opens: if the panel still covers it, they do not.
const box = await tip.boundingBox();
if (!box) throw new Error("tooltip has no box");
const clip = { x: box.x, y: box.y, width: box.width, height: box.height };
const shown = await page.screenshot({ clip });
await page.mouse.move(2, 2);
await expect(tip).toBeHidden();
const hidden = await page.screenshot({ clip });
expect(
shown.equals(hidden),
"the tooltip's own rectangle must change when it opens, or the reader cannot see it",
).toBe(false);
}
// Same base name and same nearest parent: the resting labels cannot tell these apart.
const app = rows.filter({ hasText: "…/utils/index.ts:1:24" }).first();
const server = rows.filter({ hasText: "…/utils/index.ts:1:24" }).last();
await expect(app).toHaveAccessibleName(/^packages\/app\/src\/utils\/index\.ts:1:24 /);
await expect(server).toHaveAccessibleName(/^packages\/server\/src\/utils\/index\.ts:1:24 /);
await expectPaintedPath(app, "packages/app/src/utils/index.ts:1:24");
await expectPaintedPath(server, "packages/server/src/utils/index.ts:1:24");
// A path far wider than the result column still reads in full.
await expectPaintedPath(
rows.filter({ hasText: "result-path-presentation.ts" }).first(),
`${deep}:1:24`,
);
return { panel, rows, tip };
}
/**
* The overlay chrome the desktop panel owns: a backdrop that dismisses it, Escape that dismisses
* it, and focus that goes back where it came from. Hosting the panel in the shared overlay root
* rather than a native Modal must not change any of it.
*/
export async function keepDesktopOverlayChrome(page: Page, workspace: CreatedWorkspace) {
await writeFile(path.join(workspace.repoPath, "chrome.ts"), 'const a = "CHROME_TARGET";\n');
await workspace.navigateTo();
const panel = page.getByTestId("command-center-panel");
const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true });
// Backdrop dismisses the panel.
const composer = composerLocator(page);
await composer.click();
await expect(composer).toBeFocused();
await page.keyboard.press("Meta+Shift+F");
await expect(input).toBeFocused();
await page.mouse.click(page.viewportSize()!.width - 12, page.viewportSize()!.height - 12);
await expect(panel).toBeHidden();
// Escape dismisses it and hands focus back to the composer it was opened from.
await page.keyboard.press("Meta+Shift+F");
await input.fill("CHROME_TARGET");
await expect(panel.getByRole("button", { name: /chrome\.ts:1:/ })).toBeVisible();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
await expect(composer).toBeFocused();
// Ordinary commands and the Files scope still open over the same chrome.
await page.keyboard.press("Meta+k");
await expect(page.getByTestId("command-center-input")).toBeFocused();
await page.keyboard.press("Escape");
await page.keyboard.press("Meta+p");
await expect(page.getByRole("textbox", { name: "Search files...", exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
}
@@ -1,4 +1,3 @@
import { WorkspaceContentSearch } from "./workspace-content-search";
import {
FlatList,
Modal,
@@ -11,8 +10,7 @@ import {
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { createPortal } from "react-dom";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -38,7 +36,6 @@ import { isNative, isWeb } from "@/constants/platform";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import {
getOverlayRoot,
OverlayLayerProvider,
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
@@ -93,18 +90,7 @@ const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMut
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const SCOPE_LABELS = {
files: {
chip: "shell.commandCenter.files",
placeholder: "shell.commandCenter.filePlaceholder",
},
content: {
chip: "shell.commandCenter.content",
placeholder: "shell.commandCenter.contentPlaceholder",
},
} as const;
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
const CONTENT_SEARCH_SNAP_POINTS = ["90%"];
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
@@ -273,7 +259,7 @@ function useCommandCenterState(): CommandCenterState {
error: fileSearchError,
openFile,
} = useWorkspaceFileSearch({
enabled: open && scope !== "content" && (scope === "files" || Boolean(query.trim())),
enabled: open && (scope === "files" || Boolean(query.trim())),
query,
});
const fileSections = useMemo<CommandCenterResultSection[]>(() => {
@@ -598,7 +584,6 @@ export function CommandCenter() {
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const contentKeyHandler = useRef<((key: string) => boolean) | null>(null);
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
@@ -715,30 +700,18 @@ export function CommandCenter() {
onScroll: handleListScroll,
scrollEventThrottle: 16,
};
// Every scope wears the same header; only the chip and the placeholder name it.
const scopeLabels = state.scope ? SCOPE_LABELS[state.scope] : null;
const scopeChipLabel = scopeLabels ? t(scopeLabels.chip) : null;
const placeholder = t(scopeLabels?.placeholder ?? "shell.commandCenter.placeholder");
// Content search owns arrows, Enter and Backspace while its scope is active; every other scope
// is driven by the shared result list. One dispatcher so the input, submit and the web overlay
// can never disagree about who is steering.
const dispatchKey = useCallback(
(key: string) =>
state.scope === "content" ? (contentKeyHandler.current?.(key) ?? false) : state.key(key),
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
[state],
);
const keyPress = useCallback(
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => dispatchKey(key),
[dispatchKey],
);
const submit = useCallback(() => dispatchKey("Enter"), [dispatchKey]);
const submit = useCallback(() => state.key("Enter"), [state]);
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!dispatchKey(event.key)) return false;
if (!state.key(event.key)) return false;
event.preventDefault();
return true;
},
[dispatchKey],
[state],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && state.open && !showBottomSheet,
@@ -757,9 +730,7 @@ export function CommandCenter() {
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
snapPoints={
state.scope === "content" ? CONTENT_SEARCH_SNAP_POINTS : COMMAND_CENTER_SNAP_POINTS
}
snapPoints={COMMAND_CENTER_SNAP_POINTS}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
@@ -773,7 +744,9 @@ export function CommandCenter() {
accessible={false}
>
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
{scopeChipLabel ? <ScopeChip label={scopeChipLabel} onRemove={state.clearScope} /> : null}
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedBottomSheetTextInput
testID="command-center-input"
ref={bottomSheetInputRef}
@@ -782,7 +755,11 @@ export function CommandCenter() {
onChangeText={state.setQuery}
onKeyPress={keyPress}
onSubmitEditing={submit}
placeholder={placeholder}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
@@ -793,89 +770,48 @@ export function CommandCenter() {
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{state.scope === "content" ? (
<WorkspaceContentSearch
query={state.query}
compact={isCompact}
close={state.close}
clearScope={state.clearScope}
keyHandler={contentKeyHandler}
/>
) : (
<>
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</>
)}
{fileSearchError}
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
</IsolatedBottomSheetModal>
);
}
if (!state.open) return null;
const desktopContent = (
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
<View style={[styles.overlay, isWeb && { zIndex: modalLayer }]}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View
ref={setWebOverlayScope}
testID="command-center-panel"
style={[styles.panel, state.scope === "content" && styles.contentPanel]}
>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{scopeChipLabel ? (
<ScopeChip label={scopeChipLabel} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
initialValue={state.query}
onChangeText={state.setQuery}
placeholder={placeholder}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{state.scope === "content" ? (
<WorkspaceContentSearch
query={state.query}
compact={isCompact}
close={state.close}
clearScope={state.clearScope}
keyHandler={contentKeyHandler}
/>
) : (
<>
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
</>
)}
</View>
</View>
</OverlayLayerProvider>
);
return hostDesktopOverlay(desktopContent, state.close);
}
/**
* Desktop web hosts the panel in the shared overlay root rather than React Native Web's `Modal`,
* which paints in a plane of its own above it. Inside that plane an ordinary portal — the tooltip
* on a result row — can never cover the panel however high its own z-index, so the row could not
* show a reader its exact path. `adaptive-modal-sheet` already hosts desktop modals this way; see
* docs/floating-panels.md. Native keeps the Modal.
*/
function hostDesktopOverlay(content: ReactElement, onRequestClose: () => void): ReactElement {
if (isWeb && typeof document !== "undefined") {
return createPortal(content, getOverlayRoot());
}
return (
<Modal visible transparent animationType="fade" onRequestClose={onRequestClose}>
{content}
</Modal>
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={state.close} />
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
{state.scope === "files" ? (
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
) : null}
<ThemedTextInput
testID="command-center-input"
ref={state.inputRef}
initialValue={state.query}
onChangeText={state.setQuery}
placeholder={
state.scope === "files"
? t("shell.commandCenter.filePlaceholder")
: t("shell.commandCenter.placeholder")
}
style={[styles.input, styles.growingInput]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<FileSearchLoadingIndicator
loading={state.fileSearchLoading}
label={t("shell.commandCenter.searchingFiles")}
/>
</View>
{fileSearchError}
<FlatList ref={listRef} {...commonListProps} />
</View>
</View>
</Modal>
</OverlayLayerProvider>
);
}
@@ -905,7 +841,7 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
accessibilityLabel={label}
onPress={onRemove}
style={styles.scopeChip}
testID="command-center-scope"
testID="command-center-files-scope"
>
<ThemedFolder size={14} strokeWidth={2.2} />
<Text style={styles.scopeChipLabel}>{label}</Text>
@@ -916,14 +852,12 @@ function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) {
const styles = StyleSheet.create((theme) => ({
overlay: {
...StyleSheet.absoluteFillObject,
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: theme.spacing[12],
pointerEvents: "auto",
},
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" },
contentPanel: { width: 1000, maxWidth: "96%", height: "75%" },
panel: {
width: 640,
height: 560,
@@ -1,513 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { FlatList, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { WorkspaceContentMatch } from "@getpaseo/protocol/messages";
import { isWeb } from "@/constants/platform";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { WorkspaceFileSource } from "@/file-pane/source";
import { formatFileSize } from "@/utils/format-file-size";
import { describeResultPath } from "./internal/result-path";
import {
WorkspaceContentSearchModel,
type ContentSearchSnapshot,
type ContentSearchTransport,
} from "./internal/model";
const ThemedSpinner = withUnistyles(LoadingSpinner, (theme) => ({
color: theme.colors.foregroundMuted,
}));
interface Props {
/** The Command Center owns the field; this body only reacts to what was typed in it. */
query: string;
compact: boolean;
close(): void;
clearScope(): void;
keyHandler: React.RefObject<((key: string) => boolean) | null>;
}
export function WorkspaceContentSearch(props: Props) {
const { keyHandler, close, clearScope } = props;
const { t } = useTranslation();
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const session = useSessionStore((state) => (serverId ? state.sessions[serverId] : undefined));
const unavailable =
!session?.client ||
!cwd ||
!serverId ||
!workspaceId ||
!session.serverInfo?.features?.workspaceContentSearch;
useEffect(() => {
if (!unavailable) return;
keyHandler.current = (key) => {
if (key === "Escape") close();
else if (key === "Backspace") clearScope();
return ["Enter", "ArrowUp", "ArrowDown", "Escape", "Backspace"].includes(key);
};
return () => {
keyHandler.current = null;
};
}, [unavailable, keyHandler, close, clearScope]);
if (!session?.client || !cwd || !serverId || !workspaceId)
return <Text style={styles.status}>{t("shell.commandCenter.contentUnavailable")}</Text>;
// COMPAT(workspaceContentSearch): added in v0.8.0, remove after 2027-03-09 once daemon floor >= v0.8.0.
if (!session.serverInfo?.features?.workspaceContentSearch)
return <Text style={styles.status}>{t("shell.commandCenter.contentUpdateHost")}</Text>;
return (
<ContentSearch
key={`${serverId}:${workspaceId}`}
{...props}
transport={session.client}
cwd={cwd}
serverId={serverId}
workspaceId={workspaceId}
/>
);
}
function ContentSearch({
transport,
cwd,
serverId,
workspaceId,
query,
compact,
close,
clearScope,
keyHandler,
}: Props & {
transport: ContentSearchTransport;
cwd: string;
serverId: string;
workspaceId: string;
}) {
const [previewPage, setPreviewPage] = useState(false);
const closeRef = useRef(close);
closeRef.current = close;
const model = useMemo(
() =>
new WorkspaceContentSearchModel({
cwd,
transport,
open(location) {
clearCommandCenterFocusRestoreElement();
const layout = useWorkspaceLayoutStore.getState();
openWorkspaceFileInFocusedPane({
location,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
layout.openTab({ workspaceKey, target, intent: "reveal", placement }),
focusWorkspaceTab: layout.focusTab,
requestFileNavigation: layout.requestFileNavigation,
});
closeRef.current();
},
}),
[cwd, transport, serverId, workspaceId],
);
useEffect(() => () => model.dispose(), [model]);
// The field lives in the Command Center header, so the typed value is a prop; this is the only
// bridge from it into the model that owns the search.
useEffect(() => {
setPreviewPage(false);
model.setQuery(query);
}, [model, query]);
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const listRef = useRef<FlatList>(null);
useEffect(() => {
if (state.matches.length && (!compact || !previewPage))
listRef.current?.scrollToIndex({
index: state.activeIndex,
animated: false,
viewPosition: 0.5,
});
}, [compact, previewPage, state.activeIndex, state.matches]);
const key = useCallback(
(pressed: string) => {
if (pressed === "Escape") {
close();
return true;
}
if (pressed === "Backspace" && !state.query) {
clearScope();
return true;
}
if (pressed === "ArrowDown" || pressed === "ArrowUp") {
model.move(pressed === "ArrowDown" ? 1 : -1);
return true;
}
if (pressed === "Enter") {
model.openSelected();
return true;
}
return false;
},
[close, clearScope, model, state.query],
);
useEffect(() => {
keyHandler.current = key;
return () => {
if (keyHandler.current === key) keyHandler.current = null;
};
}, [keyHandler, key]);
const select = useCallback(
(index: number) => {
model.select(index);
if (compact) setPreviewPage(true);
},
[model, compact],
);
const back = useCallback(() => setPreviewPage(false), []);
const renderItem = useCallback(
({ item, index }: { item: WorkspaceContentMatch; index: number }) => (
<ResultRow item={item} index={index} selected={state.activeIndex === index} select={select} />
),
[select, state.activeIndex],
);
const emptyState = useMemo(
() => <SearchState state={state} retry={model.retry} />,
[model.retry, state],
);
const limitNotice = useMemo(() => <ResultLimit limited={state.limited} />, [state.limited]);
return (
<View style={styles.body}>
{!compact || !previewPage ? (
<View style={[styles.results, compact && styles.full]}>
<FlatList
ref={listRef}
data={state.matches}
keyboardShouldPersistTaps="handled"
keyExtractor={resultKey}
getItemLayout={resultLayout}
renderItem={renderItem}
ListEmptyComponent={emptyState}
ListFooterComponent={limitNotice}
/>
</View>
) : null}
{!compact || previewPage ? (
<ContentPreview state={state} model={model} compact={compact} back={back} />
) : null}
</View>
);
}
function ResultRow({
item,
index,
selected,
select,
}: {
item: WorkspaceContentMatch;
index: number;
selected: boolean;
select(index: number): void;
}) {
const onPress = useCallback(() => select(index), [index, select]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
// Same hover/selection idiom as the Command Center's own result rows, one tint apart so the
// keyboard cursor that drives the preview stays distinguishable from the pointer.
const style = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(Boolean(hovered) || pressed) && styles.hovered,
selected && styles.selected,
],
[selected],
);
const line = useMemo(() => describeMatchLine(item), [item]);
const file = useMemo(() => describeResultPath(item.path), [item.path]);
const location = `${item.path}:${item.line}:${item.columnStart}`;
return (
// The resting row shows the file and its nearest parent, which two files can share
// (packages/app/src/utils/index.ts and packages/server/src/utils/index.ts). The tooltip is
// where the exact workspace-relative path lives; it is not bounded by the column's width.
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${location} ${item.snippet}`}
accessibilityState={accessibilityState}
aria-pressed={isWeb ? selected : undefined}
onPress={onPress}
style={style}
>
<Text style={styles.snippet} numberOfLines={1}>
{line.before}
<Text style={styles.match}>{line.match}</Text>
{line.after}
</Text>
{/* The name and coordinates are pinned; only the parent context may shrink, so a deep
path loses its middle rather than the identity the reader is scanning for. */}
<View style={styles.location}>
{file.directory ? (
<Text style={styles.locationDirectory} numberOfLines={1}>
{file.directory}
</Text>
) : null}
<Text style={styles.locationFile} numberOfLines={1}>
{file.name}:{item.line}:{item.columnStart}
</Text>
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" maxWidth={520} testID="content-search-row-path">
<Text style={styles.tooltipPath}>{location}</Text>
</TooltipContent>
</Tooltip>
);
}
/** The row is one line wide, so the match has to survive indentation and long-line offsets. */
function describeMatchLine(item: WorkspaceContentMatch) {
// Only a run-up long enough to be worth cutting is cut, so an ordinary line keeps its start.
const cut = item.snippetMatchStart > SNIPPET_LEAD * 2;
const from = cut ? item.snippetMatchStart - SNIPPET_LEAD : 0;
const before = item.snippet.slice(from, item.snippetMatchStart);
return {
before: cut ? `…${before}` : before.trimStart(),
match: item.snippet.slice(item.snippetMatchStart, item.snippetMatchEnd),
after: item.snippet.slice(item.snippetMatchEnd),
};
}
function resultKey(item: WorkspaceContentMatch) {
return `${item.path}:${item.line}:${item.columnStart}`;
}
function resultLayout(_data: ArrayLike<WorkspaceContentMatch> | null | undefined, index: number) {
return { length: RESULT_ROW_HEIGHT, offset: index * RESULT_ROW_HEIGHT, index };
}
/** Idle stays blank: the header placeholder already says what the field does. */
function SearchState({ state, retry }: { state: ContentSearchSnapshot; retry(): void }) {
const { t } = useTranslation();
if (state.status === "searching")
return (
<View style={styles.status} accessibilityLiveRegion="polite">
<ThemedSpinner size={14} />
<Text style={styles.muted}>{t("shell.commandCenter.searchingFiles")}</Text>
</View>
);
if (state.status === "error")
return (
<View style={styles.status} accessibilityLiveRegion="polite">
<Text style={[styles.muted, styles.error]}>{state.message}</Text>
<Button variant="outline" size="sm" onPress={retry}>
{t("common.actions.retry")}
</Button>
</View>
);
if (state.status === "ready")
return (
<View style={styles.empty}>
<Text style={styles.emptyText}>{t("shell.commandCenter.noMatches")}</Text>
{/* "No matches" alone claims the whole workspace was read. Files above the host's ceiling
are never opened, so the empty state has to say so where the claim is made. */}
{state.maxFileBytes ? (
<Text style={styles.emptyDetail}>
{t("shell.commandCenter.contentSkippedLargeFiles", {
size: formatFileSize(state.maxFileBytes),
})}
</Text>
) : null}
</View>
);
return null;
}
function ResultLimit({ limited }: { limited: boolean }) {
const { t } = useTranslation();
if (!limited) return null;
return <Text style={styles.limit}>{t("shell.commandCenter.contentLimited")}</Text>;
}
function ContentPreview({
state,
model,
compact,
back,
}: {
state: ContentSearchSnapshot;
model: WorkspaceContentSearchModel;
compact: boolean;
back(): void;
}) {
const { t } = useTranslation();
const match = state.matches[state.activeIndex];
const preview = state.preview;
const location = useMemo(
() =>
match
? {
path: match.path,
lineStart: match.line,
columnStart: match.columnStart,
columnEnd: match.columnEnd,
expectedText: match.text,
}
: null,
[match],
);
return (
<View style={styles.preview}>
{/* On desktop the selected row already names the file and Enter opens it, so the preview
carries no header. The compact flow pages away from the list and still needs both. */}
{compact && match ? (
<View style={styles.previewHeader}>
<Button variant="ghost" size="sm" onPress={back}>
{t("shell.commandCenter.contentBack")}
</Button>
<Text style={styles.previewPath} numberOfLines={1} ellipsizeMode="head">
{match.path}
</Text>
<Button variant="outline" size="sm" onPress={model.openSelected}>
{t("shell.commandCenter.contentOpen")}
</Button>
</View>
) : null}
{preview.status === "loading" ? (
<View
style={styles.status}
accessibilityRole="progressbar"
accessibilityLabel={t("shell.commandCenter.contentLoadingPreview")}
>
<ThemedSpinner size={14} />
</View>
) : null}
{preview.status === "error" ? (
<View style={styles.status} accessibilityLiveRegion="polite">
<Text style={[styles.muted, styles.error]}>{preview.message}</Text>
<Button variant="outline" size="sm" onPress={model.retryPreview}>
{t("common.actions.retry")}
</Button>
</View>
) : null}
{preview.status === "ready" && location ? (
<WorkspaceFileSource
content={preview.content}
filename={preview.path}
size={preview.size}
navigationRevision={state.activeIndex}
location={location}
/>
) : null}
</View>
);
}
// Denser than the Command Center's own two-line row: these results are scanned, not read, and a
// search wants as many occurrences on screen as stay legible.
const RESULT_ROW_HEIGHT = 44;
// Characters of the matched line kept ahead of the match itself.
const SNIPPET_LEAD = 24;
const styles = StyleSheet.create((theme) => ({
body: { flex: 1, minHeight: 0, flexDirection: "row" },
// A result row is two truncated single lines, so extra panel width buys the list nothing and
// buys the code preview everything: the column is a fixed reading width, capped so a narrow
// desktop panel cannot starve the preview.
results: {
width: 360,
maxWidth: "45%",
minHeight: 0,
borderRightWidth: 1,
borderRightColor: theme.colors.border,
},
full: { width: "100%", maxWidth: "100%", borderRightWidth: 0 },
row: {
height: RESULT_ROW_HEIGHT,
justifyContent: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[1],
},
hovered: { backgroundColor: theme.colors.surface1 },
selected: { backgroundColor: theme.colors.surface2 },
// Three levels: the match is what the reader is looking for, the rest of the line is the
// context it sits in, and the location is context for that.
snippet: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.code,
lineHeight: 17,
fontFamily: theme.fontFamily.mono,
},
match: {
color: theme.colors.foreground,
fontWeight: "600",
backgroundColor: theme.colors.terminal.selectionBackground,
},
location: { flexDirection: "row", alignItems: "baseline" },
locationFile: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 15,
},
locationDirectory: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
lineHeight: 15,
},
preview: { flex: 1, minWidth: 0, minHeight: 0 },
previewHeader: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
gap: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
previewPath: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
status: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[6],
},
muted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
error: { color: theme.colors.statusDanger },
tooltipPath: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: theme.fontFamily.mono,
},
empty: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[6],
gap: theme.spacing[1],
},
emptyText: {
textAlign: "center",
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
emptyDetail: {
textAlign: "center",
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
limit: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -1,234 +0,0 @@
import { expect, test } from "vitest";
import type { FileReadResult } from "@getpaseo/client";
import type {
WorkspaceContentMatch,
WorkspaceContentSearchResult,
} from "@getpaseo/protocol/messages";
import { WorkspaceContentSearchModel, type ContentSearchTransport } from "./model";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function match(path: string, text = "NEEDLE"): WorkspaceContentMatch {
return {
path,
line: 1,
columnStart: 1,
columnEnd: text.length + 1,
text,
snippet: text,
snippetMatchStart: 0,
snippetMatchEnd: text.length,
};
}
function result(matches: WorkspaceContentMatch[]): WorkspaceContentSearchResult {
return { status: "ok", matches, limited: false };
}
function file(path: string, text: string): FileReadResult {
return {
path,
kind: "text",
mime: "text/plain",
bytes: new TextEncoder().encode(text),
size: text.length,
modifiedAt: "2026-09-09",
};
}
test("obsolete queries cannot restore results and opening carries the original case-insensitive match", async () => {
const first = deferred<WorkspaceContentSearchResult>();
const second = deferred<WorkspaceContentSearchResult>();
const startedFirst = deferred<void>();
const startedSecond = deferred<void>();
const signals: AbortSignal[] = [];
const opened: unknown[] = [];
const transport: ContentSearchTransport = {
searchWorkspaceContent: ({ query }, { signal }) => {
signals.push(signal);
if (query === "old") {
startedFirst.resolve();
return first.promise;
}
startedSecond.resolve();
return second.promise;
},
readFile: async (path) => file(path, "NEEDLE"),
};
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
transport,
open: (location) => opened.push(location),
debounceMs: 0,
});
try {
model.setQuery("old");
await startedFirst.promise;
model.setQuery("needle");
await startedSecond.promise;
expect(signals[0].aborted).toBe(true);
second.resolve(result([match("a.ts")]));
await Promise.resolve();
await Promise.resolve();
first.resolve(result([match("obsolete.ts")]));
await Promise.resolve();
expect(model.getSnapshot()).toMatchObject({
query: "needle",
status: "ready",
matches: [match("a.ts")],
preview: { status: "ready", content: "NEEDLE" },
});
model.openSelected();
expect(opened).toEqual([
{ path: "a.ts", lineStart: 1, columnStart: 1, columnEnd: 7, expectedText: "NEEDLE" },
]);
} finally {
model.dispose();
}
});
test("selection bounds preview work to one read plus the latest target, and reads current saved content", async () => {
const started = deferred<void>();
const searched = deferred<WorkspaceContentSearchResult>();
const a = deferred<FileReadResult>();
const c = deferred<FileReadResult>();
const paths: string[] = [];
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
debounceMs: 0,
open() {},
transport: {
searchWorkspaceContent: () => {
started.resolve();
return searched.promise;
},
readFile: (_cwd, path) => {
paths.push(path);
return path === "a" ? a.promise : c.promise;
},
},
});
try {
model.setQuery("needle");
await started.promise;
searched.resolve(result([match("a"), match("b"), match("c")]));
await Promise.resolve();
model.select(1);
model.select(2);
expect(paths).toEqual(["a"]);
a.resolve(file("a", "NEEDLE"));
await Promise.resolve();
expect(paths).toEqual(["a", "c"]);
c.resolve(file("c", "changed"));
await Promise.resolve();
expect(model.getSnapshot().preview).toMatchObject({
status: "ready",
path: "c",
content: "changed",
});
} finally {
model.dispose();
}
});
test("a new query refreshes the saved preview even when its selected path is unchanged", async () => {
const reads: Array<ReturnType<typeof deferred<FileReadResult>>> = [];
const searches: Array<ReturnType<typeof deferred<WorkspaceContentSearchResult>>> = [];
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
debounceMs: 0,
open() {},
transport: {
searchWorkspaceContent: () => {
const pending = deferred<WorkspaceContentSearchResult>();
searches.push(pending);
return pending.promise;
},
readFile: () => {
const pending = deferred<FileReadResult>();
reads.push(pending);
return pending.promise;
},
},
});
try {
model.setQuery("first");
await expect.poll(() => searches.length).toBe(1);
searches[0].resolve(result([match("same.ts")]));
await Promise.resolve();
model.setQuery("second");
await expect.poll(() => searches.length).toBe(2);
searches[1].resolve(result([match("same.ts")]));
await Promise.resolve();
reads[0].resolve(file("same.ts", "old saved text"));
await Promise.resolve();
expect(reads).toHaveLength(2);
reads[1].resolve(file("same.ts", "NEEDLE current saved text"));
await Promise.resolve();
expect(model.getSnapshot().preview).toMatchObject({
status: "ready",
content: "NEEDLE current saved text",
});
} finally {
model.dispose();
}
});
test("textual image occurrences expose saved source without changing the read kind", async () => {
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
debounceMs: 0,
open() {},
transport: {
searchWorkspaceContent: async () => result([match("icon.svg")]),
readFile: async () => ({
...file("icon.svg", "<svg>NEEDLE</svg>"),
kind: "image",
mime: "image/svg+xml",
}),
},
});
try {
model.setQuery("needle");
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
expect(model.getSnapshot().preview).toMatchObject({ content: "<svg>NEEDLE</svg>" });
} finally {
model.dispose();
}
});
test("revisiting a file re-reads it, so a file deleted since the search stops previewing", async () => {
const reads: string[] = [];
let deleted = false;
const model = new WorkspaceContentSearchModel({
cwd: "/workspace",
debounceMs: 0,
open() {},
transport: {
searchWorkspaceContent: async () => result([match("a.ts"), match("b.ts")]),
readFile: async (_cwd, path) => {
reads.push(path);
if (path === "a.ts" && deleted) throw new Error("ENOENT: no such file");
return file(path, `NEEDLE in ${path}`);
},
},
});
try {
model.setQuery("needle");
await expect.poll(() => model.getSnapshot().preview.status).toBe("ready");
expect(reads).toEqual(["a.ts"]);
model.move(1);
await expect.poll(() => reads).toEqual(["a.ts", "b.ts"]);
deleted = true;
model.move(-1);
await expect.poll(() => model.getSnapshot().preview).toMatchObject({ status: "error" });
expect(reads).toEqual(["a.ts", "b.ts", "a.ts"]);
} finally {
model.dispose();
}
});
@@ -1,206 +0,0 @@
import type {
WorkspaceContentMatch,
WorkspaceContentSearchResult,
} from "@getpaseo/protocol/messages";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { FileReadResult } from "@getpaseo/client";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
export interface ContentSearchTransport {
searchWorkspaceContent(
input: { cwd: string; query: string },
options: { signal: AbortSignal },
): Promise<WorkspaceContentSearchResult>;
readFile(
cwd: string,
path: string,
requestId?: string,
maxBytes?: number,
): Promise<FileReadResult>;
}
type Preview =
| { status: "empty" | "loading" }
| { status: "error"; message: string }
| { status: "ready"; path: string; content: string; size: number };
export interface ContentSearchSnapshot {
query: string;
status: "idle" | "searching" | "ready" | "error";
message: string;
matches: WorkspaceContentMatch[];
limited: boolean;
/** Per-file byte ceiling the host applied, so an empty result can say what it did not read. */
maxFileBytes: number | null;
activeIndex: number;
preview: Preview;
}
/** The daemon refuses to read more, and rg refuses to search a file bigger than this. */
const MAX_PREVIEW_BYTES = 1_048_576;
export class WorkspaceContentSearchModel {
private snapshot: ContentSearchSnapshot = {
query: "",
status: "idle",
message: "",
matches: [],
limited: false,
maxFileBytes: null,
activeIndex: 0,
preview: { status: "empty" },
};
private listeners = new Set<() => void>();
private controller: AbortController | null = null;
private timer: ReturnType<typeof setTimeout> | undefined;
private disposed = false;
private reading = false;
private file: { path: string; content: string; size: number } | null = null;
constructor(
private readonly options: {
cwd: string;
transport: ContentSearchTransport;
open(location: WorkspaceFileLocation): void;
debounceMs?: number;
},
) {}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = () => this.snapshot;
private update(next: Partial<ContentSearchSnapshot>) {
this.snapshot = { ...this.snapshot, ...next };
for (const listener of this.listeners) listener();
}
setQuery = (query: string) => {
this.controller?.abort();
this.file = null;
clearTimeout(this.timer);
this.update({
query,
status: query ? "searching" : "idle",
message: "",
matches: [],
limited: false,
maxFileBytes: null,
activeIndex: 0,
preview: { status: "empty" },
});
if (!query || this.disposed) return;
const controller = new AbortController();
this.controller = controller;
this.timer = setTimeout(
() => void this.search(query, controller),
this.options.debounceMs ?? 100,
);
};
private async search(query: string, controller: AbortController) {
try {
const result = await this.options.transport.searchWorkspaceContent(
{ cwd: this.options.cwd, query },
{ signal: controller.signal },
);
if (this.disposed || controller.signal.aborted) return;
if (result.status === "error") {
this.update({ status: "error", message: result.message });
return;
}
this.update({
status: "ready",
matches: result.matches,
limited: result.limited,
maxFileBytes: result.maxFileBytes ?? null,
});
this.select(0);
} catch (error) {
if (!this.disposed && !controller.signal.aborted)
this.update({
status: "error",
message: error instanceof Error ? error.message : "File search failed",
});
}
}
select = (index: number) => {
const activeIndex = Math.max(0, Math.min(index, this.snapshot.matches.length - 1));
this.update({ activeIndex });
void this.preview();
};
move = (direction: number) =>
this.select(
(this.snapshot.activeIndex + direction + this.snapshot.matches.length) %
(this.snapshot.matches.length || 1),
);
private async preview() {
const match = this.snapshot.matches[this.snapshot.activeIndex];
if (!match) return;
// Only the file on screen is retained. Re-reading on every arrival is what lets the preview
// tell the truth about a file that changed or disappeared since the search ran; see the
// deleted-file recovery case in the browser spec before trading it for fewer reads.
if (this.file?.path === match.path) {
this.update({ preview: { status: "ready", ...this.file } });
return;
}
this.update({ preview: { status: "loading" } });
if (this.reading) return;
this.reading = true;
const path = match.path;
const query = this.controller;
try {
const file = await this.options.transport.readFile(
this.options.cwd,
path,
undefined,
MAX_PREVIEW_BYTES,
);
if (this.disposed) return;
if (query === this.controller) {
const content = explorerFileFromReadResult(file).content;
if (content === undefined) throw new Error("This file is no longer text");
this.file = { path, content, size: file.size };
}
} catch (error) {
if (
!this.disposed &&
query === this.controller &&
this.snapshot.matches[this.snapshot.activeIndex]?.path === path
) {
this.update({
preview: {
status: "error",
message: error instanceof Error ? error.message : "Preview unavailable",
},
});
return;
}
} finally {
this.reading = false;
}
if (!this.disposed) void this.preview();
}
retry = () => {
this.file = null;
this.setQuery(this.snapshot.query);
};
retryPreview = () => {
this.file = null;
void this.preview();
};
openSelected = () => {
const match = this.snapshot.matches[this.snapshot.activeIndex];
if (!match || this.snapshot.status !== "ready") return;
this.options.open({
path: match.path,
lineStart: match.line,
columnStart: match.columnStart,
columnEnd: match.columnEnd,
expectedText: match.text,
});
};
dispose = () => {
this.disposed = true;
this.controller?.abort();
clearTimeout(this.timer);
this.listeners.clear();
this.file = null;
};
}
@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { describeResultPath } from "./result-path";
describe("describeResultPath", () => {
it("keeps a root file whole", () => {
expect(describeResultPath("README.md")).toEqual({ directory: "", name: "README.md" });
});
it("keeps a shallow path whole", () => {
expect(describeResultPath("docs/architecture.md")).toEqual({
directory: "docs/",
name: "architecture.md",
});
});
it("elides the head of a deep path and keeps the distinguishing parent", () => {
// These two rows are both "index.tsx"; the parents are the only thing telling them apart.
expect(
describeResultPath("packages/app/src/command-center/workspace-content-search/index.tsx"),
).toEqual({ directory: "…/workspace-content-search/", name: "index.tsx" });
expect(describeResultPath("packages/app/src/file-pane/source/index.tsx")).toEqual({
directory: "…/source/",
name: "index.tsx",
});
});
it("keeps a literal backslash in a file name out of the directory", () => {
expect(describeResultPath("a\\b.txt")).toEqual({ directory: "", name: "a\\b.txt" });
});
});
@@ -1,25 +0,0 @@
/**
* How a result row names its file.
*
* A flat list of occurrences is full of repeated base names — four `index.tsx` rows tell the
* reader nothing. The nearest directories are what distinguish them, and they are also what an
* end-truncated path throws away first. So the row keeps the file name and the closest parents,
* and marks the elided head with a leading ellipsis.
*/
export interface ResultPathParts {
/** Directory context immediately above the file, already elided at the head when deeper. */
directory: string;
/** File name, which is never truncated away. */
name: string;
}
const PARENTS_KEPT = 1;
export function describeResultPath(path: string): ResultPathParts {
const segments = path.split("/");
const name = segments.pop() ?? path;
if (segments.length === 0) return { directory: "", name };
const kept = segments.slice(-PARENTS_KEPT);
const elided = segments.length > kept.length;
return { directory: `${elided ? "…/" : ""}${kept.join("/")}/`, name };
}
@@ -4,12 +4,12 @@ export interface WorkspaceFileSearchEntry {
directory: string;
}
/** Splits a workspace-relative path the host produced, where "/" is the only separator. */
export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry {
const separator = path.lastIndexOf("/");
const normalized = path.replace(/\\/g, "/");
const separator = normalized.lastIndexOf("/");
return {
path,
name: separator >= 0 ? path.slice(separator + 1) : path,
directory: separator >= 0 ? path.slice(0, separator) : "",
path: normalized,
name: separator >= 0 ? normalized.slice(separator + 1) : normalized,
directory: separator >= 0 ? normalized.slice(0, separator) : "",
};
}
@@ -10,11 +10,11 @@ describe("describeWorkspaceFilePath", () => {
});
});
it("treats a backslash as part of the file name, because the host already sent separators as /", () => {
expect(describeWorkspaceFilePath("a\\b.txt")).toEqual({
path: "a\\b.txt",
name: "a\\b.txt",
directory: "",
it("normalizes Windows separators before opening or presenting a file", () => {
expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({
path: "src/components/message.tsx",
name: "message.tsx",
directory: "src/components",
});
});
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
@@ -128,8 +128,8 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
(path: string) => {
if (!serverId || !workspaceId) return;
clearCommandCenterFocusRestoreElement();
openWorkspaceFileInFocusedPane({
location: { path },
openWorkspaceFileFromExplorer({
filePath: path,
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
closeExplorerAfterOpen: true,
showMobileAgent: usePanelStore.getState().showMobileAgent,
@@ -141,7 +141,6 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
placement,
}),
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
requestFileNavigation: useWorkspaceLayoutStore.getState().requestFileNavigation,
});
},
[serverId, workspaceId],
@@ -1,4 +1,3 @@
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -328,30 +327,10 @@ export function useWorkspaceCommandCenterActions(): void {
],
);
const allActions = useMemo(
() => [
...actions,
{
id: "workspace-content-search",
group: "actions" as const,
groupRank: 0,
rank: 10,
visibility: "query" as const,
keywords: ["search", "find", "contents", "files", "text"],
run: () => useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "content"),
presentation: {
kind: "action" as const,
title: t("shell.commandCenter.contentTitle"),
sectionTitle: t("shell.commandCenter.actions"),
},
},
],
[actions, t],
);
useCommandCenterActions({
sourceId: "workspace",
enabled: Boolean(serverId && cwd),
actions: allActions,
actions,
});
}
@@ -14,7 +14,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
import {
@@ -119,7 +119,6 @@ export function CompactExplorerSidebarHost({
[openTab],
);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
const handleOpenExplorer = useCallback(() => {
if (!model?.workspaceRoot) {
@@ -137,24 +136,16 @@ export function CompactExplorerSidebarHost({
if (!model) {
return;
}
openWorkspaceFileInFocusedPane({
location: { path: filePath },
openWorkspaceFileFromExplorer({
filePath,
persistenceKey: model.persistenceKey,
closeExplorerAfterOpen: presentation === "overlay",
showMobileAgent,
openWorkspaceTabInFocusedPane,
focusWorkspaceTab,
requestFileNavigation,
});
},
[
focusWorkspaceTab,
model,
openWorkspaceTabInFocusedPane,
presentation,
requestFileNavigation,
showMobileAgent,
],
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, presentation, showMobileAgent],
);
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
@@ -80,8 +80,11 @@ function QuestionOptionRow({
);
const optionLabelStyle = useMemo(
() => [styles.optionLabel, { color: theme.colors.foreground }],
[theme.colors.foreground],
() => [
styles.optionLabel,
{ color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted },
],
[isSelected, theme.colors.foreground, theme.colors.foregroundMuted],
);
const optionDescriptionStyle = useMemo(
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
@@ -96,11 +99,11 @@ function QuestionOptionRow({
styles.selectionControl,
multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio,
{
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted,
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundExtraMuted,
backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent",
},
],
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted],
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundExtraMuted],
);
const radioDotStyle = useMemo(
() => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }],
@@ -628,7 +631,7 @@ const styles = StyleSheet.create((theme) => ({
questionText: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.normal,
lineHeight: 22,
},
optionsWrap: {
@@ -653,7 +656,7 @@ const styles = StyleSheet.create((theme) => ({
},
questionNavText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
fontWeight: theme.fontWeight.normal,
},
optionItem: {
flexDirection: "row",
@@ -677,7 +680,7 @@ const styles = StyleSheet.create((theme) => ({
},
optionLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
fontWeight: theme.fontWeight.normal,
lineHeight: 22,
},
optionDescription: {
@@ -0,0 +1,35 @@
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload, CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { encodeImages } from "@/utils/encode-images";
import type { UserMessageImageAttachment } from "@/types/stream";
export interface WorkspaceDraftAgentRequest {
workspaceId: string;
config: AgentSessionConfig;
text: string;
clientMessageId: string;
images?: UserMessageImageAttachment[];
attachments?: CreateAgentRequestMessage["attachments"];
}
/**
* Shared by the workspace draft tab and by the new-workspace screen when it finishes creation
* after the user has already navigated away and no draft tab will ever mount.
*/
export async function requestWorkspaceDraftAgent(
client: DaemonClient,
request: WorkspaceDraftAgentRequest,
): Promise<AgentSnapshotPayload> {
const images = await encodeImages(request.images);
return await client.createAgent({
config: request.config,
workspaceId: request.workspaceId,
clientMessageId: request.clientMessageId,
...(request.text ? { initialPrompt: request.text } : {}),
...(images && images.length > 0 ? { images } : {}),
...(request.attachments && request.attachments.length > 0
? { attachments: request.attachments }
: {}),
});
}
@@ -24,7 +24,7 @@ import type { Agent } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
import { encodeImages } from "@/utils/encode-images";
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import {
@@ -194,15 +194,14 @@ async function submitDraftCreateRequest(input: {
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
});
const imagesData = await encodeImages(images);
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const result = await client.createAgent({
const result = await requestWorkspaceDraftAgent(client, {
config,
workspaceId,
...(text ? { initialPrompt: text } : {}),
text,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
...(images ? { images } : {}),
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
});
return {
@@ -72,9 +72,3 @@ describe("resolveFilePreviewReadTarget", () => {
).toBeNull();
});
});
it("preserves literal whitespace for a source read target", () => {
expect(
resolveFilePreviewReadTarget({ path: " leading.txt ", workspaceRoot: "/workspace " }),
).toEqual({ cwd: "/workspace ", path: " leading.txt " });
});
@@ -61,7 +61,7 @@ export function resolveFilePreviewReadTarget(input: {
path: string;
workspaceRoot?: string;
}): FilePreviewReadTarget | null {
const previewPath = input.path;
const previewPath = input.path.trim();
if (!previewPath) {
return null;
}
@@ -73,7 +73,7 @@ export function resolveFilePreviewReadTarget(input: {
};
}
const workspaceRoot = input.workspaceRoot;
const workspaceRoot = input.workspaceRoot?.trim();
if (!isAbsolutePath(previewPath)) {
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
return null;
@@ -32,20 +32,3 @@ describe("explorerFileFromReadResult", () => {
expect(embedded.hasBom).toBe(false);
});
});
it("keeps SVG image identity and exposes valid UTF-8 source for occurrence navigation", () => {
const file = {
...textRead(new TextEncoder().encode("<svg>needle</svg>")),
kind: "image" as const,
mime: "image/svg+xml",
path: "icon.svg",
};
expect(explorerFileFromReadResult(file)).toMatchObject({
kind: "image",
content: "<svg>needle</svg>",
});
expect(explorerFileFromReadResult({ ...file, bytes: new Uint8Array([255]) })).toMatchObject({
kind: "image",
content: undefined,
});
});
+2 -11
View File
@@ -2,21 +2,12 @@ import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { ExplorerFile } from "@/stores/session-store";
export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile {
// SVG keeps its normal image presentation while exposing its UTF-8 source to line targets.
const isText = file.kind === "text" || file.mime === "image/svg+xml";
let content: string | undefined;
if (isText) {
try {
content = new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
} catch (error) {
if (file.kind === "text") throw error;
}
}
const isText = file.kind === "text";
return {
path: file.path,
kind: file.kind,
encoding: isText ? "utf-8" : "none",
content,
content: isText ? new TextDecoder().decode(file.bytes) : undefined,
hasBom: isText && hasUtf8Bom(file.bytes),
mimeType: file.mime,
size: file.size,
+6 -1
View File
@@ -7,7 +7,6 @@ import { SegmentedControl } from "@/components/ui/segmented-control";
import type { Theme } from "@/styles/theme";
import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert";
import type { FileEditorStatus } from "./editor/model";
import { formatFileSize } from "@/utils/format-file-size";
const ThemedSpinner = withUnistyles(LoadingSpinner);
const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -116,6 +115,12 @@ export function FilePanelBar({
);
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const styles = StyleSheet.create((theme) => ({
chrome: {
flexShrink: 0,
@@ -5,6 +5,7 @@ import {
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { searchKeymap } from "@codemirror/search";
import {
EditorView,
drawSelection,
@@ -41,6 +42,7 @@ export function editorBaseExtensions(onSave: () => void) {
indentWithTab,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
]),
];
}
@@ -61,13 +63,6 @@ export function editorTheme(theme: EditorVisualTheme) {
fontFamily: theme.monoFont,
lineHeight: "1.45",
},
".cm-panels": { backgroundColor: theme.background, color: theme.foreground },
".cm-panels-top": { borderBottom: "none" },
".cm-searchMatch": { backgroundColor: theme.selection },
".cm-searchMatch-selected": {
backgroundColor: theme.selection,
outline: `1px solid ${theme.cursor}`,
},
".cm-content": { caretColor: theme.foreground, padding: "16px 0" },
".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor },
".cm-gutters": {
+16 -36
View File
@@ -1,12 +1,10 @@
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { FileFind, FileFindModel } from "../find/index.web";
import { useEffect, useRef, useSyncExternalStore } from "react";
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { getCM, vim } from "@replit/codemirror-vim";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { FileEditorModel } from "./model";
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
@@ -40,8 +38,6 @@ export function FileEditorView({
onCursorChange,
onVimModeChange,
}: FileEditorViewProps) {
const { t } = useTranslation();
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
@@ -58,7 +54,6 @@ export function FileEditorView({
doc: values.content,
extensions: [
vimCompartment.of(values.vimEnabled ? vim() : []),
find.extension,
...editorBaseExtensions(() => void values.model.save()),
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
wrappingCompartment.of(wrappingForFile(values.filename)),
@@ -86,7 +81,7 @@ export function FileEditorView({
view.destroy();
viewRef.current = null;
};
}, [find]);
}, []);
useEffect(() => {
const view = viewRef.current;
@@ -104,12 +99,15 @@ export function FileEditorView({
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
const from = view.state.doc.line(lineStart).from;
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
view.dispatch({
selection: { anchor: from, head: to },
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
effects: EditorView.scrollIntoView(from, { y: "center" }),
});
}, [location, navigationRevision]);
}, [location.lineEnd, location.lineStart, navigationRevision]);
useEffect(() => {
viewRef.current?.dispatch({
@@ -142,34 +140,16 @@ export function FileEditorView({
return () => cm.off("vim-mode-change", handleModeChange);
}, [onVimModeChange, vimEnabled]);
const changed = resolveWorkspaceFileSelection(
snapshot.content.replace(/\r\n?/g, "\n"),
location,
).changed;
return (
<div style={FRAME_STYLE}>
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
<FileFind model={find} editor={viewRef} />
</div>
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
);
}
const remoteUpdate = Annotation.define<boolean>();
// The Find widget floats inside this frame, so it stays relative; the change notice is the
// only row that shares the column with the editor host.
const FRAME_STYLE = {
display: "flex",
flexDirection: "column",
position: "relative",
flex: 1,
minHeight: 0,
minWidth: 0,
} as const;
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
@@ -1,117 +0,0 @@
import {
useCallback,
useMemo,
useEffect,
useRef,
useSyncExternalStore,
type RefObject,
} from "react";
import { EditorView } from "@codemirror/view";
import { View, type View as ViewInstance } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { usePaneFocusIfAny } from "@/panels/pane-context";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { FileFindModel } from "./model.web";
export { FileFindModel } from "./model.web";
export function FileFind({
model,
editor,
}: {
model: FileFindModel;
editor: RefObject<EditorView | null>;
}) {
const { t } = useTranslation();
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const widget = useRef<PaneFindHandle>(null);
// A source view can also render outside any pane, such as the Command Center content
// preview. Pane Find belongs to the pane, so there is nothing to open there.
const pane = usePaneFocusIfAny();
const active = useRetainedPanelActive();
// RN Web hands the underlying DOM element to a View ref; the model measures it.
const setWidgetNode = useCallback(
(node: ViewInstance | null) => model.setWidgetNode(node as unknown as HTMLElement | null),
[model],
);
useEffect(() => {
if (!pane?.isInteractive || !active) return;
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f"
) {
event.preventDefault();
model.open(editor.current);
widget.current?.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [active, editor, model, pane?.isInteractive]);
const replace = useMemo(
() =>
state.readOnly
? undefined
: {
value: state.replacement,
onChange: model.setReplacement,
onReplace: model.replace,
onReplaceAll: model.replaceAll,
},
[model, state.readOnly, state.replacement],
);
if (!pane || !state.open) return null;
const total = `${state.total}${state.limited ? "+" : ""}`;
let status = "";
if (state.query) {
if (state.total === 0) status = t("paneFind.noMatches");
else if (state.current) status = t("paneFind.position", { current: state.current, total });
else status = t("paneFind.total", { total });
}
return (
<View
style={[styles.overlay, state.placement === "top" ? styles.overlayTop : styles.overlayBottom]}
pointerEvents="box-none"
>
<View ref={setWidgetNode} style={styles.widget}>
<PaneFind
ref={widget}
query={state.query}
status={status}
canNavigate={state.total > 0}
onQueryChange={model.setSearch}
onNext={model.next}
onPrevious={model.previous}
onClose={model.close}
replace={replace}
/>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
// The widget floats over the content; the model flips the corner when it would
// otherwise sit on top of the active match.
overlay: {
position: "absolute",
left: theme.spacing[2],
right: theme.spacing[2],
alignItems: "flex-end",
zIndex: 1,
},
// Bounds the widget to the pane; PaneFind's own maxWidth resolves against this.
widget: { maxWidth: "100%" },
overlayTop: { top: theme.spacing[2] },
overlayBottom: { bottom: theme.spacing[2] },
}));
@@ -1,255 +0,0 @@
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView, keymap, type Panel, type ViewUpdate } from "@codemirror/view";
import {
closeSearchPanel,
findNext,
findPrevious,
getSearchQuery,
openSearchPanel,
replaceAll,
replaceNext,
search,
searchKeymap,
SearchQuery,
setSearchQuery,
} from "@codemirror/search";
const MATCH_STATUS_LIMIT = 10_000;
/** Which corner of the editor the floating Find widget occupies. */
export type FindPlacement = "top" | "bottom";
/** CodeMirror owns query, matching, selection, replacement, and panel lifetime. */
export class FileFindModel {
private view: EditorView | null = null;
private listeners = new Set<() => void>();
private matches: Array<{ from: number; to: number }> = [];
private widget: HTMLElement | null = null;
private widgetResize: ResizeObserver | null = null;
private snapshot = {
open: false,
placement: "top" as FindPlacement,
query: "",
replacement: "",
current: 0,
total: 0,
limited: false,
readOnly: true,
};
readonly subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
readonly getSnapshot = () => this.snapshot;
readonly extension: Extension = [
// The widget floats over the editor as a sibling overlay, so this panel is an
// inert placeholder that only reports whether Find is open. Hide the placeholder
// itself, never the shared panel container — Go to line's dialog lives there too.
EditorView.theme({ ".cm-panel.paseo-file-find": { display: "none" } }),
// Reveal matches clear of the widget instead of underneath it.
EditorView.scrollMargins.of((view) => {
const clearance = this.clearance(view);
if (!clearance) return null;
return this.snapshot.placement === "top" ? { top: clearance } : { bottom: clearance };
}),
search({ literal: true, top: true, createPanel: (view) => this.createPanel(view) }),
EditorState.transactionExtender.of((transaction) => {
// CodeMirror seeds every search-opening command from the selection. A
// single-line field cannot represent CR/LF, so keep the previous query.
const multilineSeed = transaction.effects.some(
(effect) => effect.is(setSearchQuery) && /[\r\n]/.test(effect.value.search),
);
if (!multilineSeed) return null;
return { effects: setSearchQuery.of(getSearchQuery(transaction.startState)) };
}),
keymap.of(searchKeymap.filter((binding) => binding.key !== "Mod-f")),
];
readonly open = (view = this.view) => {
if (view) openSearchPanel(view);
};
readonly close = () => {
if (this.view) {
closeSearchPanel(this.view);
this.view.focus();
}
};
readonly next = () => {
if (this.view) findNext(this.view);
};
readonly previous = () => {
if (this.view) findPrevious(this.view);
};
readonly replace = () => {
if (this.view) replaceNext(this.view);
};
readonly replaceAll = () => {
if (this.view) replaceAll(this.view);
};
readonly setReplacement = (replacement: string) =>
this.setQuery(this.snapshot.query, replacement);
readonly setSearch = (query: string) => {
this.setQuery(query, this.snapshot.replacement);
if (this.view && this.matches.length > 0) {
this.view.dispatch({ selection: { anchor: this.view.state.selection.main.from } });
findNext(this.view);
}
};
private setQuery(query: string, replacement: string) {
this.view?.dispatch({
effects: setSearchQuery.of(
new SearchQuery({ search: query, replace: replacement, literal: true }),
),
});
}
/**
* The widget renders outside CodeMirror, so the editor cannot see it. Handing the
* node here lets this model — which already owns reveal — measure the obstruction.
*/
readonly setWidgetNode = (node: HTMLElement | null) => {
if (this.widget === node) return;
this.widgetResize?.disconnect();
this.widgetResize = null;
this.widget = node;
if (!node) return;
this.widgetResize = new ResizeObserver(() => this.reposition());
this.widgetResize.observe(node);
this.reposition();
};
private createPanel(view: EditorView): Panel {
this.view = view;
const dom = document.createElement("div");
dom.className = "paseo-file-find";
return {
dom,
top: true,
mount: () => {
this.snapshot = { ...this.snapshot, open: true, placement: "top" };
this.update(view, true);
},
update: (update: ViewUpdate) => {
const queryChanged = !getSearchQuery(update.startState).eq(getSearchQuery(update.state));
if (
update.docChanged ||
update.selectionSet ||
queryChanged ||
update.geometryChanged ||
update.viewportChanged
)
this.update(view, update.docChanged || queryChanged);
},
destroy: () => {
this.snapshot = { ...this.snapshot, open: false, placement: "top" };
this.publish();
},
};
}
/** Vertical space the widget takes out of the editor, including its inset. */
private clearance(view: EditorView): number {
const widget = this.widget;
if (!widget || !this.snapshot.open) return 0;
const box = widget.getBoundingClientRect();
if (!box.height) return 0;
const editor = view.scrollDOM.getBoundingClientRect();
const clearance =
this.snapshot.placement === "top" ? box.bottom - editor.top : editor.bottom - box.top;
return Math.max(0, clearance);
}
/**
* Move the widget to the opposite corner when it sits on top of the active match
* and the editor has no scroll range left to reveal it. Placement only changes
* when the current corner collides, so navigating matches never makes it bounce.
*
* Measurement runs through `requestMeasure` because CodeMirror refuses layout
* reads while an update is in progress, and every caller here sits inside one.
*/
private reposition() {
const view = this.view;
if (!view || !this.widget || !this.snapshot.open) return;
view.requestMeasure<FindPlacement>({
key: this,
read: () => this.measurePlacement(view),
write: (placement) => {
if (placement === this.snapshot.placement) return;
this.snapshot = { ...this.snapshot, placement };
this.publish();
},
});
}
private measurePlacement(view: EditorView): FindPlacement {
const current = this.snapshot.placement;
const widget = this.widget;
if (!widget) return current;
const selection = view.state.selection.main;
if (selection.empty) return current;
const from = view.coordsAtPos(selection.from);
const to = view.coordsAtPos(selection.to);
if (!from || !to) return current;
const clearance = this.clearance(view);
if (!clearance) return current;
const box = widget.getBoundingClientRect();
const editor = view.scrollDOM.getBoundingClientRect();
const match = {
top: Math.min(from.top, to.top),
bottom: Math.max(from.bottom, to.bottom),
right: Math.max(from.right, to.right),
};
const hits = (placement: FindPlacement) => {
if (match.right <= box.left) return false;
return placement === "top"
? match.top < editor.top + clearance && match.bottom > editor.top
: match.bottom > editor.bottom - clearance && match.top < editor.bottom;
};
if (!hits(current)) return current;
const other: FindPlacement = current === "top" ? "bottom" : "top";
return hits(other) ? current : other;
}
private update(view: EditorView, recount: boolean) {
const query = getSearchQuery(view.state);
let limited = this.snapshot.limited;
if (recount) {
this.matches = [];
limited = false;
if (query.valid) {
const cursor = query.getCursor(view.state);
for (let match = cursor.next(); !match.done; match = cursor.next()) {
if (this.matches.length === MATCH_STATUS_LIMIT) {
limited = true;
break;
}
this.matches.push(match.value);
}
}
}
const selection = view.state.selection.main;
const current =
this.matches.findIndex(
(match) => match.from === selection.from && match.to === selection.to,
) + 1;
this.snapshot = {
...this.snapshot,
query: query.search,
replacement: query.replace,
current,
total: this.matches.length,
limited,
readOnly: view.state.readOnly,
};
this.publish();
this.reposition();
}
private publish() {
for (const listener of this.listeners) listener();
}
}
+44 -8
View File
@@ -33,14 +33,13 @@ import { FileMarkdownPreview } from "./markdown-preview";
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
import { createFileObservationSource } from "./editor/observation-source";
import { FileEditorView } from "./editor/view";
import { WorkspaceFileSource } from "./source";
import { FileSourceView } from "./source/view";
import type { FileConflictAlertState } from "./conflict-alert";
import type { LiveFileModel } from "./live-file/model";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
import { ZoomableImage } from "@/components/zoomable-viewport/image";
import { formatFileSize } from "@/utils/format-file-size";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -59,6 +58,24 @@ interface FilePreviewBodyProps {
type TextExplorerFile = ExplorerFile & { kind: "text" };
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function ReadonlySource({
preview,
filename,
@@ -70,13 +87,32 @@ function ReadonlySource({
location: WorkspaceFileLocation;
navigationRevision: number;
}) {
const theme = UnistylesRuntime.getTheme();
const { t } = useTranslation();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[theme],
);
return (
<WorkspaceFileSource
<FileSourceView
content={preview.content ?? ""}
filename={filename}
location={location}
navigationRevision={navigationRevision}
size={preview.size}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
@@ -86,7 +122,7 @@ function TooLargeSource({ size }: { size?: number }) {
return (
<View style={styles.centerState} testID="file-source-too-large">
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
{size ? <Text style={styles.binaryMetaText}>{formatFileSize(size)}</Text> : null}
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
</View>
);
}
@@ -128,7 +164,7 @@ function FilePreviewBody({
);
}
if (preview.kind === "text" || (location.lineStart && preview.content !== undefined)) {
if (preview.kind === "text") {
if (renderKind === "html") {
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
return (
@@ -178,7 +214,7 @@ function FilePreviewBody({
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize(preview.size)}</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
}
@@ -203,8 +239,8 @@ export function FilePane({
const supportsEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
);
const normalizedWorkspaceRoot = workspaceRoot;
const normalizedFilePath = location.path || null;
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
() =>
normalizedFilePath
@@ -1,39 +0,0 @@
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { UnistylesRuntime } from "react-native-unistyles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { FileSourceView } from "./view";
/** Read-only source preview. The source module owns platform rendering and its theme. */
export function WorkspaceFileSource(props: {
content: string;
filename: string;
location: WorkspaceFileLocation;
navigationRevision: number;
size: number;
}) {
const { t } = useTranslation();
const theme = UnistylesRuntime.getTheme();
const visualTheme = useMemo(
() => ({
colorScheme: theme.colorScheme,
background: theme.colors.surface0,
foreground: theme.colors.foreground,
cursor: theme.colors.terminal.cursor,
foregroundMuted: theme.colors.foregroundMuted,
border: theme.colors.border,
selection: theme.colors.terminal.selectionBackground,
monoFont: theme.fontFamily.mono,
codeFontSize: theme.fontSize.code,
syntax: theme.colors.syntax,
}),
[theme],
);
return (
<FileSourceView
{...props}
theme={visualTheme}
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
/>
);
}
@@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View } from "react-native";
import { useEffect, useMemo, useRef } from "react";
import { FlatList, Text, View, type ListRenderItem } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
@@ -59,16 +58,14 @@ function VirtualizedSource({
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
presentation: "highlighted" | "plain";
}) {
const { t } = useTranslation();
const listRef = useRef<FlatList<SourceLine>>(null);
const lines = useMemo(() => {
const source = content.replace(/\r\n?/g, "\n");
if (presentation === "highlighted")
return highlightCode(source, filename).map((tokens, index) => ({
return highlightCode(content, filename).map((tokens, index) => ({
number: index + 1,
tokens,
}));
return source
return content
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
}, [content, filename, presentation]);
@@ -80,63 +77,29 @@ function VirtualizedSource({
viewPosition: 0.5,
});
}, [lines.length, location.lineStart, navigationRevision]);
const selectedLine = lines[(location.lineStart ?? 1) - 1]?.tokens
.map((token) => token.text)
.join("");
const changed =
location.expectedText !== undefined &&
selectedLine?.slice((location.columnStart ?? 1) - 1, (location.columnEnd ?? 1) - 1) !==
location.expectedText;
const renderLine = useCallback(
({ item }: { item: SourceLine }) => (
<SourceLineView
line={item}
start={!changed && item.number === location.lineStart ? location.columnStart : undefined}
end={!changed && item.number === location.lineStart ? location.columnEnd : undefined}
/>
),
[changed, location],
);
return (
<View style={styles.root}>
{changed ? (
<Text accessibilityRole="alert" style={styles.notice}>
{t("shell.commandCenter.contentChanged")}
</Text>
) : null}
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
extraData={location}
renderItem={renderLine}
/>
</View>
<FlatList
ref={listRef}
data={lines}
keyExtractor={sourceLineKey}
initialNumToRender={24}
windowSize={9}
getItemLayout={sourceLineLayout}
renderItem={renderSourceLine}
/>
);
}
function SourceLineView({ line, start, end }: { line: SourceLine; start?: number; end?: number }) {
let offset = 0;
function SourceLineView({ line }: { line: SourceLine }) {
return (
<View style={styles.line}>
<Text style={styles.gutter}>{line.number}</Text>
<Text selectable style={styles.text}>
{line.tokens.map((token) => {
const tokenStart = offset;
offset += token.text.length;
const from = Math.max(0, Math.min(token.text.length, (start ?? 1) - 1 - tokenStart));
const to = Math.max(from, Math.min(token.text.length, (end ?? 1) - 1 - tokenStart));
return (
<Text key={`${tokenStart}:${token.style}`} style={syntaxTokenStyleFor(token.style)}>
{token.text.slice(0, from)}
<Text style={styles.match}>{token.text.slice(from, to)}</Text>
{token.text.slice(to)}
</Text>
);
})}
{line.tokens.map((token) => (
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
{token.text}
</Text>
))}
</Text>
</View>
);
@@ -150,14 +113,9 @@ function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index
return { length: 20, offset: index * 20, index };
}
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
const styles = StyleSheet.create((theme) => ({
root: { flex: 1 },
notice: {
padding: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
match: { backgroundColor: theme.colors.terminal.selectionBackground },
unsupported: {
flex: 1,
alignItems: "center",
+9 -41
View File
@@ -1,10 +1,8 @@
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useState } from "react";
import { FileFind, FileFindModel } from "../find/index.web";
import { useEffect, useRef } from "react";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, drawSelection } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { editorTheme } from "../editor/extensions.web";
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
@@ -61,8 +59,6 @@ function ReadonlyCodeMirror({
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
const { t } = useTranslation();
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const initial = useRef({ content, filename, presentation, theme });
@@ -75,15 +71,7 @@ function ReadonlyCodeMirror({
state: EditorState.create({
doc: values.content,
extensions: [
find.extension,
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({
tabindex: "0",
"aria-label": `Source for ${values.filename}`,
}),
// The preview is never focused, so the browser draws no selection of its own; the
// exact occurrence has to be painted by CodeMirror.
drawSelection(),
EditorView.editable.of(false),
languageCompartment.of(
languageFor({ filename: values.filename, presentation: values.presentation }),
@@ -97,7 +85,7 @@ function ReadonlyCodeMirror({
view.destroy();
viewRef.current = null;
};
}, [find]);
}, []);
useEffect(() => {
const view = viewRef.current;
@@ -117,22 +105,12 @@ function ReadonlyCodeMirror({
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
view.dispatch({
selection: { anchor: from, head: to },
// A match far into a long line lands at the scroller edge without the horizontal centre.
effects: EditorView.scrollIntoView(from, { x: "center", y: "center" }),
});
}, [content, location, navigationRevision]);
const line = Math.min(location.lineStart, view.state.doc.lines);
const from = view.state.doc.line(line).from;
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
}, [location.lineStart, navigationRevision]);
const changed = resolveWorkspaceFileSelection(content.replace(/\r\n?/g, "\n"), location).changed;
return (
<div style={FRAME_STYLE}>
{changed ? <div role="status">{t("shell.commandCenter.contentChanged")}</div> : null}
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
<FileFind model={find} editor={viewRef} />
</div>
);
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
}
function languageFor(input: {
@@ -144,16 +122,6 @@ function languageFor(input: {
: [];
}
// The Find widget floats inside this frame, so it stays relative; the change notice is the
// only row that shares the column with the editor host.
const FRAME_STYLE = {
display: "flex",
flexDirection: "column",
position: "relative",
flex: 1,
minHeight: 0,
minWidth: 0,
} as const;
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
const UNSUPPORTED_STYLE = {
alignItems: "center",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
paneFind: {
title: "بحث",
placeholder: "بحث في اللوحة",
close: "إغلاق البحث",
matches: "نتائج البحث",
previous: "التطابق السابق",
next: "التطابق التالي",
toggleReplace: "إظهار الاستبدال",
replaceWith: "استبدال بـ",
replace: "استبدال",
replaceAll: "استبدال الكل",
noMatches: "لا توجد تطابقات",
position: "{{current}} من {{total}}",
total: "{{total}} تطابقات",
},
common: {
back: "خلف",
loading: "تحميل...",
@@ -73,18 +58,6 @@ export const ar: TranslationResources = {
close: "إغلاق القائمة",
},
commandCenter: {
contentTitle: "البحث في محتوى الملفات",
content: "محتوى الملفات",
contentPlaceholder: "البحث في الملفات المحفوظة...",
contentUnavailable: "مساحة العمل غير متاحة",
contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات",
contentBack: "العودة إلى النتائج",
contentOpen: "فتح",
contentLoadingPreview: "جارٍ تحميل المعاينة...",
contentLimited: "النتائج محدودة — حدّد البحث أكثر",
contentSkippedLargeFiles: "لم يتم البحث في الملفات الأكبر من {{size}}",
contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع",
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
filePlaceholder: "البحث في الملفات...",
searchingFiles: "جارٍ البحث في الملفات...",
-27
View File
@@ -1,19 +1,4 @@
export const en = {
paneFind: {
title: "Find",
placeholder: "Find in pane",
close: "Close Find",
matches: "Find matches",
previous: "Previous match",
next: "Next match",
toggleReplace: "Toggle replace",
replaceWith: "Replace with",
replace: "Replace",
replaceAll: "Replace all",
noMatches: "No matches",
position: "{{current}} of {{total}}",
total: "{{total}} matches",
},
common: {
back: "Back",
loading: "Loading...",
@@ -70,18 +55,6 @@ export const en = {
close: "Close menu",
},
commandCenter: {
contentTitle: "Search file contents",
content: "File contents",
contentPlaceholder: "Search saved file contents...",
contentUnavailable: "Workspace unavailable",
contentUpdateHost: "Update this host to search file contents",
contentBack: "Back to results",
contentOpen: "Open",
contentLoadingPreview: "Loading preview...",
contentLimited: "Results limited — refine your search",
contentSkippedLargeFiles: "Files over {{size}} were not searched",
contentChanged: "File changed — saved match is no longer at this location",
placeholder: "Search commands, files, workspaces, and agents...",
filePlaceholder: "Search files...",
searchingFiles: "Searching files...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = {
paneFind: {
title: "Buscar",
placeholder: "Buscar en el panel",
close: "Cerrar búsqueda",
matches: "Coincidencias",
previous: "Coincidencia anterior",
next: "Siguiente coincidencia",
toggleReplace: "Mostrar reemplazo",
replaceWith: "Reemplazar con",
replace: "Reemplazar",
replaceAll: "Reemplazar todo",
noMatches: "Sin coincidencias",
position: "{{current}} de {{total}}",
total: "{{total}} coincidencias",
},
common: {
back: "Atrás",
loading: "Cargando...",
@@ -73,18 +58,6 @@ export const es: TranslationResources = {
close: "Cerrar menú",
},
commandCenter: {
contentTitle: "Buscar contenido de archivos",
content: "Contenido de archivos",
contentPlaceholder: "Buscar contenido guardado...",
contentUnavailable: "Espacio de trabajo no disponible",
contentUpdateHost: "Actualiza este host para buscar contenido",
contentBack: "Volver a resultados",
contentOpen: "Abrir",
contentLoadingPreview: "Cargando vista previa...",
contentLimited: "Resultados limitados — precisa la búsqueda",
contentSkippedLargeFiles: "No se buscó en archivos de más de {{size}}",
contentChanged: "El archivo cambió — la coincidencia ya no está aquí",
placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...",
filePlaceholder: "Buscar archivos...",
searchingFiles: "Buscando archivos...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = {
paneFind: {
title: "Rechercher",
placeholder: "Rechercher dans le panneau",
close: "Fermer la recherche",
matches: "Résultats de recherche",
previous: "Résultat précédent",
next: "Résultat suivant",
toggleReplace: "Afficher le remplacement",
replaceWith: "Remplacer par",
replace: "Remplacer",
replaceAll: "Tout remplacer",
noMatches: "Aucun résultat",
position: "{{current}} sur {{total}}",
total: "{{total}} résultats",
},
common: {
back: "Dos",
loading: "Chargement...",
@@ -74,18 +59,6 @@ export const fr: TranslationResources = {
close: "Fermer le menu",
},
commandCenter: {
contentTitle: "Rechercher dans les fichiers",
content: "Contenu des fichiers",
contentPlaceholder: "Rechercher dans les fichiers enregistrés...",
contentUnavailable: "Espace de travail indisponible",
contentUpdateHost: "Mettez cet hôte à jour pour rechercher dans les fichiers",
contentBack: "Retour aux résultats",
contentOpen: "Ouvrir",
contentLoadingPreview: "Chargement de l’aperçu...",
contentLimited: "Résultats limités — précisez la recherche",
contentSkippedLargeFiles: "Les fichiers de plus de {{size}} n’ont pas été analysés",
contentChanged: "Fichier modifié — la correspondance n’est plus à cet emplacement",
placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...",
filePlaceholder: "Rechercher des fichiers...",
searchingFiles: "Recherche de fichiers...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
paneFind: {
title: "検索",
placeholder: "ペイン内を検索",
close: "検索を閉じる",
matches: "検索結果",
previous: "前の一致",
next: "次の一致",
toggleReplace: "置換を切り替え",
replaceWith: "置換後の文字列",
replace: "置換",
replaceAll: "すべて置換",
noMatches: "一致なし",
position: "{{current}} / {{total}}",
total: "{{total}} 件の一致",
},
common: {
back: "戻る",
loading: "読み込み中...",
@@ -73,18 +58,6 @@ export const ja: TranslationResources = {
close: "メニューを閉じる",
},
commandCenter: {
contentTitle: "ファイル内容を検索",
content: "ファイル内容",
contentPlaceholder: "保存済みファイルの内容を検索...",
contentUnavailable: "ワークスペースを利用できません",
contentUpdateHost: "ファイル内容を検索するにはホストを更新してください",
contentBack: "検索結果に戻る",
contentOpen: "開く",
contentLoadingPreview: "プレビューを読み込み中...",
contentLimited: "結果が上限に達しました — 検索を絞り込んでください",
contentSkippedLargeFiles: "{{size}} を超えるファイルは検索されていません",
contentChanged: "ファイルが変更され、この位置に一致する内容はありません",
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
filePlaceholder: "ファイルを検索...",
searchingFiles: "ファイルを検索中...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
paneFind: {
title: "찾기",
placeholder: "패널에서 찾기",
close: "찾기 닫기",
matches: "검색 결과",
previous: "이전 일치 항목",
next: "다음 일치 항목",
toggleReplace: "바꾸기 표시 전환",
replaceWith: "바꿀 내용",
replace: "바꾸기",
replaceAll: "모두 바꾸기",
noMatches: "일치 항목 없음",
position: "{{current}} / {{total}}",
total: "일치 항목 {{total}}개",
},
common: {
back: "뒤로",
loading: "불러오는 중...",
@@ -73,18 +58,6 @@ export const ko: TranslationResources = {
close: "메뉴 닫기",
},
commandCenter: {
contentTitle: "파일 내용 검색",
content: "파일 내용",
contentPlaceholder: "저장된 파일 내용 검색...",
contentUnavailable: "작업 공간을 사용할 수 없음",
contentUpdateHost: "파일 내용을 검색하려면 호스트를 업데이트하세요",
contentBack: "결과로 돌아가기",
contentOpen: "열기",
contentLoadingPreview: "미리보기 로딩 중...",
contentLimited: "결과 제한 — 검색 범위를 좁히세요",
contentSkippedLargeFiles: "{{size}}보다 큰 파일은 검색하지 않았습니다",
contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다",
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
filePlaceholder: "파일 검색...",
searchingFiles: "파일 검색 중...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = {
paneFind: {
title: "Buscar",
placeholder: "Buscar no painel",
close: "Fechar busca",
matches: "Correspondências",
previous: "Correspondência anterior",
next: "Próxima correspondência",
toggleReplace: "Mostrar substituição",
replaceWith: "Substituir por",
replace: "Substituir",
replaceAll: "Substituir tudo",
noMatches: "Nenhuma correspondência",
position: "{{current}} de {{total}}",
total: "{{total}} correspondências",
},
common: {
back: "Voltar",
loading: "Carregando...",
@@ -73,18 +58,6 @@ export const ptBR: TranslationResources = {
close: "Fechar menu",
},
commandCenter: {
contentTitle: "Buscar conteúdo dos arquivos",
content: "Conteúdo dos arquivos",
contentPlaceholder: "Buscar conteúdo salvo...",
contentUnavailable: "Espaço de trabalho indisponível",
contentUpdateHost: "Atualize este host para buscar conteúdo",
contentBack: "Voltar aos resultados",
contentOpen: "Abrir",
contentLoadingPreview: "Carregando prévia...",
contentLimited: "Resultados limitados — refine a busca",
contentSkippedLargeFiles: "Arquivos maiores que {{size}} não foram pesquisados",
contentChanged: "Arquivo alterado — a correspondência não está mais neste local",
placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...",
filePlaceholder: "Pesquisar arquivos...",
searchingFiles: "Pesquisando arquivos...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
paneFind: {
title: "Найти",
placeholder: "Найти в панели",
close: "Закрыть поиск",
matches: "Совпадения",
previous: "Предыдущее совпадение",
next: "Следующее совпадение",
toggleReplace: "Показать замену",
replaceWith: "Заменить на",
replace: "Заменить",
replaceAll: "Заменить всё",
noMatches: "Нет совпадений",
position: "{{current}} из {{total}}",
total: "Совпадений: {{total}}",
},
common: {
back: "Назад",
loading: "Загрузка...",
@@ -73,18 +58,6 @@ export const ru: TranslationResources = {
close: "Закрыть меню",
},
commandCenter: {
contentTitle: "Поиск по содержимому файлов",
content: "Содержимое файлов",
contentPlaceholder: "Поиск в сохранённых файлах...",
contentUnavailable: "Рабочее пространство недоступно",
contentUpdateHost: "Обновите этот хост для поиска по содержимому",
contentBack: "Назад к результатам",
contentOpen: "Открыть",
contentLoadingPreview: "Загрузка предпросмотра...",
contentLimited: "Результаты ограничены — уточните поиск",
contentSkippedLargeFiles: "Файлы больше {{size}} не искались",
contentChanged: "Файл изменён — совпадения больше нет в этом месте",
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
filePlaceholder: "Поиск файлов...",
searchingFiles: "Поиск файлов...",
-27
View File
@@ -2,21 +2,6 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
paneFind: {
title: "查找",
placeholder: "在窗格中查找",
close: "关闭查找",
matches: "查找结果",
previous: "上一个匹配项",
next: "下一个匹配项",
toggleReplace: "切换替换",
replaceWith: "替换为",
replace: "替换",
replaceAll: "全部替换",
noMatches: "无匹配项",
position: "{{current}} / {{total}}",
total: "{{total}} 个匹配项",
},
common: {
back: "返回",
loading: "加载中...",
@@ -73,18 +58,6 @@ export const zhCN: TranslationResources = {
close: "关闭菜单",
},
commandCenter: {
contentTitle: "搜索文件内容",
content: "文件内容",
contentPlaceholder: "搜索已保存的文件内容...",
contentUnavailable: "工作区不可用",
contentUpdateHost: "请更新此主机以搜索文件内容",
contentBack: "返回结果",
contentOpen: "打开",
contentLoadingPreview: "正在加载预览...",
contentLimited: "结果已达上限 — 请缩小搜索范围",
contentSkippedLargeFiles: "未搜索大于 {{size}} 的文件",
contentChanged: "文件已更改 — 此位置不再匹配",
placeholder: "搜索命令、文件、工作区和 Agent...",
filePlaceholder: "搜索文件...",
searchingFiles: "正在搜索文件...",
-1
View File
@@ -46,7 +46,6 @@ export type KeyboardActionId =
| "settings.toggle"
| "command-center.toggle"
| "command-center.files"
| "command-center.content"
| "shortcuts.dialog.toggle"
| "workspace.terminal.new"
| "workspace.new"
@@ -1196,20 +1196,3 @@ describe("direct new-tab target shortcuts", () => {
).toEqual([["ctrl", "shift", "H"]]);
});
});
it("searches contents from terminal focus while preserving explicit Focus overrides", () => {
const event = { key: "F", code: "KeyF", metaKey: true, shiftKey: true };
const context = { isMac: true, isDesktop: true, focusScope: "terminal" as const };
expect(resolveShortcut({ event, context }).match?.action).toBe("command-center.content");
expect(resolveShortcut({ event: { ...event, altKey: true }, context }).match?.action).toBe(
"view.toggle.focus",
);
const bindings = buildEffectiveBindings({ "view-toggle-focus-cmd-shift-f-mac": "Cmd+Shift+F" });
expect(resolveShortcut({ event, context, bindings }).match?.action).toBe("view.toggle.focus");
const disabled = buildEffectiveBindings({
"view-toggle-focus-cmd-shift-f-mac": UNASSIGNED_COMBO,
});
expect(
resolveShortcut({ event: { ...event, altKey: true }, context, bindings: disabled }).match,
).toBeNull();
});
@@ -156,7 +156,6 @@ export const SHORTCUT_HELP_ROW_ORDER: Record<ShortcutSectionId, readonly string[
general: [
"toggle-command-center",
"search-files",
"search-workspace-content",
"show-shortcuts",
"toggle-settings",
"cycle-theme",
@@ -240,7 +239,6 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"toggle-right-sidebar": "settings.shortcuts.help.toggleRightSidebar",
"toggle-both-sidebars": "settings.shortcuts.help.toggleBothSidebars",
"toggle-settings": "settings.shortcuts.help.toggleSettings",
"search-workspace-content": "shell.commandCenter.contentTitle",
"toggle-focus": "settings.shortcuts.help.toggleFocusMode",
"cycle-theme": "settings.shortcuts.help.cycleTheme",
"focus-message-input": "settings.shortcuts.help.focusMessageInput",
@@ -1005,7 +1003,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
{
id: "view-toggle-focus-cmd-shift-f-mac",
action: "view.toggle.focus",
combo: "Cmd+Alt+Shift+F",
combo: "Cmd+Shift+F",
when: { mac: true, commandCenter: false },
help: {
id: "toggle-focus",
@@ -1016,7 +1014,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
{
id: "view-toggle-focus-ctrl-shift-f-non-mac",
action: "view.toggle.focus",
combo: "Ctrl+Alt+Shift+F",
combo: "Ctrl+Shift+F",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-focus",
@@ -1025,21 +1023,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
{
id: "command-center-content-cmd-shift-f-mac",
action: "command-center.content",
combo: "Cmd+Shift+F",
when: { mac: true, commandCenter: false },
help: { id: "search-workspace-content", section: "general", label: "Search file contents" },
},
{
id: "command-center-content-ctrl-shift-f-non-mac",
action: "command-center.content",
combo: "Ctrl+Shift+F",
when: { mac: false, commandCenter: false },
help: { id: "search-workspace-content", section: "general", label: "Search file contents" },
},
// --- Theme cycling ---
{
id: "theme-cycle-cmd-shift-t-mac",
+1 -5
View File
@@ -32,7 +32,7 @@ export type ShortcutAction =
| { kind: "router-push"; route: string }
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" | "content" }
| { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" }
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
const NONE: ShortcutAction = { kind: "none" };
@@ -209,10 +209,6 @@ export function routeKeyboardShortcut(
return routeSettingsToggle(ctx);
case "command-center.toggle":
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
case "command-center.content":
if (parseHostWorkspaceRouteFromPathname(ctx.pathname))
return { kind: "command-center-toggle", nextOpen: true, scope: "content" };
return dispatch({ id: "workspace.project.pick", scope: "workspace" });
case "command-center.files":
if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) {
return { kind: "command-center-toggle", nextOpen: true, scope: "files" };
-363
View File
@@ -1,363 +0,0 @@
import {
forwardRef,
useCallback,
useLayoutEffect,
useMemo,
useImperativeHandle,
useRef,
useState,
type ReactNode,
type KeyboardEvent,
} from "react";
import {
Text,
View,
type NativeSyntheticEvent,
type TextInputKeyPressEventData,
type TextInputProps,
} from "react-native";
import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
import {
createControlGeometry,
resolveControlInteractionStyles,
} from "@/components/ui/control-geometry";
import {
mutedIconColorMapping,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
const TextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ArrowUpIcon = withUnistyles(ArrowUp, mutedIconColorMapping);
const ArrowDownIcon = withUnistyles(ArrowDown, mutedIconColorMapping);
const CloseIcon = withUnistyles(X, mutedIconColorMapping);
const ChevronDownIcon = withUnistyles(ChevronDown, mutedIconColorMapping);
const ChevronRightIcon = withUnistyles(ChevronRight, mutedIconColorMapping);
export interface PaneFindHandle {
focus(): void;
}
export interface PaneFindProps {
query: string;
status: string;
canNavigate: boolean;
onQueryChange(query: string): void;
onNext(): void;
onPrevious(): void;
onClose(): void;
replace?: {
value: string;
onChange(value: string): void;
onReplace(): void;
onReplaceAll(): void;
};
}
/**
* The bordered box around one Find input. It owns the field chrome so the query
* row and the replacement row land on the same rails, and so the match count can
* sit inside the query box instead of widening the widget.
*/
const FindField = forwardRef<
EditingTextInputHandle,
{
label: string;
initialValue: string;
height: number;
trailing?: ReactNode;
onChangeText(value: string): void;
onKeyPress(event: NativeSyntheticEvent<TextInputKeyPressEventData>): void;
autoFocus?: boolean;
returnKeyType?: TextInputProps["returnKeyType"];
}
>(function FindField(
{ label, initialValue, height, trailing, onChangeText, onKeyPress, autoFocus, returnKeyType },
ref,
) {
const [focused, setFocused] = useState(false);
const onFocus = useCallback(() => setFocused(true), []);
const onBlur = useCallback(() => setFocused(false), []);
const fieldStyle = useMemo(
() => [
styles.field,
{ minHeight: height },
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
controlHover: styles.controlHover,
controlActive: styles.controlActive,
},
{ focused },
),
],
[focused, height],
);
return (
<View style={fieldStyle}>
<TextInput
ref={ref}
autoFocus={autoFocus}
selectTextOnFocus
initialValue={initialValue}
onChangeText={onChangeText}
onKeyPress={onKeyPress}
onFocus={onFocus}
onBlur={onBlur}
accessibilityLabel={label}
placeholder={label}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
returnKeyType={returnKeyType}
style={styles.input}
/>
{trailing}
</View>
);
});
/** Pane-local chrome. The content owner supplies search state and commands. */
export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneFind(
{ query, status, canNavigate, onQueryChange, onNext, onPrevious, onClose, replace },
ref,
) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const controlSize = smallIconButtonChromeFrameSize(isCompact);
const glyphSize = paneContentToolbarIconSize(isCompact);
const rowHeight = Math.max(COLLAPSED_FIELD_HEIGHT, controlSize);
// Keep the replace actions on the same row height as the icon controls beside them.
const actionSize = isCompact ? "sm" : "xs";
const input = useRef<EditingTextInputHandle>(null);
const replacementInput = useRef<EditingTextInputHandle>(null);
useLayoutEffect(() => {
if (input.current?.getText() !== query) input.current?.replaceText(query);
}, [query]);
useLayoutEffect(() => {
if (replace && replacementInput.current?.getText() !== replace.value)
replacementInput.current?.replaceText(replace.value);
}, [replace]);
const [replaceExpanded, setReplaceExpanded] = useState(false);
const focus = useCallback(() => {
input.current?.focus();
const text = input.current?.getText() ?? "";
input.current?.replaceText(text, { start: 0, end: text.length });
}, []);
useImperativeHandle(ref, () => ({ focus }), [focus]);
const onKeyPress = useCallback(
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
const key = event.nativeEvent as TextInputKeyPressEventData & {
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
isComposing?: boolean;
keyCode?: number;
};
if (isImeComposingKeyboardEvent(key)) return;
if (
(key.metaKey || key.ctrlKey) &&
!key.altKey &&
!key.shiftKey &&
key.key.toLowerCase() === "f"
) {
// RN Web inputs stop keydown before the pane's document listener.
event.preventDefault();
event.stopPropagation();
focus();
} else if (key.key === "Escape" || key.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (key.key === "Escape") onClose();
else if (key.shiftKey) onPrevious();
else onNext();
}
},
[focus, onClose, onNext, onPrevious],
);
const toggleReplace = useCallback(() => setReplaceExpanded((expanded) => !expanded), []);
const gutterStyle = useMemo(
() => [styles.gutter, { width: controlSize, height: rowHeight }],
[controlSize, rowHeight],
);
const matchCount = useMemo(
() => (
<Text
style={styles.status}
role="status"
accessibilityLabel={t("paneFind.matches")}
accessibilityLiveRegion="polite"
>
{status}
</Text>
),
[status, t],
);
return (
<View
style={styles.widget}
accessibilityLabel={t("paneFind.title")}
{...(isWeb
? {
onKeyDown: (event: KeyboardEvent) => {
if (event.key === "Escape" && !isImeComposingKeyboardEvent(event.nativeEvent)) {
event.preventDefault();
event.stopPropagation();
onClose();
}
},
}
: {})}
>
{replace ? (
<View style={gutterStyle}>
<ToolbarButton
label={t("paneFind.toggleReplace")}
aria-expanded={replaceExpanded}
compact={isCompact}
onPress={toggleReplace}
>
{replaceExpanded ? (
<ChevronDownIcon size={glyphSize} />
) : (
<ChevronRightIcon size={glyphSize} />
)}
</ToolbarButton>
</View>
) : null}
<View style={styles.rows}>
<View style={styles.row}>
<FindField
ref={input}
autoFocus
label={t("paneFind.placeholder")}
initialValue={query}
height={rowHeight}
returnKeyType="search"
trailing={matchCount}
onChangeText={onQueryChange}
onKeyPress={onKeyPress}
/>
<ToolbarButton
label={t("paneFind.previous")}
compact={isCompact}
disabled={!canNavigate}
onPress={onPrevious}
>
<ArrowUpIcon size={glyphSize} />
</ToolbarButton>
<ToolbarButton
label={t("paneFind.next")}
compact={isCompact}
disabled={!canNavigate}
onPress={onNext}
>
<ArrowDownIcon size={glyphSize} />
</ToolbarButton>
<ToolbarButton label={t("paneFind.close")} compact={isCompact} onPress={onClose}>
<CloseIcon size={glyphSize} />
</ToolbarButton>
</View>
{replace && replaceExpanded ? (
<View style={styles.row}>
<FindField
ref={replacementInput}
label={t("paneFind.replaceWith")}
initialValue={replace.value}
height={rowHeight}
onChangeText={replace.onChange}
onKeyPress={onKeyPress}
/>
<Button
variant="ghost"
size={actionSize}
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplace}
>
{t("paneFind.replace")}
</Button>
<Button
variant="ghost"
size={actionSize}
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplaceAll}
>
{t("paneFind.replaceAll")}
</Button>
</View>
) : null}
</View>
</View>
);
});
/** Desktop find rows run one step tighter than a form field — this is pane chrome. */
const COLLAPSED_FIELD_HEIGHT = 28;
const FIND_WIDGET_WIDTH = 340;
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
widget: {
width: FIND_WIDGET_WIDTH,
maxWidth: "100%",
flexDirection: "row",
padding: theme.spacing[1.5],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
...theme.shadow.md,
},
// The disclosure column spans both rows so the two fields share a leading rail.
gutter: { alignItems: "center", justifyContent: "center", flexShrink: 0 },
rows: { flex: 1, minWidth: 0, gap: theme.spacing[1] },
row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] },
field: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
},
controlRest: { ...geometry.controlRest },
controlHover: { ...geometry.controlHover },
controlActive: { ...geometry.controlActive },
input: {
flex: 1,
minWidth: 0,
paddingHorizontal: 0,
paddingVertical: 0,
color: theme.colors.foreground,
outlineWidth: 0,
outlineColor: "transparent",
...geometry.fieldTextSm,
},
status: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
// Ghost actions sit on the field's rail, so they carry the field's padding.
replaceAction: { paddingHorizontal: theme.spacing[2] },
};
});
+1 -6
View File
@@ -75,12 +75,7 @@ export function usePaneContext(): PaneContextValue {
}
export function usePaneFocus(): PaneFocusContextValue {
const value = usePaneFocusIfAny();
const value = useContext(PaneFocusContext);
invariant(value, "PaneFocusContext is required");
return value;
}
/** Pane focus for subtrees that a pane may or may not own, such as an overlay preview. */
export function usePaneFocusIfAny(): PaneFocusContextValue | null {
return useContext(PaneFocusContext);
}
+108 -12
View File
@@ -85,10 +85,15 @@ import { useProjectIcons } from "@/projects/icons";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import type { MessagePayload } from "@/composer/types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type {
CreatePaseoWorktreeInput,
DaemonClient,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
@@ -123,6 +128,11 @@ import {
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "./workspace/terminals/state";
import {
captureWorkspaceDraftCleanup,
createWorkspaceAgentInBackground,
} from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
const ThemedFolderPlus = withUnistyles(FolderPlus);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -747,10 +757,19 @@ function normalizeBranchDetails(
return names.map((name) => ({ name, committerDate: 0 }));
}
/**
* "background" means the user left the New workspace screen mid-creation, so nothing navigated
* and the screen — if still mounted under another route — has to drop its pending state itself.
*/
type SubmitOutcome = "navigated" | "background";
interface SubmitDraftInput {
clearConsumedDraft: () => void;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
initialSetup?: WorkspaceDraftTabSetup;
workspaceId: string;
workspaceDirectory: string;
@@ -759,6 +778,8 @@ interface SubmitDraftInput {
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
}
type NewWorkspaceComposerState = NonNullable<
@@ -860,9 +881,13 @@ interface CreateChatAgentInput {
withInitialAgent: boolean;
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
labels: {
composerStateRequired: string;
selectModel: string;
@@ -918,8 +943,9 @@ function buildComposerInitialValues(input: {
return undefined;
}
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
const clearConsumedDraft = captureWorkspaceDraftCleanup(input);
const { text, attachments, cwd } = payload;
if (!composerState) {
throw new Error(input.labels.composerStateRequired);
@@ -947,10 +973,13 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
provider,
composerState,
});
submitWorkspaceDraft({
return await submitWorkspaceDraft({
clearConsumedDraft,
serverId,
clearDraft,
draftKey: input.draftKey,
draftId: input.draftId,
draftContextScopeKey: input.draftContextScopeKey,
initialSetup,
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
@@ -959,6 +988,8 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
provider,
composerState,
supportsForgeSearch: input.supportsForgeSearch,
resolveClient: input.resolveClient,
isStillOnCreateScreen: input.isStillOnCreateScreen,
});
}
@@ -1021,7 +1052,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
};
}
function submitWorkspaceDraft(input: SubmitDraftInput): void {
async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutcome> {
const {
serverId,
clearDraft,
@@ -1049,6 +1080,33 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
composerState,
initialSetup,
});
// Creation blocks on a slow daemon RPC. If the user moved on while it ran, the destination
// screen's draft tab will never mount to issue create_agent, so this path does it instead.
if (!input.isStillOnCreateScreen()) {
await createWorkspaceAgentInBackground({
clearConsumedDraft: input.clearConsumedDraft,
createAgent: () =>
requestWorkspaceDraftAgent(input.resolveClient(), {
workspaceId,
config: buildWorkspaceDraftAgentConfig({
provider: submission.provider,
cwd: submission.cwd,
...(submission.modeId ? { modeId: submission.modeId } : {}),
...(submission.model ? { model: submission.model } : {}),
...(submission.thinkingOptionId
? { thinkingOptionId: submission.thinkingOptionId }
: {}),
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
}),
text: text.trim(),
clientMessageId,
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
}),
});
return "background";
}
useCreateFlowStore.getState().setPending({
serverId,
draftId,
@@ -1082,6 +1140,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
workspaceId,
target: submission.target,
});
return "navigated";
}
function useNewWorkspaceHostSelector(input: {
@@ -1591,6 +1650,7 @@ export function NewWorkspaceScreen({
const isolationPickerAnchorRef = useRef<View>(null);
const hostPickerAnchorRef = useRef<View | null>(null);
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
// Launch target: what the composer submits to (chat agent, or a terminal
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
@@ -1696,11 +1756,12 @@ export function NewWorkspaceScreen({
}, []);
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
if (!connectedClient?.isConnected) {
throw new Error(t("newWorkspace.errors.hostDisconnected"));
}
return client;
}, [client, isConnected, t]);
return connectedClient;
}, [selectedServerId, t]);
const clientReady = isConnected && Boolean(client);
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
@@ -2043,31 +2104,49 @@ export function NewWorkspaceScreen({
await updateFormPreferences({ launchTarget });
if (isEmptyWorkspaceSubmission(payload)) {
setPendingAction("empty");
let outcome: SubmitOutcome = "background";
await runCreateEmptyWorkspace({
payload,
ensureWorkspace,
serverId: selectedServerId,
navigate: (targetServerId, workspaceId) =>
navigateToWorkspace({ serverId: targetServerId, workspaceId }),
navigate: (targetServerId, workspaceId) => {
if (!isStillOnCreateScreen()) {
return;
}
outcome = "navigated";
navigateToWorkspace({ serverId: targetServerId, workspaceId });
},
});
// Nothing navigated, so this screen may still be mounted under another route. Release
// the pending lock it would otherwise keep forever.
if (outcome === "background") {
setPendingAction(null);
}
return;
}
setPendingAction("chat");
await runCreateChatAgent({
const outcome = await runCreateChatAgent({
payload,
composerState,
forkDraftSetup,
ensureWorkspace,
serverId: selectedServerId,
clearDraft: chatDraft.clear,
draftKey,
draftId,
draftContextScopeKey,
supportsForgeSearch,
resolveClient: withConnectedClient,
isStillOnCreateScreen,
labels: {
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
selectModel: t("newWorkspace.errors.selectModel"),
},
});
if (outcome === "background") {
setPendingAction(null);
}
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
@@ -2077,16 +2156,20 @@ export function NewWorkspaceScreen({
},
[
composerState,
draftContextScopeKey,
draftId,
chatDraft.clear,
draftKey,
ensureWorkspace,
forkDraftSetup,
isStillOnCreateScreen,
launchTarget,
selectedServerId,
supportsForgeSearch,
t,
toast,
updateFormPreferences,
withConnectedClient,
],
);
@@ -2095,6 +2178,7 @@ export function NewWorkspaceScreen({
setErrorMessage(null);
await updateFormPreferences({ launchTarget });
setPendingAction("terminal");
let outcome: SubmitOutcome = "background";
await runCreateTerminalWorkspace({
cwd: selectedSourceDirectory ?? "",
prompt: terminalPromptText,
@@ -2128,9 +2212,20 @@ export function NewWorkspaceScreen({
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
},
serverId: selectedServerId,
navigate: (targetServerId, workspaceId, target) =>
navigateToWorkspace({ serverId: targetServerId, workspaceId, target }),
// The terminal is spawned and fed its command before this runs, so skipping the
// navigation costs nothing: it is standalone, and `reconcileTabs` auto-opens standalone
// terminals when the workspace is next visited.
navigate: (targetServerId, workspaceId, target) => {
if (!isStillOnCreateScreen()) {
return;
}
outcome = "navigated";
navigateToWorkspace({ serverId: targetServerId, workspaceId, target });
},
});
if (outcome === "background") {
setPendingAction(null);
}
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
@@ -2139,6 +2234,7 @@ export function NewWorkspaceScreen({
}
}, [
ensureWorkspace,
isStillOnCreateScreen,
launchTarget,
queryClient,
selectedServerId,
@@ -0,0 +1,176 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useDraftStore } from "@/stores/draft-store";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import {
captureWorkspaceDraftCleanup,
createWorkspaceAgentInBackground,
} from "./background-handoff";
const DRAFT_ID = "draft_1";
const DRAFT_KEY = "new-workspace";
const SCOPE_KEY = "draft:draft_1";
function saveDraft(text: string) {
useDraftStore
.getState()
.saveDraftInput({ draftKey: DRAFT_KEY, draft: { text, attachments: [] } });
}
function draftText(): string | undefined {
return useDraftStore.getState().drafts[DRAFT_KEY]?.input.text;
}
function captureCleanup() {
return captureWorkspaceDraftCleanup({
draftId: DRAFT_ID,
draftKey: DRAFT_KEY,
clearDraft: (lifecycle) =>
useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }),
draftContextScopeKey: SCOPE_KEY,
});
}
function run(createAgent: () => Promise<unknown>, clearConsumedDraft = captureCleanup()) {
return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft });
}
beforeEach(() => {
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} });
useCreateFlowStore.setState({ pendingByDraftId: {} });
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } });
saveDraft("do the thing");
});
describe("createWorkspaceAgentInBackground", () => {
it("preserves context added to a newer draft without editing its text", async () => {
await run(async () => {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey: SCOPE_KEY,
attachment: {
kind: "chat_history",
id: "fork-context",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Preserve this context",
},
source: { serverId: "local", agentId: "agent-1" },
},
});
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId: DRAFT_ID,
setup: {
cwd: "/work/new-repo",
provider: "codex",
modeId: null,
model: null,
thinkingOptionId: null,
featureValues: {},
},
});
});
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe(
"/work/new-repo",
);
});
it("creates one agent and clears the consumed draft setup, attachments and input", async () => {
let calls = 0;
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId: DRAFT_ID,
setup: {
cwd: "/work/repo",
provider: "claude",
modeId: null,
model: null,
thinkingOptionId: null,
featureValues: {},
},
});
await run(async () => {
calls += 1;
});
expect(calls).toBe(1);
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({});
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined();
expect(draftText()).toBe("");
});
it("preserves context changed while workspace creation was pending", async () => {
const clearConsumedDraft = captureCleanup();
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey: SCOPE_KEY,
attachment: {
kind: "chat_history",
id: "fork-context",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Preserve this context",
},
source: { serverId: "local", agentId: "agent-1" },
},
});
await run(async () => undefined, clearConsumedDraft);
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
expect(draftText()).toBe("do the thing");
});
it("leaves no pending submission behind, so no draft tab can create a second agent", async () => {
await run(async () => undefined);
expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({});
expect(useCreateFlowStore.getState().pendingByDraftId).toEqual({});
});
it("rethrows a failed creation and keeps the prompt for retry", async () => {
const failure = new Error("daemon exploded");
await expect(
run(async () => {
throw failure;
}),
).rejects.toThrow(failure);
expect(draftText()).toBe("do the thing");
});
it("keeps a prompt typed into the shared draft while creation was in flight", async () => {
await run(async () => {
saveDraft("a different idea");
});
expect(draftText()).toBe("a different idea");
});
it("keeps a newer draft written while workspace creation was pending", async () => {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey: SCOPE_KEY,
attachment: {
kind: "chat_history",
id: "fork-context",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Preserve this context",
},
source: { serverId: "local", agentId: "agent-1" },
},
});
const clearConsumedDraft = captureCleanup();
saveDraft("a newer workspace idea");
await run(async () => undefined, clearConsumedDraft);
expect(draftText()).toBe("a newer workspace idea");
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
});
});
@@ -0,0 +1,54 @@
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { useDraftStore } from "@/stores/draft-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
interface WorkspaceDraftCleanupInput {
draftId?: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftContextScopeKey: string | null;
}
/** Capture ownership before workspace creation starts, not when its response arrives. */
export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput): () => void {
const draftId = input.draftId?.trim() ?? "";
const scopeKey = input.draftContextScopeKey ?? "";
const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version;
const setup = useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId];
const attachments = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey];
return () => {
// These stores update independently. A context-only edit does not bump the text version.
// Any newer draft state owns the whole draft, including its unchanged context.
if (
useDraftStore.getState().drafts[input.draftKey]?.version !== draftVersion ||
useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId] !== setup ||
useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey] !== attachments
) {
return;
}
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
if (scopeKey) {
useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey });
}
input.clearDraft("sent");
};
}
/**
* The workspace draft tab normally issues create_agent once it mounts on the destination screen.
* When the user leaves the New workspace screen before creation resolves, that tab never mounts,
* so this path issues the request itself. The agent tab opens on its own when the workspace is
* next visited — `reconcileTabs` auto-opens agents.
*
* The caller must not write a pending entry to the draft-submission or create-flow stores on this
* path: those drive the draft tab's auto-submit, and the daemon does not dedupe create_agent by
* clientMessageId, so a leftover entry would create a second agent when the workspace is opened.
*/
export async function createWorkspaceAgentInBackground(input: {
clearConsumedDraft: () => void;
createAgent: () => Promise<unknown>;
}): Promise<void> {
await input.createAgent();
input.clearConsumedDraft();
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { isNewWorkspaceScreenActive } from "./screen-presence";
describe("isNewWorkspaceScreenActive", () => {
it("is active while the screen is mounted and the route is /new", () => {
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/new" })).toBe(true);
});
it("is inactive once the screen has been popped off the stack", () => {
expect(isNewWorkspaceScreenActive({ isMounted: false, pathname: "/new" })).toBe(false);
});
it("is inactive while another route sits on top of the screen", () => {
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/settings" })).toBe(false);
});
it("is inactive on a workspace route", () => {
expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/host/workspace/wks_1" })).toBe(
false,
);
});
});
@@ -0,0 +1,41 @@
import { usePathname } from "expo-router";
import { useCallback, useEffect, useRef } from "react";
const NEW_WORKSPACE_PATHNAME = "/new";
export interface NewWorkspaceScreenPresence {
isMounted: boolean;
pathname: string;
}
/**
* Workspace creation blocks on a slow daemon RPC, so by the time it resolves the user may have
* moved on. Both signals are needed: popping to a workspace unmounts the screen while its last
* observed pathname stays "/new", and pushing a route on top of it keeps it mounted underneath.
*/
export function isNewWorkspaceScreenActive(input: NewWorkspaceScreenPresence): boolean {
return input.isMounted && input.pathname === NEW_WORKSPACE_PATHNAME;
}
export function useNewWorkspaceScreenPresence(): () => boolean {
const pathname = usePathname();
const pathnameRef = useRef(pathname);
pathnameRef.current = pathname;
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
return useCallback(
() =>
isNewWorkspaceScreenActive({
isMounted: isMountedRef.current,
pathname: pathnameRef.current,
}),
[],
);
}
@@ -1,24 +1,22 @@
import { describe, expect, it, vi } from "vitest";
import { openWorkspaceFileInFocusedPane } from "@/screens/workspace/workspace-file-open-command";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
function createInput(closeExplorerAfterOpen: boolean) {
return {
location: { path: "src/app.tsx" } as WorkspaceFileLocation,
filePath: "src/app.tsx",
persistenceKey: "server:workspace",
closeExplorerAfterOpen,
showMobileAgent: vi.fn(),
openWorkspaceTabInFocusedPane: vi.fn(() => "file-tab"),
focusWorkspaceTab: vi.fn(),
requestFileNavigation: vi.fn(),
};
}
describe("openWorkspaceFileInFocusedPane", () => {
describe("openWorkspaceFileFromExplorer", () => {
it("closes the phone overlay after opening a file", () => {
const input = createInput(true);
openWorkspaceFileInFocusedPane(input);
openWorkspaceFileFromExplorer(input);
expect(input.showMobileAgent).toHaveBeenCalledOnce();
});
@@ -26,29 +24,10 @@ describe("openWorkspaceFileInFocusedPane", () => {
it("keeps the tablet dock open after opening a file", () => {
const input = createInput(false);
openWorkspaceFileInFocusedPane(input);
openWorkspaceFileFromExplorer(input);
expect(input.showMobileAgent).not.toHaveBeenCalled();
expect(input.openWorkspaceTabInFocusedPane).toHaveBeenCalledOnce();
expect(input.focusWorkspaceTab).toHaveBeenCalledWith("server:workspace", "file-tab");
});
it("asks for navigation every time, so reopening the same occurrence recentres its pane", () => {
const input = createInput(false);
input.location = { path: "src/app.tsx", lineStart: 1201, columnStart: 17 };
openWorkspaceFileInFocusedPane(input);
openWorkspaceFileInFocusedPane(input);
expect(input.requestFileNavigation).toHaveBeenCalledTimes(2);
expect(input.requestFileNavigation).toHaveBeenLastCalledWith("file-tab");
});
it("does not navigate when no tab could be opened", () => {
const input = { ...createInput(false), openWorkspaceTabInFocusedPane: vi.fn(() => null) };
openWorkspaceFileInFocusedPane(input);
expect(input.requestFileNavigation).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,6 @@
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type WorkspaceFileLocation,
} from "@/workspace/file-open";
import {
FOCUSED_PANE_PLACEMENT,
@@ -9,8 +8,8 @@ import {
} from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
interface OpenWorkspaceFileInput {
location: WorkspaceFileLocation;
interface OpenWorkspaceFileFromExplorerInput {
filePath: string;
persistenceKey: string | null;
closeExplorerAfterOpen: boolean;
showMobileAgent: () => void;
@@ -20,24 +19,16 @@ interface OpenWorkspaceFileInput {
placement?: WorkspaceTabPlacement,
) => string | null;
focusWorkspaceTab: (workspaceKey: string, tabId: string) => void;
requestFileNavigation: (tabId: string) => void;
}
/**
* Opens a workspace file in the focused pane and navigates to it.
*
* The navigation request is the part callers keep forgetting: a file target is identity-stable, so
* reopening the same path — or the same occurrence — reuses its tab and would otherwise leave the
* pane wherever the reader had scrolled it. Every explicit activation asks for a reveal.
*/
export function openWorkspaceFileInFocusedPane(input: OpenWorkspaceFileInput): void {
export function openWorkspaceFileFromExplorer(input: OpenWorkspaceFileFromExplorerInput): void {
if (input.closeExplorerAfterOpen) {
input.showMobileAgent();
}
if (!input.persistenceKey) {
return;
}
const location = normalizeWorkspaceFileLocation(input.location);
const location = normalizeWorkspaceFileLocation({ path: input.filePath });
if (!location) {
return;
}
@@ -46,9 +37,7 @@ export function openWorkspaceFileInFocusedPane(input: OpenWorkspaceFileInput): v
createWorkspaceFileTabTarget(location),
FOCUSED_PANE_PLACEMENT,
);
if (!tabId) {
return;
if (tabId) {
input.focusWorkspaceTab(input.persistenceKey, tabId);
}
input.focusWorkspaceTab(input.persistenceKey, tabId);
input.requestFileNavigation(tabId);
}
@@ -1647,13 +1647,17 @@ function WorkspaceScreenContent({
) => openTab({ workspaceKey, target, intent: "reveal", parentTabId, placement }),
[openTab],
);
// File targets stay identity-stable so the same path reuses its tab. Navigation requests live
// with the tabs in the layout store, so an opener outside this screen — the Command Center —
// asks for the same reveal through the same capability.
const fileNavigationRevisionByTabId = useWorkspaceLayoutStore(
(state) => state.fileNavigationRevisionByTabId,
);
const requestFileNavigation = useWorkspaceLayoutStore((state) => state.requestFileNavigation);
// File targets stay identity-stable so the same path reuses its tab. Keep navigation
// requests separate so clicking an unchanged path:line can still recenter the pane.
const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState<
Record<string, number>
>({});
const requestFileNavigation = useCallback((tabId: string) => {
setFileNavigationRevisionByTabId((current) => ({
...current,
[tabId]: (current[tabId] ?? 0) + 1,
}));
}, []);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
@@ -3,7 +3,7 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
const SHORTCUT_BADGE_DELAY_MS = 150;
export type CommandCenterScope = "files" | "content" | null;
export type CommandCenterScope = "files" | null;
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
@@ -110,12 +110,6 @@ interface WorkspaceLayoutStore {
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
/**
* Bumped whenever a file tab is explicitly activated. File targets are identity-stable so the
* same path reuses its tab; without this an activation of an unchanged location would look like
* a no-op and leave the pane wherever the reader had scrolled it.
*/
fileNavigationRevisionByTabId: Record<string, number>;
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
sidePaneIdByWorkspace: Record<string, string | null>;
openTab: (input: OpenWorkspaceTabInput) => string | null;
@@ -126,7 +120,6 @@ interface WorkspaceLayoutStore {
ensureSidePane: (workspaceKey: string) => string | null;
closeTab: (workspaceKey: string, tabId: string) => void;
focusTab: (workspaceKey: string, tabId: string) => void;
requestFileNavigation: (tabId: string) => void;
selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void;
replaceTab: (
workspaceKey: string,
@@ -214,9 +207,6 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
path: z.string(),
lineStart: z.number().int().positive().optional(),
lineEnd: z.number().int().positive().optional(),
columnStart: z.number().int().positive().optional(),
columnEnd: z.number().int().positive().optional(),
expectedText: z.string().optional(),
}),
z.strictObject({
kind: z.literal("working_diff"),
@@ -777,7 +767,6 @@ export function createWorkspaceLayoutStore(
pendingAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
fileNavigationRevisionByTabId: {},
explorerSidebarPaneIdByWorkspace: {},
sidePaneIdByWorkspace: {},
openTab: (input) => {
@@ -1051,16 +1040,6 @@ export function createWorkspaceLayoutStore(
};
});
},
requestFileNavigation: (tabId) => {
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedTabId) return;
set((state) => ({
fileNavigationRevisionByTabId: {
...state.fileNavigationRevisionByTabId,
[normalizedTabId]: (state.fileNavigationRevisionByTabId[normalizedTabId] ?? 0) + 1,
},
}));
},
focusTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
@@ -1,6 +0,0 @@
/** Renders a byte count in the units the file pane and file search already show. */
export function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
-13
View File
@@ -1,16 +1,3 @@
export function isAbsolutePath(value: string): boolean {
return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
}
/**
* Rewrites separators only for a path that is Windows-shaped, meaning it carries a drive letter
* or a UNC prefix. Everywhere else a backslash is an ordinary file name character, so a POSIX or
* workspace-relative path is returned untouched.
*/
export function normalizePathSeparators(value: string): string {
return isWindowsShapedPath(value) ? value.replace(/\\/g, "/") : value;
}
function isWindowsShapedPath(value: string): boolean {
return value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
}
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
resolveWorkspaceFileSelection,
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
resolveWorkspaceFilePaths,
@@ -8,10 +7,10 @@ import {
} from ".";
describe("normalizeWorkspaceFileLocation", () => {
it("clamps line ranges and leaves the path as the producer gave it", () => {
it("normalizes paths and valid line ranges", () => {
expect(
normalizeWorkspaceFileLocation({
path: "src/app.ts",
path: "src\\app.ts",
lineStart: 12.8,
lineEnd: 20.2,
}),
@@ -35,7 +34,7 @@ describe("normalizeWorkspaceFileLocation", () => {
});
it("rejects empty paths", () => {
expect(normalizeWorkspaceFileLocation({ path: "" })).toBeNull();
expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull();
});
});
@@ -98,33 +97,13 @@ describe("resolveWorkspaceFilePaths", () => {
});
});
it("normalizes separators in a Windows-shaped path and leaves every other path literal", () => {
it("normalizes Windows separators in the file path", () => {
expect(
resolveWorkspaceFilePaths({
path: "C:\\Users\\me\\repo\\src\\app.ts",
workspaceRoot: "C:\\Users\\me\\repo",
}),
resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "C:/Users/me/repo/src/app.ts",
absolutePath: "/Users/me/repo/src/app.ts",
relativePath: "src/app.ts",
});
// A workspace-relative path is the host's own identity, so its backslash is a file name
// character. Rewriting it here is what opened a different file than the search result.
expect(
resolveWorkspaceFilePaths({ path: "a\\b.txt", workspaceRoot: "/Users/me/repo" }),
).toEqual({
absolutePath: "/Users/me/repo/a\\b.txt",
relativePath: "a\\b.txt",
});
expect(
resolveWorkspaceFilePaths({
path: "/Users/me/repo/a\\b.txt",
workspaceRoot: "/Users/me/repo",
}),
).toEqual({
absolutePath: "/Users/me/repo/a\\b.txt",
relativePath: "a\\b.txt",
});
});
it("normalizes dot segments in workspace-relative paths", () => {
@@ -204,50 +183,3 @@ describe("resolveWorkspaceFilePaths", () => {
).toBeNull();
});
});
it("selects exact UTF-16 columns and refuses a stale saved match", () => {
const location = {
path: "a.ts",
lineStart: 2,
columnStart: 5,
columnEnd: 11,
expectedText: "NEEDLE",
};
expect(resolveWorkspaceFileSelection("first\né🙂 NEEDLE", location)).toEqual({
from: 10,
to: 16,
changed: false,
});
expect(resolveWorkspaceFileSelection("first\né🙂 change", location)).toEqual({
from: 10,
to: 10,
changed: true,
});
expect(workspaceFileLocationsEqual(location, { ...location, columnStart: 6 })).toBe(false);
});
it("does not select an identical occurrence on another line after the saved line disappears", () => {
expect(
resolveWorkspaceFileSelection("needle", {
path: "a.ts",
lineStart: 3,
columnStart: 1,
columnEnd: 7,
expectedText: "needle",
}),
).toEqual({ from: 0, to: 0, changed: true });
});
it("preserves literal filename and workspace whitespace through location and host paths", () => {
const location = { path: " leading.txt ", lineStart: 1 };
expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
expect(resolveWorkspaceFilePaths({ path: location.path, workspaceRoot: "/workspace " })).toEqual({
absolutePath: "/workspace / leading.txt ",
relativePath: " leading.txt ",
});
});
it("preserves a literal backslash file name through location identity", () => {
const location = { path: "a\\b.txt", lineStart: 1, columnStart: 1, expectedText: "needle" };
expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
});
+5 -49
View File
@@ -1,18 +1,11 @@
import { isAbsolutePath, normalizePathSeparators } from "@/utils/path";
import { isAbsolutePath } from "@/utils/path";
export type OpenFileDisposition = "main" | "preferred" | "side";
export interface WorkspaceFileLocation {
path: string;
/** One-based source line; CRLF, CR and LF each separate lines. */
lineStart?: number;
lineEnd?: number;
/** One-based UTF-16 column on lineStart. */
columnStart?: number;
/** Exclusive UTF-16 column on the same line. */
columnEnd?: number;
/** Original saved occurrence; a mismatch reveals the location without selecting text. */
expectedText?: string;
}
export type WorkspaceFileTabTarget = { kind: "file" } & WorkspaceFileLocation;
@@ -29,25 +22,16 @@ export function normalizeWorkspaceFileLocation(
return null;
}
// The path is an identity the host already produced, so it is kept verbatim: a backslash is a
// file name character outside Windows, and rewriting it here opened a different file than the
// one a search result or explorer row named. Callers holding a path scraped out of text
// normalize it themselves before arriving.
const path = location.path;
const path = location.path.trim().replace(/\\/g, "/");
if (!path) {
return null;
}
const lineStart = normalizeLineNumber(location.lineStart);
const lineEnd = normalizeLineNumber(location.lineEnd);
const columnStart = normalizeLineNumber(location.columnStart);
const columnEnd = normalizeLineNumber(location.columnEnd);
return {
path,
...(lineStart ? { lineStart } : {}),
...(lineStart && columnStart ? { columnStart } : {}),
...(lineStart && columnEnd && columnEnd >= (columnStart ?? 1) ? { columnEnd } : {}),
...(location.expectedText !== undefined ? { expectedText: location.expectedText } : {}),
...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}),
};
}
@@ -57,12 +41,7 @@ export function workspaceFileLocationsEqual(
right: WorkspaceFileLocation,
): boolean {
return (
left.path === right.path &&
left.lineStart === right.lineStart &&
left.lineEnd === right.lineEnd &&
left.columnStart === right.columnStart &&
left.columnEnd === right.columnEnd &&
left.expectedText === right.expectedText
left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd
);
}
@@ -166,8 +145,8 @@ export function resolveWorkspaceFilePaths(input: {
path: string;
workspaceRoot: string;
}): ResolvedWorkspaceFilePaths | null {
const filePath = normalizePathSeparators(input.path);
const workspaceRoot = normalizeAbsolutePath(normalizePathSeparators(input.workspaceRoot));
const filePath = input.path.trim().replace(/\\/g, "/");
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
if (!filePath || !workspaceRoot) {
return null;
}
@@ -197,26 +176,3 @@ export function resolveWorkspaceFilePaths(input: {
}
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
}
/** Resolves a saved location without manufacturing a selection when its text has moved. */
export function resolveWorkspaceFileSelection(
content: string,
location: WorkspaceFileLocation,
): { from: number; to: number; changed: boolean } {
const lines = content.split("\n");
const index = Math.max(0, Math.min((location.lineStart ?? 1) - 1, lines.length - 1));
const line = lines[index];
const offset = lines.slice(0, index).reduce((sum, value) => sum + value.length + 1, 0);
const from = offset + Math.min((location.columnStart ?? 1) - 1, line.length);
const endIndex = Math.max(index, Math.min((location.lineEnd ?? index + 1) - 1, lines.length - 1));
let to = from;
if (location.columnEnd !== undefined) to = offset + Math.min(location.columnEnd - 1, line.length);
else if (endIndex > index)
to =
lines.slice(0, endIndex).reduce((sum, value) => sum + value.length + 1, 0) +
lines[endIndex].length;
const changed =
location.expectedText !== undefined &&
(index !== (location.lineStart ?? 1) - 1 || content.slice(from, to) !== location.expectedText);
return { from, to: changed ? from : to, changed };
}
-28
View File
@@ -1,4 +1,3 @@
import type { WorkspaceContentSearchResult } from "@getpaseo/protocol/messages";
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import {
@@ -4492,33 +4491,6 @@ export class DaemonClient {
return payload.directory;
}
async searchWorkspaceContent(
input: { cwd: string; query: string },
options: { signal?: AbortSignal } = {},
): Promise<WorkspaceContentSearchResult> {
if (options.signal?.aborted)
return { status: "error", code: "cancelled", message: "Search cancelled" };
const requestId = this.createRequestId();
const cancel = () => {
void this.sendCorrelatedSessionRequest({
message: { type: "fs.content.cancel.request", searchRequestId: requestId },
responseType: "fs.content.cancel.response",
}).catch(() => undefined);
};
const response = this.sendCorrelatedSessionRequest({
requestId,
message: { type: "fs.content.search.request", ...input },
responseType: "fs.content.search.response",
});
options.signal?.addEventListener("abort", cancel, { once: true });
if (options.signal?.aborted) cancel();
try {
return (await response).result;
} finally {
options.signal?.removeEventListener("abort", cancel);
}
}
async readFile(
cwd: string,
path: string,
-1
View File
@@ -1,4 +1,3 @@
export type { FileReadResult } from "./daemon-client.js";
import type { DaemonClientConfig } from "./daemon-client.js";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import type {
-56
View File
@@ -2688,57 +2688,6 @@ export const FileVersionSchema = z.discriminatedUnion("status", [
}),
]);
export const WorkspaceContentMatchSchema = z.object({
path: z.string(),
line: z.number().int().positive(),
columnStart: z.number().int().positive(),
columnEnd: z.number().int().positive(),
text: z.string(),
snippet: z.string(),
snippetMatchStart: z.number().int().nonnegative(),
snippetMatchEnd: z.number().int().nonnegative(),
});
export const WorkspaceContentSearchResultSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("ok"),
matches: z.array(WorkspaceContentMatchSchema),
limited: z.boolean(),
/**
* Per-file byte ceiling the host applied. Files above it are never opened, so a result set
* is complete only for files at or under this size. Absent on hosts that do not report it.
*/
// COMPAT(workspaceContentSearchMaxFileBytes): added in v0.8.0, remove optionality after
// 2027-03-10 once the daemon floor reports it.
maxFileBytes: z.number().int().positive().optional(),
}),
z.object({
status: z.literal("error"),
code: z.enum(["missing_rg", "timeout", "invalid_query", "unavailable", "busy", "cancelled"]),
message: z.string(),
}),
]);
export const WorkspaceContentSearchRequestSchema = z.object({
type: z.literal("fs.content.search.request"),
requestId: z.string(),
cwd: z.string(),
query: z.string(),
});
export const WorkspaceContentCancelRequestSchema = z.object({
type: z.literal("fs.content.cancel.request"),
requestId: z.string(),
searchRequestId: z.string(),
});
export const WorkspaceContentSearchResponseSchema = z.object({
type: z.literal("fs.content.search.response"),
payload: z.object({ requestId: z.string(), result: WorkspaceContentSearchResultSchema }),
});
export const WorkspaceContentCancelResponseSchema = z.object({
type: z.literal("fs.content.cancel.response"),
payload: z.object({ requestId: z.string(), searchRequestId: z.string() }),
});
export type WorkspaceContentMatch = z.infer<typeof WorkspaceContentMatchSchema>;
export type WorkspaceContentSearchResult = z.infer<typeof WorkspaceContentSearchResultSchema>;
export const FileSubscribeRequestSchema = z.object({
type: z.literal("fs.file.subscribe.request"),
cwd: z.string(),
@@ -3262,8 +3211,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WorkspaceClearAttentionRequestSchema,
WorkspaceMarkUnreadRequestSchema,
FileExplorerRequestSchema,
WorkspaceContentSearchRequestSchema,
WorkspaceContentCancelRequestSchema,
FileSubscribeRequestSchema,
FileUnsubscribeRequestSchema,
FileWriteRequestSchema,
@@ -3569,7 +3516,6 @@ export const ServerInfoStatusPayloadSchema = z
workspaceRecovery: z.boolean().optional(),
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: z.boolean().optional(),
workspaceContentSearch: z.boolean().optional(),
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
@@ -6671,8 +6617,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeArchiveResponseSchema,
CreatePaseoWorktreeResponseSchema,
FileExplorerResponseSchema,
WorkspaceContentSearchResponseSchema,
WorkspaceContentCancelResponseSchema,
FileSubscribeResponseSchema,
FileUnsubscribeResponseSchema,
FileWriteResponseSchema,
@@ -2885,6 +2885,9 @@ export class AgentManager {
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
const agent = this.requireAgent(agentId);
if (agent.inFlightPermissionResponses.has(requestId)) {
throw new Error("A response to this permission request is already being submitted");
}
agent.inFlightPermissionResponses.add(requestId);
try {
@@ -3079,8 +3082,11 @@ export class AgentManager {
epoch: this.timelineStore.getEpoch(agentId),
});
}
await this.refreshRuntimeInfo(agent);
// Rewind stages provider events under the run lock; publish its final state directly.
this.refreshSessionPersistence(agent);
await this.refreshSessionState(agent, { emit: false });
await this.persistSnapshot(agent);
this.emitState(agent, { persist: false });
this.logger.info(
{ agentId, provider: agent.provider, messageId, mode },
"agent.rewind.complete",
@@ -4231,14 +4237,18 @@ export class AgentManager {
private onStreamThreadStarted(agent: ActiveManagedAgent): void {
const previousSessionId = agent.persistence?.sessionId ?? null;
this.refreshSessionPersistence(agent);
if (agent.persistence?.sessionId !== previousSessionId) {
this.emitState(agent);
}
void this.refreshRuntimeInfo(agent);
}
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
const handle = agent.session.describePersistence();
if (handle) {
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
if (agent.persistence?.sessionId !== previousSessionId) {
this.emitState(agent);
}
}
void this.refreshRuntimeInfo(agent);
}
private async onStreamTimelineEvent(params: {
@@ -4443,6 +4453,7 @@ export class AgentManager {
): void {
const hadPendingPermissions = agent.pendingPermissions.size > 0;
agent.pendingPermissions.set(event.request.id, event.request);
this.refreshSessionPersistence(agent);
if (!hadPendingPermissions && !agent.internal) {
this.broadcastAgentAttention(agent, "permission");
}
@@ -4457,6 +4468,7 @@ export class AgentManager {
}): void {
const { agent, event, options, flags } = params;
agent.pendingPermissions.delete(event.requestId);
this.refreshSessionPersistence(agent);
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
agent.bufferedPermissionResolutions.set(event.requestId, event);
flags.shouldDispatchEvent = false;
@@ -24,6 +24,12 @@ interface TestClaudeSession {
close(): Promise<void>;
}
function isLoadingCompactionEvent(event: AgentStreamEvent): boolean {
return (
event.type === "timeline" && event.item.type === "compaction" && event.item.status === "loading"
);
}
function isPermissionResolvedEvent(
event: AgentStreamEvent,
): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> {
@@ -1981,6 +1987,15 @@ describe("ClaudeAgentSession context window usage", () => {
};
}
function createCompactingStatus(): Record<string, unknown> {
return {
type: "system",
subtype: "status",
status: "compacting",
session_id: "session-1",
};
}
test("emits turn_started before the submitted user message", async () => {
const session = await createSessionForTurns([[]]);
const events: AgentStreamEvent[] = [];
@@ -2857,6 +2872,110 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("repeated compacting statuses open a single compaction marker", async () => {
const session = await createSessionForTurns([
[
createCompactingStatus(),
createCompactingStatus(),
createCompactingStatus(),
createCompactBoundary(),
createCompactingStatus(),
createCompactingStatus(),
createCompactBoundary(),
createSuccessResult(),
],
]);
try {
const events = await collectStreamEvents(session, "compact twice");
const compactions = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "compaction" ? [event.item.status] : [],
);
expect(compactions).toEqual(["loading", "completed", "loading", "completed"]);
} finally {
await session.close();
}
});
test("a compaction abandoned mid-turn does not suppress the next compaction marker", async () => {
// The first turn starts compacting and then ends without ever reaching a
// compact_boundary, so the marker it opened is never resolved.
const session = await createSessionForTurns([
[createCompactingStatus(), createSuccessResult()],
[createCompactingStatus(), createSuccessResult()],
]);
try {
const abandonedTurn = await collectStreamEvents(session, "abandoned compaction");
expect(abandonedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
const nextTurn = await collectStreamEvents(session, "next compaction");
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
} finally {
await session.close();
}
});
test("an interrupted compaction does not suppress the next compaction marker", async () => {
// The first turn starts compacting and is then interrupted, so it never reaches a
// compact_boundary and the marker it opened is never resolved.
const session = await createSessionForTurns([
[createCompactingStatus()],
[createCompactingStatus(), createSuccessResult()],
]);
try {
const interruptedTurn: AgentStreamEvent[] = [];
const streaming = (async () => {
for await (const event of streamSession(session, "interrupted compaction")) {
interruptedTurn.push(event);
}
})();
await vi.waitFor(() => {
expect(interruptedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
});
await session.interrupt();
await streaming;
expect(interruptedTurn).toContainEqual(
expect.objectContaining({ type: "turn_canceled", provider: "claude" }),
);
const nextTurn = await collectStreamEvents(session, "next compaction");
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
} finally {
await session.close();
}
});
test("a compaction abandoned in an autonomous turn does not suppress the next marker", async () => {
// Trailing output after the foreground result opens an autonomous turn, which starts
// compacting and is then ended by the next foreground turn, never reaching a boundary.
const session = await createSessionForTurns([
[createSuccessResult(), createMessageStartEvent(), createCompactingStatus()],
[createCompactingStatus(), createSuccessResult()],
]);
try {
const observed: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => {
observed.push(event);
});
await collectStreamEvents(session, "foreground turn");
await vi.waitFor(() => {
expect(observed.filter(isLoadingCompactionEvent)).toHaveLength(1);
});
unsubscribe();
const nextTurn = await collectStreamEvents(session, "next compaction");
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
} finally {
await session.close();
}
});
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
const session = await createSessionForTest();
@@ -2095,6 +2095,7 @@ class ClaudeAgentSession implements AgentSession {
private lastOptionsModel: string | null = null;
private lastRuntimeModel: string | null = null;
private compacting = false;
private compactionMarkerOpen = false;
private queryPumpPromise: Promise<void> | null = null;
private queryRestartNeeded = false;
private pendingInterruptAbort = false;
@@ -3572,6 +3573,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundInput = null;
this.cancelCurrentTurn = null;
this.activeTurnHasAssistantText = false;
this.compactionMarkerOpen = false;
this.syncTurnState("foreground turn terminal");
}
@@ -3583,6 +3585,7 @@ class ClaudeAgentSession implements AgentSession {
}
if (terminalSeen) {
this.compactionMarkerOpen = false;
if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
@@ -3624,6 +3627,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.activeTurnHasAssistantText = false;
this.compactionMarkerOpen = false;
this.syncTurnState("autonomous turn completed");
}
@@ -4260,15 +4264,22 @@ class ClaudeAgentSession implements AgentSession {
const status = toObjectRecord(message)?.status;
if (status === "compacting") {
this.compacting = true;
events.push({
type: "timeline",
item: { type: "compaction", status: "loading" },
provider: "claude",
});
// Claude Code repeats this status every 30 seconds until the compaction
// finishes. Each repeat used to open its own marker, and the app only ever
// resolves one of them, so the rest stayed on "Compacting..." forever.
if (!this.compactionMarkerOpen) {
this.compactionMarkerOpen = true;
events.push({
type: "timeline",
item: { type: "compaction", status: "loading" },
provider: "claude",
});
}
}
return;
}
if (message.subtype === "compact_boundary") {
this.compactionMarkerOpen = false;
const compactMetadata = readCompactionMetadata(message);
events.push({
type: "timeline",
@@ -52,6 +52,7 @@ import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js";
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
import {
mapCodexToolCallEnvelope,
mapCodexToolCallFromThreadItem,
@@ -1845,6 +1846,17 @@ function mapCodexThreadImageItem(
);
}
function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem {
const question = codexAsyncQuestionToTimeline(item);
if (question) return question;
const messageId = nonEmptyString(item.id);
return {
type: "assistant_message",
text: typeof item.text === "string" ? item.text : "",
...(messageId ? { messageId } : {}),
};
}
export function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
@@ -1871,14 +1883,8 @@ export function threadItemToTimeline(
switch (normalizedType) {
case "userMessage":
return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage);
case "agentMessage": {
const messageId = nonEmptyString(normalizedItem.id);
return {
type: "assistant_message",
text: typeof normalizedItem.text === "string" ? normalizedItem.text : "",
...(messageId ? { messageId } : {}),
};
}
case "agentMessage":
return mapCodexAgentMessage(normalizedItem);
case "plan":
return mapCodexThreadPlanItem(normalizedItem);
case "reasoning":
@@ -3293,6 +3299,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly logger: Logger;
private readonly config: AgentSessionConfig;
private readonly asyncQuestions: CodexAsyncQuestions;
private currentMode: string;
private hasWorkflowModeOverride: boolean;
private readonly providerOptions: CodexProviderOptions;
@@ -3405,6 +3412,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID;
this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {});
this.config = config;
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = "fast";
@@ -3799,6 +3807,9 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.resetCodexUserMessageTurns();
for (const entry of timeline) {
if (entry.item.type === "tool_call" && entry.item.name === "request_user_input_async") {
entry.item = this.asyncQuestions.timeline(entry.item.callId) ?? entry.item;
}
if (entry.item.type === "user_message") {
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
}
@@ -4464,13 +4475,16 @@ export class CodexAppServerAgentSession implements AgentSession {
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values());
return [...this.pendingPermissions.values(), ...this.asyncQuestions.pending()];
}
async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
if (this.asyncQuestions.hasPending(requestId)) {
return this.respondToAsyncQuestion(requestId, response);
}
const pending = this.pendingPermissionHandlers.get(requestId);
if (!pending) {
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
@@ -4564,6 +4578,33 @@ export class CodexAppServerAgentSession implements AgentSession {
pending.resolve({ answers: {} });
}
private async respondToAsyncQuestion(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
const prepared = this.asyncQuestions.prepareResponse(requestId, response);
let followUpPrompt = prepared.prompt;
const expectedTurnId = this.activeForegroundTurnId;
if (prepared.prompt && expectedTurnId) {
const result = await this.steerActiveTurn(prepared.prompt, {
expectedTurnId,
clientMessageId: randomUUID(),
});
if (result.status !== "accepted") {
throw new Error("The active Codex turn changed. Retry sending your answer.");
}
followUpPrompt = undefined;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() });
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: response,
});
return followUpPrompt ? { followUpPrompt } : undefined;
}
private handlePlanPermissionResponse(params: {
requestId: string;
response: AgentPermissionResponse;
@@ -4700,6 +4741,7 @@ export class CodexAppServerAgentSession implements AgentSession {
toolPolicy: this.config.toolPolicy,
systemPrompt: this.config.systemPrompt,
mcpServers: this.config.mcpServers,
asyncQuestions: this.asyncQuestions.serialize(),
},
};
}
@@ -4729,6 +4771,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.persistedHistory = [];
this.historyPending = false;
await this.loadPersistedHistory();
this.reconcileAsyncQuestionsAfterRewind();
},
});
}
@@ -5903,6 +5946,7 @@ export class CodexAppServerAgentSession implements AgentSession {
error: parsed.errorMessage ?? "Codex turn failed",
});
} else if (parsed.status === "interrupted") {
this.dismissInterruptedAsyncQuestions();
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else {
if (this.planModeEnabled && this.latestPlanResult?.text) {
@@ -6255,6 +6299,43 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private receiveAsyncQuestion(threadId: string | null, item: unknown): void {
if (threadId !== this.currentThreadId) return;
const request = this.asyncQuestions.receive(item);
if (request)
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
}
private dismissInterruptedAsyncQuestions(): void {
const resolution: AgentPermissionResponse = { behavior: "deny", message: "Interrupted" };
for (const request of this.asyncQuestions.pending()) {
const prepared = this.asyncQuestions.prepareResponse(request.id, resolution);
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() });
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId: request.id,
resolution,
});
}
}
private reconcileAsyncQuestionsAfterRewind(): void {
const retainedIds = new Set(
this.persistedHistory.flatMap(({ item }) =>
item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [],
),
);
for (const requestId of this.asyncQuestions.retain(retainedIds)) {
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: { behavior: "deny", message: "Removed by rewind" },
});
}
}
private handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): void {
@@ -6265,6 +6346,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
return;
@@ -0,0 +1,436 @@
import { tmpdir } from "node:os";
import { expect, test } from "vitest";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
waitForNextEvent,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { AgentManager } from "../agent-manager.js";
const questionItem = {
type: "agentMessage",
id: "async-question-1",
text: "Which color?\n- Blue\n- Green",
phase: "final_answer",
delivery: "async",
questions: [{ title: "Which color?", options: ["Blue", "Green"] }],
};
async function setup(metadata?: Record<string, unknown>, rejectSteer = false) {
const appServer = createFakeCodexAppServer({
"turn/interrupt": () => ({}),
"turn/steer": () => {
if (rejectSteer) return { __jsonRpcError: { code: -32000, message: "Delivery failed" } };
return { turnId: "native-turn" };
},
"thread/read": () => ({ thread: { id: "thread-1", turns: [] } }),
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" },
metadata ? { sessionId: "thread-1", metadata } : null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.startTurn("Help me choose a color while you inspect the project.");
const started = waitForNextEvent(session, "turn_started");
appServer.startsTurn({ threadId: "thread-1", turnId: "native-turn" });
await started;
async function ask() {
const shown = waitForTimelineToolCall(session, questionItem.id);
appServer.child.stdout.write(
JSON.stringify({
method: "item/completed",
params: { threadId: "thread-1", turnId: "native-turn", item: questionItem },
}) + "\n",
);
await shown;
}
async function say(text: string) {
const shown = waitForNextEvent(
session,
"timeline",
(event) => event.item.type === "assistant_message" && event.item.text.includes(text),
);
appServer.says({ threadId: "thread-1", text });
await shown;
}
async function finish(status: "completed" | "interrupted" = "completed") {
const finished = waitForNextEvent(
session,
status === "completed" ? "turn_completed" : "turn_canceled",
);
appServer.completeTurn({ status });
await finished;
}
function allowAnswers() {
rejectSteer = false;
}
return { session, appServer, events, ask, say, finish, allowAnswers };
}
const answer = { behavior: "allow" as const, updatedInput: { answers: { "Question 1": "Green" } } };
async function setupRewind(fail = false) {
const records = [
{ item: { ...questionItem, id: "earlier-pending" } },
{ item: { ...questionItem, id: "earlier-answered" }, resolution: ["Blue"] },
{ item: { ...questionItem, id: "removed-pending" } },
{ item: { ...questionItem, id: "removed-answered" }, resolution: ["Green"] },
];
function userMessage(id: string) {
return { type: "userMessage", id, content: [{ type: "text", text: id }] };
}
const turns = [
{
id: "earlier-turn",
items: [userMessage("earlier"), ...records.slice(0, 2).map((r) => r.item)],
},
{
id: "removed-turn",
items: [userMessage("rewind-here"), ...records.slice(2).map((r) => r.item)],
},
];
const appServer = createFakeCodexAppServer({
"thread/read": (params) => {
const { threadId } = params as { threadId: string };
return {
thread: { id: threadId, turns: threadId === "thread-1" ? turns : turns.slice(0, 1) },
};
},
...(fail
? {
"thread/rollback": () => ({ __jsonRpcError: { code: -32000, message: "Rewind failed" } }),
}
: {}),
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" },
{ sessionId: "thread-1", metadata: { asyncQuestions: records } },
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
return { session, events, records };
}
test("rewind removes only questions outside the remaining history, including after resume", async () => {
const { session, events, records } = await setupRewind();
let metadata: Record<string, unknown> | undefined;
try {
await session.revertConversation({ messageId: "rewind-here" });
expect(session.getPendingPermissions().map((p) => p.id)).toEqual([
"permission-earlier-pending",
]);
metadata = session.describePersistence()!.metadata;
expect(metadata.asyncQuestions).toEqual(
records.slice(0, 2).map((record) => ({
resolution: record.resolution,
item: {
type: "agentMessage",
id: record.item.id,
delivery: "async",
questions: questionItem.questions,
},
})),
);
expect(events.filter((e) => e.type === "permission_resolved")).toEqual([
expect.objectContaining({ requestId: "permission-removed-pending" }),
]);
const history = [];
for await (const event of session.streamHistory()) history.push(event);
expect(
history.some(
(e) =>
e.type === "timeline" &&
e.item.type === "tool_call" &&
e.item.callId === "removed-pending",
),
).toBe(false);
} finally {
await session.close();
}
const resumed = await setup(metadata);
try {
expect(resumed.session.getPendingPermissions().map((p) => p.id)).toEqual([
"permission-earlier-pending",
]);
} finally {
await resumed.session.close();
}
});
test("failed rewind preserves question state", async () => {
const { session, events } = await setupRewind(true);
try {
const before = session.describePersistence();
await expect(session.revertConversation({ messageId: "rewind-here" })).rejects.toThrow(
"Rewind failed",
);
expect(session.describePersistence()).toEqual(before);
expect(session.getPendingPermissions().map((p) => p.id)).toEqual([
"permission-earlier-pending",
"permission-removed-pending",
]);
expect(events.some((e) => e.type === "permission_resolved")).toBe(false);
} finally {
await session.close();
}
});
async function manage(session: CodexAppServerAgentSession) {
const manager = new AgentManager({
clients: {
codex: {
provider: "codex",
capabilities: session.capabilities,
createSession: async () => session,
resumeSession: async () => session,
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
},
},
logger: createTestLogger(),
});
const agent = await manager.createAgent(
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4" },
undefined,
{ workspaceId: undefined },
);
return { manager, agent };
}
test("manager publishes and saves the provider state after rewind", async () => {
const { session } = await setupRewind();
const { manager, agent } = await manage(session);
try {
await manager.rewind(agent.id, "rewind-here", "conversation");
const snapshot = manager.getAgent(agent.id)!;
expect(Array.from(snapshot.pendingPermissions.keys())).toEqual(["permission-earlier-pending"]);
expect(snapshot.persistence).toEqual(session.describePersistence());
expect(snapshot.persistence?.sessionId).toBe("forked-thread");
} finally {
await manager.closeAgent(agent.id);
}
});
test("manager snapshots capture pending and answered question state before the turn ends", async () => {
const { session, ask } = await setup();
const { manager, agent } = await manage(session);
try {
await ask();
const [permission] = session.getPendingPermissions();
expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([
{
item: {
type: "agentMessage",
id: questionItem.id,
delivery: "async",
questions: questionItem.questions,
},
},
]);
await manager.respondToPermission(agent.id, permission.id, answer);
expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([
{
item: {
type: "agentMessage",
id: questionItem.id,
delivery: "async",
questions: questionItem.questions,
},
resolution: ["Green"],
},
]);
} finally {
await manager.closeAgent(agent.id);
}
});
test("concurrent answers deliver only one response to the active Codex turn", async () => {
const { session, appServer, ask } = await setup();
const { manager, agent } = await manage(session);
try {
await ask();
const [permission] = session.getPendingPermissions();
const results = await Promise.allSettled([
manager.respondToPermission(agent.id, permission.id, answer),
manager.respondToPermission(agent.id, permission.id, answer),
]);
expect(results.map((result) => result.status)).toEqual(["fulfilled", "rejected"]);
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toHaveLength(
1,
);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await manager.closeAgent(agent.id);
}
});
test("a failed submission releases the request so the user can retry", async () => {
const { session, ask, allowAnswers } = await setup(undefined, true);
const { manager, agent } = await manage(session);
try {
await ask();
const [permission] = session.getPendingPermissions();
await expect(manager.respondToPermission(agent.id, permission.id, answer)).rejects.toThrow(
"Delivery failed",
);
expect(session.getPendingPermissions().map((request) => request.id)).toEqual([permission.id]);
allowAnswers();
await manager.respondToPermission(agent.id, permission.id, answer);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await manager.closeAgent(agent.id);
}
});
test("shows an async question, keeps streaming, and delivers its answer without interrupting", async () => {
const { session, appServer, events, ask, say } = await setup();
try {
await ask();
const [permission] = session.getPendingPermissions();
expect(permission).toMatchObject({
kind: "question",
input: {
questions: [
{
header: "Question 1",
question: "Which color?",
isOther: true,
options: [{ label: "Blue" }, { label: "Green" }],
},
],
},
});
await say("I am still inspecting the project.");
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("still inspecting"),
),
).toBe(true);
await session.respondToPermission(permission.id, answer);
expect(session.getPendingPermissions()).toEqual([]);
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toMatchObject(
[
{
params: {
expectedTurnId: "native-turn",
clientUserMessageId: expect.any(String),
input: [{ type: "text", text: expect.stringContaining("Which color?\nGreen") }],
},
},
],
);
expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual(
[],
);
expect(
events.some(
(event) => event.type === "permission_resolved" && event.requestId === permission.id,
),
).toBe(true);
} finally {
await session.close();
}
});
test("keeps a failed answer pending for retry", async () => {
const { session, ask } = await setup(undefined, true);
try {
await ask();
const [permission] = session.getPendingPermissions();
expect(permission).toBeDefined();
await expect(session.respondToPermission(permission.id, answer)).rejects.toThrow();
expect(session.getPendingPermissions()).toHaveLength(1);
} finally {
await session.close();
}
});
test("Stop dismisses async questions before cancellation and keeps them dismissed after resume", async () => {
const first = await setup();
let metadata: Record<string, unknown> | undefined;
try {
await first.ask();
expect(first.session.getPendingPermissions()).toHaveLength(1);
first.session.subscribe((event) => {
if (event.type === "turn_canceled") {
metadata = first.session.describePersistence()!.metadata;
}
});
await first.session.interrupt();
await first.finish("interrupted");
expect(first.session.getPendingPermissions()).toEqual([]);
expect(metadata?.asyncQuestions).toEqual([
expect.objectContaining({ resolution: "dismissed" }),
]);
expect(first.events).toContainEqual(
expect.objectContaining({
type: "permission_resolved",
requestId: "permission-async-question-1",
resolution: { behavior: "deny", message: "Interrupted" },
}),
);
} finally {
await first.session.close();
}
const resumed = await setup(metadata);
try {
await resumed.ask();
expect(resumed.session.getPendingPermissions()).toEqual([]);
} finally {
await resumed.session.close();
}
});
test("late answers use the existing follow-up prompt and dismissal does not interrupt", async () => {
const { session, appServer, ask, finish } = await setup();
try {
await ask();
const [permission] = session.getPendingPermissions();
expect(permission).toBeDefined();
await finish();
expect(session.getPendingPermissions()).toHaveLength(1);
expect(await session.respondToPermission(permission.id, answer)).toEqual({
followUpPrompt: "Answers to your questions:\n\nWhich color?\nGreen",
});
expect(appServer.requests().some((request) => request.method === "turn/interrupt")).toBe(false);
} finally {
await session.close();
}
});
test("restores unanswered questions and does not reopen answered questions on duplicate events", async () => {
const first = await setup();
let metadata: Record<string, unknown> | undefined;
try {
await first.ask();
expect(first.session.getPendingPermissions()).toHaveLength(1);
metadata = first.session.describePersistence()!.metadata;
} finally {
await first.session.close();
}
const resumed = await setup(metadata);
try {
const [permission] = resumed.session.getPendingPermissions();
expect(permission).toBeDefined();
await resumed.session.respondToPermission(permission.id, { behavior: "deny" });
await resumed.ask();
expect(resumed.session.getPendingPermissions()).toEqual([]);
expect(
resumed.appServer.requests().some((request) => request.method === "turn/interrupt"),
).toBe(false);
} finally {
await resumed.session.close();
}
});
@@ -0,0 +1,155 @@
import { z } from "zod";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
ToolCallTimelineItem,
} from "../../agent-sdk-types.js";
const QuestionSchema = z.object({
title: z.string().trim().min(1),
options: z.array(z.string().min(1)).nullish(),
});
const ItemSchema = z.object({
type: z.literal("agentMessage"),
id: z.string().min(1),
delivery: z.literal("async"),
questions: z.array(QuestionSchema).min(1),
});
const RecordSchema = z.object({
item: ItemSchema,
resolution: z.union([z.literal("dismissed"), z.array(z.string())]).optional(),
});
type QuestionRecord = z.infer<typeof RecordSchema>;
function requestId(itemId: string): string {
return `permission-${itemId}`;
}
function toPermission(record: QuestionRecord): AgentPermissionRequest {
return {
id: requestId(record.item.id),
provider: "codex",
name: "request_user_input_async",
kind: "question",
title: "Question",
input: {
questions: record.item.questions.map((question, index) => ({
id: String(index),
header: `Question ${index + 1}`,
question: question.title,
options: (question.options ?? []).map((label) => ({ label })),
isOther: true,
})),
},
};
}
function toTimeline(record: QuestionRecord): ToolCallTimelineItem {
const answers = Array.isArray(record.resolution) ? record.resolution : undefined;
return {
type: "tool_call",
callId: record.item.id,
name: "request_user_input_async",
status: "completed",
error: null,
detail: {
type: "plain_text",
icon: "brain",
text:
record.item.questions
.map((question, index) =>
[question.title, answers ? answers[index] : (question.options ?? []).join(", ")]
.filter(Boolean)
.join("\n"),
)
.join("\n\n") + (record.resolution === "dismissed" ? "\n\nDismissed" : ""),
},
};
}
export function codexAsyncQuestionToTimeline(item: unknown): ToolCallTimelineItem | null {
const parsed = ItemSchema.safeParse(item);
return parsed.success ? toTimeline({ item: parsed.data }) : null;
}
/** Codex emits these as completed messages; the outstanding answer belongs to the session. */
export class CodexAsyncQuestions {
private readonly records = new Map<string, QuestionRecord>();
constructor(saved: unknown) {
const parsed = z.array(RecordSchema).safeParse(saved);
if (parsed.success) {
for (const record of parsed.data) this.records.set(requestId(record.item.id), record);
}
}
receive(item: unknown): AgentPermissionRequest | null {
const parsed = ItemSchema.safeParse(item);
if (!parsed.success || this.records.has(requestId(parsed.data.id))) return null;
const record = { item: parsed.data };
this.records.set(requestId(parsed.data.id), record);
return toPermission(record);
}
pending(): AgentPermissionRequest[] {
return Array.from(this.records.values())
.filter((record) => record.resolution === undefined)
.map(toPermission);
}
hasPending(id: string): boolean {
const record = this.records.get(id);
return record !== undefined && record.resolution === undefined;
}
prepareResponse(
id: string,
response: AgentPermissionResponse,
): { prompt?: string; complete: () => ToolCallTimelineItem } {
const record = this.records.get(id);
if (!record || record.resolution !== undefined)
throw new Error("Question is no longer pending");
let resolution: QuestionRecord["resolution"] = "dismissed";
let prompt: string | undefined;
if (response.behavior === "allow") {
const answers = z.record(z.string(), z.string()).parse(response.updatedInput?.answers);
resolution = record.item.questions.map((_, index) => {
const answer = answers[`Question ${index + 1}`]?.trim();
if (!answer) throw new Error(`Answer Question ${index + 1} before submitting`);
return answer;
});
const values = resolution;
prompt =
"Answers to your questions:\n\n" +
record.item.questions
.map((question, index) => `${question.title}\n${values[index]}`)
.join("\n\n");
}
return {
prompt,
complete: () => {
record.resolution = resolution;
return toTimeline(record);
},
};
}
timeline(itemId: string): ToolCallTimelineItem | null {
const record = this.records.get(requestId(itemId));
return record ? toTimeline(record) : null;
}
retain(itemIds: ReadonlySet<string>): string[] {
const removedPendingIds: string[] = [];
for (const [id, record] of this.records) {
if (itemIds.has(record.item.id)) continue;
this.records.delete(id);
if (record.resolution === undefined) removedPendingIds.push(id);
}
return removedPendingIds;
}
serialize(): unknown {
return Array.from(this.records.values());
}
}
@@ -618,7 +618,7 @@ function toJsonObject(value: unknown): JsonObject {
type StreamEventType = AgentStreamEvent["type"];
type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent, { type: TType }>;
function waitForNextEvent<TType extends StreamEventType>(
export function waitForNextEvent<TType extends StreamEventType>(
session: AgentSession,
type: TType,
accepts?: (event: StreamEventOfType<TType>) => boolean,
@@ -81,8 +81,6 @@ const INBOUND_PERMISSION = {
fetch_workspaces_request: ["workspace.read", "hub.execute"],
"file.upload.request": "workspace.write",
file_download_token_request: "workspace.read",
"fs.content.search.request": "workspace.read",
"fs.content.cancel.request": "workspace.read",
file_explorer_request: "workspace.read",
"forge.search.request": "workspace.read",
"fs.entry.create.request": "workspace.write",
@@ -294,8 +292,6 @@ const OUTBOUND_PERMISSION = {
fetch_workspaces_response: ["workspace.read", "hub.execute"],
"file.upload.response": "workspace.write",
file_download_token_response: "workspace.read",
"fs.content.search.response": "workspace.read",
"fs.content.cancel.response": "workspace.read",
file_explorer_response: "workspace.read",
"forge.search.response": "workspace.read",
"fs.entry.create.response": "workspace.write",
@@ -1,42 +0,0 @@
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { expect, test } from "vitest";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
test("real client searches saved contents, cancels host work and can search again", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "paseo-content-rpc-"));
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await writeFile(path.join(cwd, "a.ts"), "const original = 'NEEDLE';\n");
await client.connect();
await client.fetchAgents();
const result = await client.searchWorkspaceContent({ cwd, query: "needle" });
expect(
result.status === "ok" && result.matches.map((match) => [match.path, match.text]),
).toEqual([["a.ts", "NEEDLE"]]);
const controller = new AbortController();
const searching = client.searchWorkspaceContent(
{ cwd, query: "absent" },
{ signal: controller.signal },
);
controller.abort();
expect(await searching).toMatchObject({ status: "error", code: "cancelled" });
// A complete empty result also reports the per-file ceiling the host applied, so the client
// can say which files it never opened rather than claiming it read the whole workspace.
expect(await client.searchWorkspaceContent({ cwd, query: "missing" })).toEqual({
status: "ok",
matches: [],
limited: false,
maxFileBytes: 1_048_576,
});
const preview = await client.readFile(cwd, "a.ts", undefined, 1_048_576);
expect(new TextDecoder().decode(preview.bytes)).toBe("const original = 'NEEDLE';\n");
} finally {
await client.close();
await daemon.close();
await rm(cwd, { recursive: true, force: true });
}
}, 30000);
@@ -592,44 +592,3 @@ describe("file explorer service", () => {
}
});
});
it("enforces the caller byte ceiling at the opened file for inline and streaming reads", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-read-budget-"));
try {
await writeFile(path.join(root, "growing.txt"), "small");
const budget = (await stat(path.join(root, "growing.txt"))).size;
await appendFile(path.join(root, "growing.txt"), " grew after precheck");
await expect(
readExplorerFile({ root, relativePath: "growing.txt", maxBytes: budget }),
).rejects.toThrow("File is too large to display");
await expect(
streamExplorerFile({ root, relativePath: "growing.txt", maxBytes: budget }, async () => {
throw new Error("Must reject before delivering content");
}),
).rejects.toThrow("File is too large to display");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("preserves literal whitespace in existing file identities through read and write", async () => {
const root = await createTempDir("paseo-literal-path-");
try {
await writeFile(path.join(root, " leading.txt"), "needle");
await writeFile(path.join(root, "leading.txt"), "different file");
const file = await readExplorerFile({ root, relativePath: " leading.txt" });
expect(file.content).toBe("needle");
const written = await writeExplorerFile({
root,
relativePath: " leading.txt",
content: "updated",
expectedModifiedAt: file.modifiedAt,
expectedRevision: file.revision,
});
expect(written.status).toBe("written");
expect(await readFile(path.join(root, " leading.txt"), "utf8")).toBe("updated");
expect(await readFile(path.join(root, "leading.txt"), "utf8")).toBe("different file");
} finally {
await rm(root, { recursive: true, force: true });
}
});
@@ -2,7 +2,7 @@ import { constants, promises as fs, type BigIntStats, type Stats } from "fs";
import type { FileHandle } from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import { expandUserPath, resolvePathFromBase, toWorkspaceRelativePath } from "../path-utils.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import { runGitCommand } from "../../utils/run-git-command.js";
export type ExplorerEntryKind = "file" | "directory";
@@ -15,7 +15,6 @@ export interface ListDirectoryParams {
}
export interface ReadFileParams {
maxBytes?: number;
root: string;
relativePath: string;
}
@@ -194,9 +193,8 @@ export async function listDirectoryEntries({
export async function readExplorerFile({
root,
relativePath,
maxBytes,
}: ReadFileParams): Promise<FileExplorerFile> {
const file = await readExplorerFileBytes({ root, relativePath, maxBytes });
const file = await readExplorerFileBytes({ root, relativePath });
if (file.kind === "image") {
return {
@@ -238,7 +236,6 @@ export async function readExplorerFile({
export async function readExplorerFileBytes({
root,
relativePath,
maxBytes,
}: ReadFileParams): Promise<FileExplorerFileBytes> {
const filePath = await resolveScopedPath({ root, relativePath });
const handle = await openFileForRead(filePath.resolvedPath);
@@ -250,8 +247,6 @@ export async function readExplorerFileBytes({
throw new Error("Requested path is not a file");
}
if (maxBytes !== undefined && stats.size > BigInt(maxBytes))
throw new Error("File is too large to display");
const ext = path.extname(filePath.resolvedPath).toLowerCase();
const basePayload = {
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
@@ -260,10 +255,7 @@ export async function readExplorerFileBytes({
revision: fileRevision(stats),
};
const chunks: Buffer[] = [];
for await (const chunk of readFileHandleChunks(handle, Number(stats.size), fileRevision(stats)))
chunks.push(Buffer.from(chunk));
const buffer = Buffer.concat(chunks);
const buffer = await handle.readFile();
if (ext in IMAGE_MIME_TYPES) {
return {
...basePayload,
@@ -297,7 +289,7 @@ export async function readExplorerFileBytes({
}
export async function streamExplorerFile(
{ root, relativePath, maxBytes }: ReadFileParams,
{ root, relativePath }: ReadFileParams,
consume: (file: FileExplorerFileStream) => Promise<void>,
): Promise<void> {
const filePath = await resolveScopedPath({ root, relativePath });
@@ -309,8 +301,6 @@ export async function streamExplorerFile(
throw new Error("Requested path is not a file");
}
if (maxBytes !== undefined && stats.size > BigInt(maxBytes))
throw new Error("File is too large to display");
const advertisedSize = Number(stats.size);
const advertisedRevision = fileRevision(stats);
const ext = path.extname(filePath.resolvedPath).toLowerCase();
@@ -871,7 +861,7 @@ function normalizeRelativePath({ root, targetPath }: { root: string; targetPath:
const normalizedRoot = expandUserPath(root);
const normalizedTarget = expandUserPath(targetPath);
const relative = path.relative(normalizedRoot, normalizedTarget);
return relative === "" ? "." : toWorkspaceRelativePath(relative);
return relative === "" ? "." : relative.split(path.sep).join("/");
}
function textMimeTypeForExtension(ext: string): string {
@@ -29,6 +29,7 @@ import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
import { runWithGitCommandPriority } from "../utils/run-git-command.js";
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
projectId?: string;
@@ -37,6 +38,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
export interface CreatePaseoWorktreeResult {
worktree: WorktreeConfig;
worktreeIncludeSummary?: WorktreeIncludeSummary;
intent: WorktreeCreationIntent;
workspace: PersistedWorkspaceRecord;
repoRoot: string;
@@ -117,6 +119,7 @@ async function createPaseoWorktreeWithPriority(
return {
worktree: createdWorktree.worktree,
worktreeIncludeSummary: createdWorktree.worktreeIncludeSummary,
intent: createdWorktree.intent,
workspace,
repoRoot: createdWorktree.repoRoot,
@@ -1,18 +0,0 @@
import { sep } from "node:path";
import { expect, test } from "vitest";
import { toWorkspaceRelativePath } from "./path-utils.js";
// Workspace-relative identities cross the wire with "/" whatever the host separator is, so a
// client never has to guess which backslash was a separator and which was a file name character.
test("renders a host-relative path with forward slashes", () => {
expect(toWorkspaceRelativePath(["src", "app.ts"].join(sep))).toBe("src/app.ts");
expect(toWorkspaceRelativePath("app.ts")).toBe("app.ts");
});
test.skipIf(sep === "\\")("leaves a POSIX file name containing a backslash intact", () => {
expect(toWorkspaceRelativePath("a\\b.txt")).toBe("a\\b.txt");
});
test.skipIf(sep !== "\\")("converts every Windows separator", () => {
expect(toWorkspaceRelativePath("src\\nested\\app.ts")).toBe("src/nested/app.ts");
});
+9 -16
View File
@@ -1,5 +1,5 @@
import { homedir } from "node:os";
import { isAbsolute, posix, resolve, sep, win32 } from "node:path";
import { isAbsolute, posix, resolve, win32 } from "node:path";
export function assertAbsolutePath(cwd: string): void {
if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) {
@@ -12,17 +12,19 @@ function hasHomePrefix(value: string): boolean {
}
export function expandUserPath(value: string): string {
if (hasHomePrefix(value)) {
return resolve(homedir(), value.slice(2));
const trimmed = value.trim();
if (hasHomePrefix(trimmed)) {
return resolve(homedir(), trimmed.slice(2));
}
return resolve(value);
return resolve(trimmed);
}
export function resolvePathFromBase(baseCwd: string, requestedPath: string): string {
if (hasHomePrefix(requestedPath) || isAbsolute(requestedPath)) {
return expandUserPath(requestedPath);
const trimmed = requestedPath.trim();
if (hasHomePrefix(trimmed) || isAbsolute(trimmed)) {
return expandUserPath(trimmed);
}
return resolve(baseCwd, requestedPath);
return resolve(baseCwd, trimmed);
}
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
@@ -38,12 +40,3 @@ export function isSameOrDescendantPath(basePath: string, candidatePath: string):
normalizedCandidate === normalizedBase || normalizedCandidate.startsWith(normalizedBase + "/")
);
}
/**
* Renders a host-relative path the way every workspace-relative identity crosses the wire: this
* host's separator becomes "/", and every other character survives, so a Unix file name that
* contains a backslash stays that file rather than becoming a directory on the client.
*/
export function toWorkspaceRelativePath(relative: string): string {
return relative.split(sep).join("/");
}
+1 -5
View File
@@ -873,6 +873,7 @@ export class Session {
logger: this.sessionLogger,
});
this.workspaceRecovery = createWorkspaceRecoveryService({
logger: this.sessionLogger,
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
@@ -2624,11 +2625,6 @@ export class Session {
source?: object,
): Promise<void> | undefined {
switch (msg.type) {
case "fs.content.search.request":
return this.workspaceFilesSession.handleContentSearch(msg);
case "fs.content.cancel.request":
this.workspaceFilesSession.handleContentCancel(msg);
return;
case "file_explorer_request":
return this.workspaceFilesSession.handleFileExplorerRequest(msg, source);
case "fs.file.subscribe.request":
@@ -1,4 +1,3 @@
import { searchWorkspaceContent } from "../../workspace-content-search/index.js";
import type pino from "pino";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import {
@@ -64,7 +63,6 @@ export interface WorkspaceFilesSessionOptions {
* the whole concern.
*/
export class WorkspaceFilesSession {
private readonly searches = new Map<string, AbortController>();
private readonly host: WorkspaceFilesSessionHost;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly logger: pino.Logger;
@@ -80,50 +78,6 @@ export class WorkspaceFilesSession {
this.fileObserver = options.fileObserver ?? workspaceFileObserver;
}
async handleContentSearch(
request: Extract<SessionInboundMessage, { type: "fs.content.search.request" }>,
): Promise<void> {
if (this.searches.size >= 2 || this.searches.has(request.requestId)) {
this.host.emit({
type: "fs.content.search.response",
payload: {
requestId: request.requestId,
result: {
status: "error",
code: "busy",
message: "Another search is still stopping — retry",
},
},
});
return;
}
const controller = new AbortController();
this.searches.set(request.requestId, controller);
try {
const result = await searchWorkspaceContent({
cwd: request.cwd,
query: request.query,
signal: controller.signal,
});
this.host.emit({
type: "fs.content.search.response",
payload: { requestId: request.requestId, result },
});
} finally {
this.searches.delete(request.requestId);
}
}
handleContentCancel(
request: Extract<SessionInboundMessage, { type: "fs.content.cancel.request" }>,
): void {
this.searches.get(request.searchRequestId)?.abort();
this.host.emit({
type: "fs.content.cancel.response",
payload: { requestId: request.requestId, searchRequestId: request.searchRequestId },
});
}
async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise<void> {
this.fileSubscriptions.get(request.subscriptionId)?.();
try {
@@ -260,14 +214,13 @@ export class WorkspaceFilesSession {
}
dispose(): void {
for (const controller of this.searches.values()) controller.abort();
for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe();
this.fileSubscriptions.clear();
}
async handleFileExplorerRequest(request: FileExplorerRequest, source?: object): Promise<void> {
const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request;
const cwd = workspaceCwd;
const cwd = workspaceCwd.trim();
if (!cwd) {
this.host.emit(
{
@@ -317,45 +270,41 @@ export class WorkspaceFilesSession {
}
}
if (request.acceptBinary && this.host.hasBinaryChannel()) {
await streamExplorerFile(
{ root: cwd, relativePath: requestedPath, maxBytes: request.maxBytes },
async (file) => {
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
await this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId,
metadata: {
mime: file.mimeType,
size: file.size,
encoding: file.encoding,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
}),
source,
);
for await (const chunk of file.chunks) {
await this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
opcode: FileTransferOpcode.FileChunk,
requestId,
metadata: {
mime: file.mimeType,
size: file.size,
encoding: file.encoding,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
payload: chunk,
}),
source,
);
for await (const chunk of file.chunks) {
await this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId,
payload: chunk,
}),
source,
);
}
await this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId,
}),
source,
);
},
);
}
await this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId,
}),
source,
);
});
} else {
const file = await readExplorerFile({
maxBytes: request.maxBytes,
root: cwd,
relativePath: requestedPath,
});
@@ -442,7 +391,7 @@ export class WorkspaceFilesSession {
async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise<void> {
const { cwd: workspaceCwd, path: requestedPath, requestId } = request;
const cwd = workspaceCwd;
const cwd = workspaceCwd.trim();
if (!cwd) {
this.host.emit({
type: "file_download_token_response",
@@ -76,6 +76,7 @@ function createHarness(input?: {
const directories = new Set(input?.directories ?? ["/repo"]);
const unarchived: string[] = [];
const service = createWorkspaceRecoveryService({
logger: { warn: () => undefined } as never,
paseoHome: input?.paseoHome ?? "/paseo-home",
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
getWorkspace: async (workspaceId) =>
@@ -135,6 +136,7 @@ describe("workspace recovery", () => {
const sourceSubdirectory = join(repoDir, "packages", "app");
mkdirSync(sourceSubdirectory, { recursive: true });
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
writeFileSync(join(repoDir, ".worktreeinclude"), "missing.local\n");
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
@@ -170,7 +172,9 @@ describe("workspace recovery", () => {
mainRepoRoot: repoDir,
});
const unarchived: string[] = [];
const warnings: unknown[][] = [];
const service = createWorkspaceRecoveryService({
logger: { warn: (...args: unknown[]) => warnings.push(args) } as never,
paseoHome,
worktreesRoot,
getWorkspace: async (workspaceId) =>
@@ -189,6 +193,15 @@ describe("workspace recovery", () => {
expect(existsSync(worktreeRoot)).toBe(true);
expect(existsSync(workspaceCwd)).toBe(true);
expect(unarchived).toEqual([workspace.workspaceId]);
expect(warnings).toEqual([
[
expect.objectContaining({
materialized: 0,
skipped: [expect.objectContaining({ raw: "missing.local", reason: "missing" })],
}),
"Worktree include completed with skipped entries during workspace recovery",
],
]);
});
test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => {
@@ -220,6 +233,7 @@ describe("workspace recovery", () => {
});
const unarchived: string[] = [];
const service = createWorkspaceRecoveryService({
logger: { warn: () => undefined } as never,
paseoHome,
worktreesRoot,
getWorkspace: async (workspaceId) =>
@@ -1,4 +1,5 @@
import { basename } from "node:path";
import type { Logger } from "pino";
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
import { runGitCommand } from "../../../utils/run-git-command.js";
@@ -59,6 +60,7 @@ type RecoveryPlan =
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
export function createWorkspaceRecoveryService(deps: {
logger: Logger;
paseoHome: string;
worktreesRoot?: string;
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
@@ -195,6 +197,16 @@ export function createWorkspaceRecoveryService(deps: {
worktreesRoot: deps.worktreesRoot,
});
recreatedWorktreePath = result.worktreePath;
if (result.worktreeIncludeSummary.skipped.length > 0) {
deps.logger.warn(
{
materialized: result.worktreeIncludeSummary.materialized,
skipped: result.worktreeIncludeSummary.skipped,
worktreePath: result.worktreePath,
},
"Worktree include completed with skipped entries during workspace recovery",
);
}
} catch (error) {
throw toWorktreeRequestError(error);
}
@@ -1712,7 +1712,6 @@ export class VoiceAssistantWebSocketServer {
workspaceRecovery: true,
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: true,
workspaceContentSearch: true,
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
@@ -1,227 +0,0 @@
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import { searchWorkspaceContent } from "./index.js";
const roots: string[] = [];
async function workspace(files: Record<string, string>) {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-content-search-"));
roots.push(root);
for (const [name, text] of Object.entries(files)) await writeFile(path.join(root, name), text);
return root;
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("searches saved untracked text literally, ignoring case and ignore rules in a non-Git workspace", async () => {
const cwd = await workspace({
"a.ts": "é🙂 NEEDLE needle\r\n",
"b.ts": "a.b AXB",
".hidden": "Needle",
".gitignore": "ignored\n",
ignored: "needle",
});
const result = await searchWorkspaceContent({ cwd, query: "needle" });
expect(result).toEqual({
status: "ok",
limited: false,
maxFileBytes: 1_048_576,
matches: [
{
path: ".hidden",
line: 1,
columnStart: 1,
columnEnd: 7,
text: "Needle",
snippet: "Needle",
snippetMatchStart: 0,
snippetMatchEnd: 6,
},
{
path: "a.ts",
line: 1,
columnStart: 5,
columnEnd: 11,
text: "NEEDLE",
snippet: "é🙂 NEEDLE needle",
snippetMatchStart: 4,
snippetMatchEnd: 10,
},
{
path: "a.ts",
line: 1,
columnStart: 12,
columnEnd: 18,
text: "needle",
snippet: "é🙂 NEEDLE needle",
snippetMatchStart: 11,
snippetMatchEnd: 17,
},
],
});
const literal = await searchWorkspaceContent({ cwd, query: "a.b" });
expect(literal.status === "ok" && literal.matches.map((m) => m.text)).toEqual(["a.b"]);
});
test("reports missing rg, empty search, cancellation and file/result limits", async () => {
const cwd = await workspace({
"many.txt": "needle\n".repeat(250),
oversized: "needle" + " ".repeat(1_048_576),
});
const result = await searchWorkspaceContent({ cwd, query: "needle" });
expect(result.status === "ok" && [result.matches.length, result.limited]).toEqual([200, true]);
expect(await searchWorkspaceContent({ cwd, query: "" })).toEqual({
status: "ok",
matches: [],
limited: false,
maxFileBytes: 1_048_576,
});
expect(await searchWorkspaceContent({ cwd, query: "x" }, { env: { PATH: "" } })).toMatchObject({
status: "error",
code: "missing_rg",
});
expect(
await searchWorkspaceContent({ cwd, query: "x", signal: AbortSignal.abort() }),
).toMatchObject({ status: "error", code: "cancelled" });
});
test("preserves Unicode case-fold matches and BOM offsets, rejects binary files and bounds long records", async () => {
const cwd = await workspace({
unicode: "\uFEFFé🙂 K k K ſ S s σ Σ ς\r\n",
binary: "needle\0binary",
long: "x".repeat(1_040_000) + "needle",
});
const kelvin = await searchWorkspaceContent({ cwd, query: "k" });
expect(kelvin.status === "ok" && kelvin.matches.map((m) => [m.text, m.columnStart])).toEqual([
["K", 5],
["k", 7],
["K", 9],
]);
const sigma = await searchWorkspaceContent({ cwd, query: "σ" });
expect(sigma.status === "ok" && sigma.matches.map((m) => m.text)).toEqual(["σ", "Σ", "ς"]);
const binary = await searchWorkspaceContent({ cwd, query: "needle" });
expect(binary.status === "ok" && binary.matches.map((m) => m.path)).toEqual(["long"]);
});
test("bounds escaped output and reports timeouts and invalid single-line queries", async () => {
const cwd = await workspace({ escaped: "\t".repeat(600_000) + "needle" });
expect(await searchWorkspaceContent({ cwd, query: "needle" })).toMatchObject({
status: "ok",
limited: true,
});
expect(await searchWorkspaceContent({ cwd, query: "x\ny" })).toMatchObject({
status: "error",
code: "invalid_query",
});
expect(await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs: 0 })).toMatchObject({
status: "error",
code: "timeout",
});
});
test("uses source lines across CR-only and mixed separators, including unmatched preceding lines", async () => {
const cwd = await workspace({
"cr.txt": "first\rneedle",
"mixed.txt": "unmatched\rprefix\nfirst\r🙂 NEEDLE\r\nend",
});
const result = await searchWorkspaceContent({ cwd, query: "needle" });
expect(
result.status === "ok" &&
result.matches.map(({ path: filePath, line, columnStart, columnEnd, text }) => ({
path: filePath,
line,
columnStart,
columnEnd,
text,
})),
).toEqual([
{ path: "cr.txt", line: 2, columnStart: 1, columnEnd: 7, text: "needle" },
{ path: "mixed.txt", line: 4, columnStart: 4, columnEnd: 10, text: "NEEDLE" },
]);
});
test("validates UTF-8 beyond the matching record and preserves an embedded BOM character", async () => {
const cwd = await workspace({ "valid.txt": "prefix \uFEFFneedle" });
await writeFile(
path.join(cwd, "invalid.txt"),
Buffer.from([110, 101, 101, 100, 108, 101, 10, 255]),
);
const invalid = await searchWorkspaceContent({ cwd, query: "needle" });
expect(invalid.status === "ok" && invalid.matches.map((match) => match.path)).toEqual([
"valid.txt",
]);
const embedded = await searchWorkspaceContent({ cwd, query: "\uFEFFneedle" });
expect(
embedded.status === "ok" && embedded.matches.map((match) => [match.text, match.columnStart]),
).toEqual([["\uFEFFneedle", 8]]);
});
test("states the per-file ceiling so a search that skipped an oversized file is not reported as complete", async () => {
const cwd = await workspace({ "large.txt": `needle${" ".repeat(1_048_576)}` });
expect(await searchWorkspaceContent({ cwd, query: "needle" })).toEqual({
status: "ok",
matches: [],
limited: false,
maxFileBytes: 1_048_576,
});
await writeFile(path.join(cwd, "small.txt"), "needle");
const withSmall = await searchWorkspaceContent({ cwd, query: "needle" });
expect(
withSmall.status === "ok" && [withSmall.matches.map((m) => m.path), withSmall.maxFileBytes],
).toEqual([["small.txt"], 1_048_576]);
});
// A backslash is an ordinary character in a Unix file name but a separator on Windows, so this
// file name only exists on POSIX. Windows separator handling is covered by toWorkspaceRelativePath,
// which runs on every platform.
test.skipIf(process.platform === "win32")(
"keeps a literal backslash in a saved file name exactly as the host reported it",
async () => {
const cwd = await workspace({ "a\\b.txt": "needle" });
const result = await searchWorkspaceContent({ cwd, query: "needle" });
expect(result.status === "ok" && result.matches.map((match) => match.path)).toEqual([
"a\\b.txt",
]);
},
);
test("converts coordinates in one pass, so a high-line-count file stays inside the budget", async () => {
// A supported saved shape: a million empty lines, then 200 literal matches. Converting each
// occurrence by decoding and splitting everything before it blocked for ~5.9s and answered "ok"
// after the five-second budget had already passed.
const timeoutMs = 5000;
const cwd = await workspace({
"sparse.txt": `${"\n".repeat(1_000_000)}${"needle\n".repeat(200)}`,
});
const started = Date.now();
const result = await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs });
const elapsed = Date.now() - started;
expect(result.status === "ok" && result.matches.length).toBe(200);
expect(result.status === "ok" && result.matches[0]).toMatchObject({
path: "sparse.txt",
line: 1_000_001,
columnStart: 1,
columnEnd: 7,
text: "needle",
snippet: "needle",
});
expect(result.status === "ok" && result.matches[199]).toMatchObject({ line: 1_000_200 });
// A successful answer must arrive inside the budget it claims to honour.
expect(elapsed, `succeeded after ${elapsed}ms against a ${timeoutMs}ms budget`).toBeLessThan(
timeoutMs,
);
// And with room to spare: the conversion is meant to be proportional to the file, not to the
// file times its occurrences.
expect(elapsed, `conversion took ${elapsed}ms`).toBeLessThan(2000);
});
test("stops converting when the deadline passes instead of answering late", async () => {
const cwd = await workspace({ "many.txt": `${"needle\n".repeat(200)}` });
expect(await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs: 0 })).toMatchObject({
status: "error",
code: "timeout",
});
});
@@ -1,345 +0,0 @@
import { stat } from "node:fs/promises";
import { sep } from "node:path";
import type { ChildProcess } from "node:child_process";
import type {
WorkspaceContentMatch,
WorkspaceContentSearchResult,
} from "@getpaseo/protocol/messages";
import { readExplorerFileBytes } from "../file-explorer/service.js";
import { spawnProcess } from "../../utils/spawn.js";
import { toWorkspaceRelativePath } from "../path-utils.js";
const MAX_RESULTS = 200;
const MAX_FILE_BYTES = 1_048_576;
const MAX_OUTPUT_BYTES = 4 * MAX_FILE_BYTES;
/** Searches the host's saved files; owns rg and every resource it allocates. */
export async function searchWorkspaceContent(
input: { cwd: string; query: string; signal?: AbortSignal },
options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
): Promise<WorkspaceContentSearchResult> {
if (input.signal?.aborted) return failure("cancelled", "Search cancelled");
if (
Buffer.byteLength(input.query) > 1024 ||
["\0", "\r", "\n"].some((character) => input.query.includes(character))
) {
return failure("invalid_query", "Search text must be one line, up to 1,024 bytes");
}
if (!input.query)
return { status: "ok", matches: [], limited: false, maxFileBytes: MAX_FILE_BYTES };
try {
if (!(await stat(input.cwd)).isDirectory())
return failure("unavailable", "Workspace directory is unavailable");
} catch {
return failure("unavailable", "Workspace directory is unavailable");
}
let child: ChildProcess;
try {
child = spawnProcess(
process.platform === "win32" ? "rg.exe" : "rg",
[
"--no-config",
"--json",
"--fixed-strings",
"--ignore-case",
"--hidden",
"--no-require-git",
"--glob",
"!.git",
"--max-filesize",
String(MAX_FILE_BYTES),
"--sort",
"path",
"--encoding",
"none",
"--",
input.query,
".",
],
{ cwd: input.cwd, shell: false, env: options.env, stdio: ["ignore", "pipe", "pipe"] },
);
} catch (cause) {
return spawnFailure(cause);
}
return new Promise((resolve) => {
const matches: RgMatch[] = [];
let pending: RgMatch[] = [];
let pendingPath = "";
let buffer = Buffer.alloc(0);
let outputBytes = 0;
let stderr = "";
let limited = false;
let stopped = false;
let error: WorkspaceContentSearchResult | null = null;
let killTimer: ReturnType<typeof setTimeout> | undefined;
const stop = () => {
if (stopped) return;
stopped = true;
child.kill();
killTimer = setTimeout(() => child.kill("SIGKILL"), 250);
killTimer.unref();
};
const abort = () => {
error = failure("cancelled", "Search cancelled");
stop();
};
const timedOut = () => {
error = failure("timeout", "Search timed out — refine your query or retry");
stop();
};
// Reading and converting each matched file is synchronous work between ticks, so the timer
// alone cannot end an operation that overruns inside it. Conversion checks the same deadline
// directly, and the budget covers the whole operation rather than only its search.
const deadline = Date.now() + (options.timeoutMs ?? 5000);
const timeout = setTimeout(timedOut, options.timeoutMs ?? 5000);
input.signal?.addEventListener("abort", abort, { once: true });
if (input.signal?.aborted) abort();
child.on("error", (cause) => {
error ??= spawnFailure(cause);
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr = (stderr + chunk.toString("utf8")).slice(0, 8192);
});
child.stdout?.on("data", (chunk: Buffer) => {
if (stopped) return;
outputBytes += chunk.byteLength;
if (outputBytes > MAX_OUTPUT_BYTES) {
limited = true;
stop();
return;
}
buffer = Buffer.concat([buffer, chunk]);
let end: number;
while ((end = buffer.indexOf(10)) !== -1) {
if (stopped) break;
const record = buffer.subarray(0, end);
buffer = buffer.subarray(end + 1);
if (record.byteLength > MAX_FILE_BYTES) {
limited = true;
stop();
break;
}
try {
const event = JSON.parse(record.toString("utf8")) as RgEvent;
if (event.type === "begin") {
pending = [];
pendingPath = workspacePathFromRg(event.data.path?.text);
} else if (event.type === "match" && pendingPath) {
pending.push(
...readMatches(
event.data,
pendingPath,
MAX_RESULTS + 1 - matches.length - pending.length,
),
);
} else if (event.type === "end") {
// rg may emit matches before discovering a NUL byte later in the file.
if (event.data.binary_offset == null) matches.push(...pending);
pending = [];
if (matches.length > MAX_RESULTS) {
limited = true;
stop();
}
}
} catch {
error = failure("unavailable", "Could not read ripgrep search results");
stop();
}
}
if (buffer.byteLength > MAX_FILE_BYTES) {
limited = true;
stop();
}
});
const cleanup = () => {
for (const timer of [timeout, killTimer]) clearTimeout(timer);
input.signal?.removeEventListener("abort", abort);
};
child.once("close", async (code) => {
if (!error && !limited && code !== 0 && code !== 1)
error = failure("unavailable", stderr.trim() || "File search failed");
let located: WorkspaceContentMatch[] = [];
try {
if (!error)
located = await locateMatches(input.cwd, matches.slice(0, MAX_RESULTS), () => {
if (!error && Date.now() >= deadline) timedOut();
return !!error;
});
} catch {
error ??= failure("unavailable", "Files changed while searching — retry");
}
cleanup();
resolve(error ?? { status: "ok", matches: located, limited, maxFileBytes: MAX_FILE_BYTES });
});
});
}
/** rg reports paths under the "." root using this host's separator; identities cross the wire
* with "/" so a client never has to guess which character was a separator. */
function workspacePathFromRg(reported: string | undefined): string {
if (!reported) return "";
const relative = reported.startsWith(`.${sep}`) ? reported.slice(2) : reported;
return toWorkspaceRelativePath(relative);
}
interface RgEvent {
type: string;
data: {
path?: { text?: string };
lines?: { text?: string };
line_number?: number;
submatches?: { start: number; end: number }[];
binary_offset?: number | null;
absolute_offset?: number;
};
}
function readMatches(data: RgEvent["data"], path: string, remaining: number): RgMatch[] {
if (!data.lines?.text || !data.line_number || remaining <= 0) return [];
const raw = Buffer.from(data.lines.text);
return (data.submatches ?? []).slice(0, remaining).map(({ start, end }) => ({
path,
byteOffset: (data.absolute_offset ?? 0) + start,
text: raw.subarray(start, end).toString("utf8"),
}));
}
function failure(
code: Extract<WorkspaceContentSearchResult, { status: "error" }>["code"],
message: string,
): WorkspaceContentSearchResult {
return { status: "error", code, message };
}
function spawnFailure(cause: unknown): WorkspaceContentSearchResult {
return cause instanceof Error && "code" in cause && cause.code === "ENOENT"
? failure(
"missing_rg",
"Install ripgrep on this host and make rg available on its PATH, then retry",
)
: failure(
"unavailable",
cause instanceof Error ? cause.message : "Could not start file search",
);
}
interface RgMatch {
path: string;
text: string;
byteOffset: number;
}
const LINE_FEED = 0x0a;
const CARRIAGE_RETURN = 0x0d;
/**
* Walks a file's saved bytes once, handing out each occurrence's source line and UTF-16 column.
*
* rg counts LF records while source views count CR, CRLF and LF lines, so the offsets it reports
* have to be translated. Doing that by decoding and splitting everything before each occurrence
* costs the whole file per match: a million-line file with 200 matches blocked for about six
* seconds and answered after its own deadline. Occurrences arrive in ascending offset order, so a
* single forward cursor answers all of them, and each line is decoded once however many matches
* it holds.
*/
function createLineReader(bytes: Uint8Array) {
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
// A leading byte-order mark is not part of the first line's text and is not counted in its
// columns. Stripping it once here keeps every later decode positional.
const start = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0;
let cursor = start;
let line = 1;
let lineStart = start;
let lineEnd = -1;
let lineText = "";
let columnByte = 0;
let column = 0;
function endOfLine(from: number): number {
let index = from;
while (index < bytes.length && bytes[index] !== LINE_FEED && bytes[index] !== CARRIAGE_RETURN) {
index += 1;
}
return index;
}
return function read(byteOffset: number) {
while (cursor < byteOffset) {
const byte = bytes[cursor];
cursor += 1;
if (byte === CARRIAGE_RETURN && bytes[cursor] === LINE_FEED && cursor < byteOffset) {
cursor += 1;
} else if (byte !== CARRIAGE_RETURN && byte !== LINE_FEED) {
continue;
}
line += 1;
lineStart = cursor;
lineEnd = -1;
}
if (lineEnd === -1) {
lineEnd = endOfLine(lineStart);
lineText = decoder.decode(bytes.subarray(lineStart, lineEnd));
columnByte = lineStart;
column = 0;
}
// Matches on one line arrive in order, so the column advances from the previous one rather
// than being recounted from the start of the line.
if (byteOffset > columnByte) {
column += decoder.decode(bytes.subarray(columnByte, byteOffset)).length;
columnByte = byteOffset;
}
return { line, column, lineText };
};
}
/** Read each matched file once through the bounded/revision-checked producer to translate original
* byte offsets. A file changed during the search must not manufacture an occurrence. */
async function locateMatches(
cwd: string,
matches: RgMatch[],
stopped: () => boolean,
): Promise<WorkspaceContentMatch[]> {
const located: WorkspaceContentMatch[] = [];
let path = "";
let bytes: Uint8Array = new Uint8Array();
let valid = false;
let read = createLineReader(bytes);
for (const { byteOffset, ...match } of matches) {
if (stopped()) break;
if (path !== match.path) {
path = match.path;
const file = await readExplorerFileBytes({
root: cwd,
relativePath: path,
maxBytes: MAX_FILE_BYTES,
});
bytes = file.bytes;
try {
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
valid = !bytes.includes(0);
} catch {
valid = false;
}
read = createLineReader(bytes);
}
if (!valid) continue;
const end = byteOffset + Buffer.byteLength(match.text);
if (
new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(byteOffset, end)) !==
match.text
)
continue;
const { line, column, lineText } = read(byteOffset);
const snippetStart = Math.max(0, column - 80);
const snippet = lineText.slice(snippetStart, snippetStart + 240);
located.push({
...match,
line,
columnStart: column + 1,
columnEnd: column + match.text.length + 1,
snippet,
snippetMatchStart: column - snippetStart,
snippetMatchEnd: Math.min(column + match.text.length - snippetStart, snippet.length),
});
}
return located;
}
+12 -8
View File
@@ -7,6 +7,7 @@ import {
validateBranchSlug,
type WorktreeConfig,
} from "../utils/worktree.js";
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
import {
resolveWorktreeCreationIntent,
type ResolveWorktreeCreationIntentInput,
@@ -42,6 +43,7 @@ export interface CreateWorktreeCoreDeps {
export interface CreateWorktreeCoreResult {
worktree: WorktreeConfig;
worktreeIncludeSummary?: WorktreeIncludeSummary;
intent: WorktreeCreationIntent;
repoRoot: string;
created: boolean;
@@ -115,15 +117,17 @@ async function createWorktreeCoreWithPriority(
}
}
const { worktreeIncludeSummary, ...worktree } = await createWorktree({
cwd: repoRoot,
worktreeSlug: normalizedSlug,
source: intent,
runSetup: input.runSetup ?? true,
paseoHome: input.paseoHome,
worktreesRoot: input.worktreesRoot,
});
return {
worktree: await createWorktree({
cwd: repoRoot,
worktreeSlug: normalizedSlug,
source: intent,
runSetup: input.runSetup ?? true,
paseoHome: input.paseoHome,
worktreesRoot: input.worktreesRoot,
}),
worktree,
worktreeIncludeSummary,
intent,
repoRoot,
created: true,
@@ -628,6 +628,17 @@ export async function createPaseoWorktreeWorkflow(
const workspace = createdWorktree.workspace;
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
if (createdWorktree.created && createdWorktree.worktreeIncludeSummary?.skipped.length) {
dependencies.sessionLogger.warn(
{
materialized: createdWorktree.worktreeIncludeSummary.materialized,
skipped: createdWorktree.worktreeIncludeSummary.skipped,
worktreePath: createdWorktree.worktree.worktreePath,
},
"Worktree include completed with skipped entries",
);
}
try {
await dependencies.assertWorkspaceAutomationAllowed?.(workspace.workspaceId);
} catch (error) {
@@ -0,0 +1,635 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
existsSync,
chmodSync,
lstatSync,
linkSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
readlinkSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "fs";
import { tmpdir } from "os";
import { dirname, join, relative } from "path";
import { isPlatform } from "../test-utils/platform.js";
import { materializeWorktreeIncludePlan, readWorktreeIncludePlan } from "./worktree-include.js";
describe("worktree include planning", () => {
let tempDir: string;
let sourceRoot: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-test-"));
sourceRoot = join(tempDir, "source");
mkdirSync(sourceRoot);
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("copies bare entries and accepts explicit copy and symlink modes", async () => {
writeFileSync(
join(sourceRoot, ".worktreeinclude"),
[".env.local", ".cache/**", "symlink shared-state", "copy packages/*/.runtime.env", ""].join(
"\n",
),
);
writeFileSync(join(sourceRoot, ".env.local"), "source\n");
mkdirSync(join(sourceRoot, ".cache"), { recursive: true });
writeFileSync(join(sourceRoot, ".cache", "state.txt"), "cache\n");
mkdirSync(join(sourceRoot, "shared-state"), { recursive: true });
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "shared\n");
mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true });
writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toHaveLength(4);
expect(plan.materializations).toEqual(
expect.arrayContaining([
expect.objectContaining({ mode: "copy", relativePath: ".env.local", sourceKind: "file" }),
expect.objectContaining({ mode: "copy", relativePath: ".cache", sourceKind: "directory" }),
expect.objectContaining({
mode: "symlink",
relativePath: "shared-state",
sourceKind: "directory",
}),
expect.objectContaining({
mode: "copy",
relativePath: "packages/api/.runtime.env",
sourceKind: "file",
}),
]),
);
});
it("skips invalid and conflicting entries while retaining safe entries", async () => {
writeFileSync(join(sourceRoot, "shared"), "source\n");
writeFileSync(join(sourceRoot, ".env"), "source\n");
writeFileSync(
join(sourceRoot, ".worktreeinclude"),
["../outside", "symlink", "shared", "symlink shared", ".env", ""].join("\n"),
);
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toEqual([
expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }),
]);
expect(plan.skipped).toEqual(
expect.arrayContaining([
expect.objectContaining({ lineNumber: 1, raw: "../outside", reason: "invalid" }),
expect.objectContaining({ lineNumber: 2, raw: "symlink", reason: "invalid" }),
expect.objectContaining({ lineNumber: 3, raw: "shared", reason: "conflict" }),
expect.objectContaining({ lineNumber: 4, raw: "symlink shared", reason: "conflict" }),
]),
);
});
it("skips missing entries while retaining existing literal and glob matches", async () => {
writeFileSync(
join(sourceRoot, ".worktreeinclude"),
[".env", ".env.local", "config/*.env", "missing/**", ""].join("\n"),
);
writeFileSync(join(sourceRoot, ".env"), "source\n");
mkdirSync(join(sourceRoot, "config"), { recursive: true });
writeFileSync(join(sourceRoot, "config", "runtime.env"), "runtime\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toEqual([
expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }),
expect.objectContaining({
mode: "copy",
relativePath: "config/runtime.env",
sourceKind: "file",
}),
]);
expect(plan.skipped).toEqual(
expect.arrayContaining([
expect.objectContaining({ raw: ".env.local", reason: "missing" }),
expect.objectContaining({ raw: "missing/**", reason: "missing" }),
]),
);
});
it("requires the optimized trailing recursive form to resolve to a directory", async () => {
writeFileSync(join(sourceRoot, "cache"), "not-a-directory\n");
writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toEqual([]);
expect(plan.skipped).toEqual([expect.objectContaining({ raw: "cache/**", reason: "unsafe" })]);
});
it("skips directory copies that overlap protected worktree paths", async () => {
const protectedWorktreeRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees", "project");
mkdirSync(protectedWorktreeRoot, { recursive: true });
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n");
const plan = await readWorktreeIncludePlan({
sourceRoot,
excludedSourceRoots: [protectedWorktreeRoot],
});
expect(plan.materializations).toEqual([]);
expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]);
});
it("protects every managed project beneath checkout-local worktree storage", async () => {
const managedWorktreesRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees");
const siblingWorktree = join(managedWorktreesRoot, "other-project", "sibling");
mkdirSync(siblingWorktree, { recursive: true });
writeFileSync(join(siblingWorktree, ".env"), "secret\n");
writeFileSync(
join(sourceRoot, ".worktreeinclude"),
".dev/paseo-home/worktrees/other-project/sibling/.env\n",
);
const plan = await readWorktreeIncludePlan({
sourceRoot,
excludedSourceRoots: [managedWorktreesRoot],
});
expect(plan.materializations).toEqual([]);
expect(plan.skipped).toEqual([expect.objectContaining({ reason: "unsafe" })]);
});
it("allows includes from a source worktree inside the protected worktree root", async () => {
const managedWorktreesRoot = join(tempDir, "paseo-home", "worktrees", "project");
sourceRoot = join(managedWorktreesRoot, "source-worktree");
mkdirSync(sourceRoot, { recursive: true });
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".env\n");
writeFileSync(join(sourceRoot, ".env"), "source\n");
const plan = await readWorktreeIncludePlan({
sourceRoot,
excludedSourceRoots: [managedWorktreesRoot],
});
expect(plan.materializations).toEqual([
expect.objectContaining({ relativePath: ".env", sourceKind: "file" }),
]);
expect(plan.skipped).toEqual([]);
});
it.skipIf(isPlatform("win32"))(
"skips protected paths reached through a symlink alias",
async () => {
const sourceAlias = join(tempDir, "source-alias");
symlinkSync(sourceRoot, sourceAlias, "dir");
mkdirSync(join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"), {
recursive: true,
});
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n");
const plan = await readWorktreeIncludePlan({
sourceRoot,
excludedSourceRoots: [join(sourceAlias, ".dev", "paseo-home", "worktrees", "project")],
});
expect(plan.materializations).toEqual([]);
expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]);
},
);
it.skipIf(isPlatform("win32"))(
"skips external source links while retaining safe entries",
async () => {
const outsidePath = join(tempDir, "outside.txt");
writeFileSync(outsidePath, "outside\n");
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
symlinkSync(outsidePath, join(sourceRoot, "linked.txt"));
writeFileSync(
join(sourceRoot, ".worktreeinclude"),
["safe.txt", "linked.txt", ""].join("\n"),
);
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toEqual([
expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }),
]);
expect(plan.skipped).toEqual([
expect.objectContaining({ raw: "linked.txt", reason: "unsafe" }),
]);
},
);
it.skipIf(isPlatform("win32"))("matches globs beneath a safe symlinked directory", async () => {
mkdirSync(join(sourceRoot, "actual-config"));
writeFileSync(join(sourceRoot, "actual-config", "runtime.env"), "runtime\n");
symlinkSync(join(sourceRoot, "actual-config"), join(sourceRoot, "config"), "dir");
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/*.env\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toEqual([
expect.objectContaining({ relativePath: "config/runtime.env", sourceKind: "file" }),
]);
expect(plan.skipped).toEqual([]);
});
it.skipIf(isPlatform("win32"))(
"does not scan unrelated directories for bounded globs",
async () => {
writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n");
mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true });
writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n");
const unreadableDirectory = join(sourceRoot, "unrelated");
mkdirSync(unreadableDirectory);
chmodSync(unreadableDirectory, 0o000);
try {
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toEqual([
expect.objectContaining({ relativePath: "packages/api/.runtime.env" }),
]);
} finally {
chmodSync(unreadableDirectory, 0o700);
}
},
);
it.skipIf(isPlatform("win32") || process.getuid?.() === 0)(
"skips inaccessible entries while retaining safe paths",
async () => {
const inaccessibleDirectory = join(sourceRoot, "private");
mkdirSync(inaccessibleDirectory);
writeFileSync(join(inaccessibleDirectory, "secret.txt"), "secret\n");
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
writeFileSync(
join(sourceRoot, ".worktreeinclude"),
["private/**", "private/*", "safe.txt", ""].join("\n"),
);
chmodSync(inaccessibleDirectory, 0o000);
try {
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toEqual([
expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }),
]);
expect(plan.skipped).toEqual(
expect.arrayContaining([
expect.objectContaining({ raw: "private/**", reason: "materialization" }),
expect.objectContaining({ raw: "private/*", reason: "materialization" }),
]),
);
} finally {
chmodSync(inaccessibleDirectory, 0o700);
}
},
);
it.skipIf(isPlatform("win32") || process.getuid?.() === 0)(
"retains valid glob matches when a viable sibling is unreadable",
async () => {
mkdirSync(join(sourceRoot, "packages", "good"), { recursive: true });
mkdirSync(join(sourceRoot, "packages", "private"), { recursive: true });
writeFileSync(join(sourceRoot, "packages", "good", ".runtime.env"), "good\n");
writeFileSync(join(sourceRoot, "packages", "private", ".runtime.env"), "private\n");
writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n");
chmodSync(join(sourceRoot, "packages", "private"), 0o000);
try {
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toEqual([
expect.objectContaining({ relativePath: "packages/good/.runtime.env" }),
]);
expect(plan.skipped).toEqual([
expect.objectContaining({ raw: "packages/*/.runtime.env", reason: "materialization" }),
]);
} finally {
chmodSync(join(sourceRoot, "packages", "private"), 0o700);
}
},
);
});
describe.skipIf(isPlatform("win32"))("worktree include materialization", () => {
let tempDir: string;
let sourceRoot: string;
let worktreeRoot: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-materialize-test-"));
sourceRoot = join(tempDir, "source");
worktreeRoot = join(tempDir, "worktree");
mkdirSync(sourceRoot);
mkdirSync(worktreeRoot);
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("copies snapshots, links shared paths, and is idempotent", async () => {
writeFileSync(
join(sourceRoot, ".worktreeinclude"),
["copy.txt", "copy-dir/**", "symlink linked.txt", "symlink linked-dir"].join("\n"),
);
writeFileSync(join(sourceRoot, "copy.txt"), "copy-v1\n");
mkdirSync(join(sourceRoot, "copy-dir"), { recursive: true });
writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v1\n");
writeFileSync(join(sourceRoot, "linked.txt"), "linked-v1\n");
mkdirSync(join(sourceRoot, "linked-dir"), { recursive: true });
writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v1\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
expect(lstatSync(join(worktreeRoot, "copy.txt")).isSymbolicLink()).toBe(false);
expect(lstatSync(join(worktreeRoot, "copy-dir")).isSymbolicLink()).toBe(false);
expect(lstatSync(join(worktreeRoot, "linked.txt")).isSymbolicLink()).toBe(true);
expect(lstatSync(join(worktreeRoot, "linked-dir")).isSymbolicLink()).toBe(true);
writeFileSync(join(sourceRoot, "copy.txt"), "copy-v2\n");
writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v2\n");
writeFileSync(join(sourceRoot, "linked.txt"), "linked-v2\n");
writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v2\n");
expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v1\n");
expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v1\n");
expect(readFileSync(join(worktreeRoot, "linked.txt"), "utf8")).toBe("linked-v2\n");
expect(readFileSync(join(worktreeRoot, "linked-dir", "state.txt"), "utf8")).toBe(
"linked-dir-v2\n",
);
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v2\n");
expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v2\n");
});
it("replaces an existing directory snapshot without retaining destination-only files", async () => {
mkdirSync(join(sourceRoot, "cache"));
writeFileSync(join(sourceRoot, "cache", "current.txt"), "current\n");
writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n");
mkdirSync(join(worktreeRoot, "cache"));
writeFileSync(join(worktreeRoot, "cache", "stale.txt"), "stale\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
expect(result).toMatchObject({ materialized: 1, skipped: [] });
expect(readFileSync(join(worktreeRoot, "cache", "current.txt"), "utf8")).toBe("current\n");
expect(existsSync(join(worktreeRoot, "cache", "stale.txt"))).toBe(false);
});
it("retains an explicit descendant when an overlapping directory copy is skipped", async () => {
mkdirSync(join(sourceRoot, "config", "nested"), { recursive: true });
writeFileSync(join(sourceRoot, "config", "local.env"), "local\n");
writeFileSync(join(sourceRoot, "config", "nested", "state.txt"), "state\n");
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/**\nconfig/local.env\n");
mkdirSync(join(worktreeRoot, "config"));
writeFileSync(join(worktreeRoot, "config", "nested"), "conflict\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
expect(result).toMatchObject({ materialized: 1 });
expect(result.skipped).toEqual([
expect.objectContaining({ raw: "config/**", reason: "conflict" }),
]);
expect(readFileSync(join(worktreeRoot, "config", "local.env"), "utf8")).toBe("local\n");
});
it("skips a destination parent symlink without writing through it", async () => {
mkdirSync(join(sourceRoot, "config"), { recursive: true });
writeFileSync(join(sourceRoot, "config", "local.json"), "{}\n");
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/local.json\nsafe.txt\n");
const outsideRoot = join(tempDir, "outside");
mkdirSync(outsideRoot);
symlinkSync(outsideRoot, join(worktreeRoot, "config"));
const plan = await readWorktreeIncludePlan({ sourceRoot });
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
expect(existsSync(join(outsideRoot, "local.json"))).toBe(false);
expect(readFileSync(join(worktreeRoot, "safe.txt"), "utf8")).toBe("safe\n");
expect(result).toMatchObject({ materialized: 1 });
expect(result.skipped).toEqual([
expect.objectContaining({ raw: "config/local.json", reason: "conflict" }),
]);
expect(readdirSync(worktreeRoot)).not.toContain(
expect.stringMatching(/^\.paseo-worktreeinclude-/),
);
});
it("copies resolved source links and creates direct live links", async () => {
const targetPath = join(sourceRoot, "target.txt");
const targetDirectoryPath = join(sourceRoot, "target-directory");
writeFileSync(targetPath, "v1\n");
mkdirSync(targetDirectoryPath);
writeFileSync(join(targetDirectoryPath, "state.txt"), "v1\n");
symlinkSync(targetPath, join(sourceRoot, "copy-link.txt"));
symlinkSync(targetPath, join(sourceRoot, "live-link.txt"));
symlinkSync(targetDirectoryPath, join(sourceRoot, "copy-directory-link"), "dir");
symlinkSync(targetDirectoryPath, join(sourceRoot, "live-directory-link"), "dir");
writeFileSync(
join(sourceRoot, ".worktreeinclude"),
[
"copy-link.txt",
"symlink live-link.txt",
"copy-directory-link",
"symlink live-directory-link",
"",
].join("\n"),
);
const plan = await readWorktreeIncludePlan({ sourceRoot });
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
const copiedPath = join(worktreeRoot, "copy-link.txt");
const linkedPath = join(worktreeRoot, "live-link.txt");
const copiedDirectoryPath = join(worktreeRoot, "copy-directory-link");
const linkedDirectoryPath = join(worktreeRoot, "live-directory-link");
const canonicalWorktreeRoot = realpathSync(worktreeRoot);
expect(result).toMatchObject({ materialized: 4, skipped: [] });
expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false);
expect(lstatSync(linkedPath).isSymbolicLink()).toBe(true);
expect(lstatSync(copiedDirectoryPath).isSymbolicLink()).toBe(false);
expect(lstatSync(linkedDirectoryPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(linkedPath)).toBe(
relative(dirname(join(canonicalWorktreeRoot, "live-link.txt")), realpathSync(targetPath)),
);
expect(readlinkSync(linkedDirectoryPath)).toBe(
relative(
dirname(join(canonicalWorktreeRoot, "live-directory-link")),
realpathSync(targetDirectoryPath),
),
);
expect(realpathSync(linkedPath)).toBe(realpathSync(targetPath));
expect(realpathSync(linkedDirectoryPath)).toBe(realpathSync(targetDirectoryPath));
writeFileSync(targetPath, "v2\n");
writeFileSync(join(targetDirectoryPath, "state.txt"), "v2\n");
expect(readFileSync(copiedPath, "utf8")).toBe("v1\n");
expect(readFileSync(linkedPath, "utf8")).toBe("v2\n");
expect(readFileSync(join(copiedDirectoryPath, "state.txt"), "utf8")).toBe("v1\n");
expect(readFileSync(join(linkedDirectoryPath, "state.txt"), "utf8")).toBe("v2\n");
});
it("rejects source links that alias Git metadata", async () => {
mkdirSync(join(sourceRoot, ".git"));
writeFileSync(join(sourceRoot, ".git", "config"), "secret\n");
symlinkSync(join(sourceRoot, ".git"), join(sourceRoot, "metadata"), "dir");
writeFileSync(join(sourceRoot, ".worktreeinclude"), "metadata\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toEqual([]);
expect(plan.skipped).toEqual([expect.objectContaining({ raw: "metadata", reason: "unsafe" })]);
});
it("treats hard links as ordinary files", async () => {
const targetPath = join(sourceRoot, "target.txt");
const hardLinkPath = join(sourceRoot, "hard-link.txt");
writeFileSync(targetPath, "v1\n");
linkSync(targetPath, hardLinkPath);
writeFileSync(join(sourceRoot, ".worktreeinclude"), "hard-link.txt\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
const copiedPath = join(worktreeRoot, "hard-link.txt");
expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false);
expect(readFileSync(copiedPath, "utf8")).toBe("v1\n");
});
it("skips a source link retargeted outside the checkout after planning", async () => {
const insidePath = join(sourceRoot, "inside.txt");
const linkedPath = join(sourceRoot, "linked.txt");
const outsidePath = join(tempDir, "outside.txt");
writeFileSync(insidePath, "inside\n");
writeFileSync(outsidePath, "outside\n");
symlinkSync(insidePath, linkedPath);
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink linked.txt\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
rmSync(linkedPath);
symlinkSync(outsidePath, linkedPath);
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
expect(existsSync(join(worktreeRoot, "linked.txt"))).toBe(false);
expect(result.skipped).toEqual([
expect.objectContaining({ raw: "symlink linked.txt", reason: "unsafe" }),
]);
});
it("skips a linked directory that exposes an external nested link", async () => {
const sharedPath = join(sourceRoot, "shared");
const outsidePath = join(tempDir, "outside.txt");
mkdirSync(sharedPath);
writeFileSync(outsidePath, "outside\n");
symlinkSync(outsidePath, join(sharedPath, "outside.txt"));
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
expect(plan.materializations).toEqual([]);
expect(plan.skipped).toEqual([
expect.objectContaining({ raw: "symlink shared", reason: "unsafe" }),
]);
});
it("allows a linked directory with internal nested links", async () => {
const sharedPath = join(sourceRoot, "shared");
const targetPath = join(sourceRoot, "shared-target");
mkdirSync(sharedPath);
mkdirSync(targetPath);
writeFileSync(join(targetPath, "state.txt"), "source\n");
symlinkSync(targetPath, join(sharedPath, "target"), "dir");
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
expect(result).toMatchObject({ materialized: 1, skipped: [] });
expect(readFileSync(join(worktreeRoot, "shared", "target", "state.txt"), "utf8")).toBe(
"source\n",
);
});
it("skips a source removed after planning", async () => {
writeFileSync(join(sourceRoot, ".worktreeinclude"), "runtime.env\n");
const sourcePath = join(sourceRoot, "runtime.env");
writeFileSync(sourcePath, "source\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
rmSync(sourcePath);
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
expect(existsSync(join(worktreeRoot, "runtime.env"))).toBe(false);
expect(result.skipped).toEqual([
expect.objectContaining({ raw: "runtime.env", reason: "missing" }),
]);
});
it.skipIf(process.getuid?.() === 0)(
"skips ordinary filesystem failures during materialization revalidation",
async () => {
mkdirSync(join(sourceRoot, "private"));
writeFileSync(join(sourceRoot, "private", "state.txt"), "private\n");
writeFileSync(join(sourceRoot, ".worktreeinclude"), "private/**\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
chmodSync(join(sourceRoot, "private"), 0o000);
try {
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
expect(result).toMatchObject({ materialized: 0 });
expect(result.skipped).toEqual([
expect.objectContaining({ raw: "private/**", reason: "materialization" }),
]);
} finally {
chmodSync(join(sourceRoot, "private"), 0o700);
}
},
);
});
describe.skipIf(!isPlatform("win32"))("worktree include Windows directory links", () => {
let tempDir: string;
let sourceRoot: string;
let worktreeRoot: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-windows-test-"));
sourceRoot = join(tempDir, "source");
worktreeRoot = join(tempDir, "worktree");
mkdirSync(sourceRoot);
mkdirSync(worktreeRoot);
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("uses a live directory link and removes it without touching the source", async () => {
mkdirSync(join(sourceRoot, "shared-state"));
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v1\n");
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared-state\n");
const plan = await readWorktreeIncludePlan({ sourceRoot });
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v2\n");
expect(readFileSync(join(worktreeRoot, "shared-state", "state.txt"), "utf8")).toBe(
"source-v2\n",
);
rmSync(worktreeRoot, { recursive: true, force: true });
expect(readFileSync(join(sourceRoot, "shared-state", "state.txt"), "utf8")).toBe("source-v2\n");
});
});
File diff suppressed because it is too large Load Diff
@@ -382,6 +382,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
expect(metadata).toMatchObject({ baseRefName: "main" });
});
it("removes fetched branches when include planning fails", async () => {
const remoteDir = join(tempDir, "remote.git");
const remoteCloneDir = join(tempDir, "remote-clone");
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
execFileSync("git", ["clone", remoteDir, remoteCloneDir]);
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir });
execFileSync("git", ["checkout", "-b", "contributor/cleanup"], { cwd: remoteCloneDir });
writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n");
execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pr branch"], {
cwd: remoteCloneDir,
});
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir })
.toString()
.trim();
execFileSync("git", ["push", "origin", "contributor/cleanup"], { cwd: remoteCloneDir });
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/44/head", prHead]);
mkdirSync(join(repoDir, ".worktreeinclude"));
await expect(
createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "pr-44-cleanup",
source: {
kind: "checkout-github-pr",
githubPrNumber: 44,
headRef: "contributor/cleanup",
baseRefName: "main",
},
runSetup: false,
paseoHome,
}),
).rejects.toMatchObject({ code: "EISDIR" });
expect(() =>
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
cwd: repoDir,
stdio: "pipe",
}),
).toThrow();
await expect(
createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "branch-cleanup",
source: { kind: "checkout-branch", branchName: "contributor/cleanup" },
runSetup: false,
paseoHome,
}),
).rejects.toMatchObject({ code: "EISDIR" });
expect(() =>
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
cwd: repoDir,
stdio: "pipe",
}),
).toThrow();
});
it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => {
const remoteDir = join(tempDir, "remote.git");
const remoteCloneDir = join(tempDir, "remote-clone");
@@ -1298,6 +1360,241 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
).toBe("");
});
it("materializes copies and symlinks before setup, then removes only the new worktree links", async () => {
writeFileSync(
join(repoDir, ".gitignore"),
[".copy.env", "copy-cache/", "linked-file.txt", "linked-state", "setup.log", ""].join("\n"),
);
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: [
"test -f .copy.env",
"test -L linked-file.txt",
"test -L linked-state",
"cat linked-state/state.txt > setup.log",
],
},
}),
);
execFileSync("git", ["add", ".gitignore", "paseo.json"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include fixture"], {
cwd: repoDir,
});
writeFileSync(
join(repoDir, ".worktreeinclude"),
[".copy.env", "copy-cache/**", "symlink linked-file.txt", "symlink linked-state", ""].join(
"\n",
),
);
writeFileSync(join(repoDir, ".copy.env"), "copy-v1\n");
mkdirSync(join(repoDir, "copy-cache"), { recursive: true });
writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v1\n");
writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v1\n");
mkdirSync(join(repoDir, "linked-state"), { recursive: true });
writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v1\n");
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "include-links",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/include-links" },
runSetup: true,
paseoHome,
});
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe(
"linked-state-v1\n",
);
expect(lstatSync(join(result.worktreePath, ".copy.env")).isSymbolicLink()).toBe(false);
expect(lstatSync(join(result.worktreePath, "copy-cache")).isSymbolicLink()).toBe(false);
expect(lstatSync(join(result.worktreePath, "linked-file.txt")).isSymbolicLink()).toBe(true);
expect(lstatSync(join(result.worktreePath, "linked-state")).isSymbolicLink()).toBe(true);
expect(
execFileSync("git", ["status", "--porcelain"], {
cwd: result.worktreePath,
encoding: "utf8",
}),
).toBe("");
writeFileSync(join(repoDir, ".copy.env"), "copy-v2\n");
writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v2\n");
writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v2\n");
writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v2\n");
expect(readFileSync(join(result.worktreePath, ".copy.env"), "utf8")).toBe("copy-v1\n");
expect(readFileSync(join(result.worktreePath, "copy-cache", "state.txt"), "utf8")).toBe(
"copy-cache-v1\n",
);
expect(readFileSync(join(result.worktreePath, "linked-file.txt"), "utf8")).toBe(
"linked-file-v2\n",
);
expect(readFileSync(join(result.worktreePath, "linked-state", "state.txt"), "utf8")).toBe(
"linked-state-v2\n",
);
await deletePaseoWorktree({
cwd: repoDir,
worktreePath: result.worktreePath,
paseoHome,
});
expect(existsSync(result.worktreePath)).toBe(false);
expect(readFileSync(join(repoDir, "linked-file.txt"), "utf8")).toBe("linked-file-v2\n");
expect(readFileSync(join(repoDir, "linked-state", "state.txt"), "utf8")).toBe(
"linked-state-v2\n",
);
});
it("skips missing includes and materializes paths that exist", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "missing-include");
writeFileSync(
join(repoDir, ".worktreeinclude"),
[".env", ".env.local", ".sops.yaml", ""].join("\n"),
);
writeFileSync(join(repoDir, ".env"), "present\n");
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "missing-include",
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" },
runSetup: false,
paseoHome,
});
expect(result.worktreePath).toBe(expectedWorktreePath);
expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n");
expect(existsSync(join(result.worktreePath, ".env.local"))).toBe(false);
expect(existsSync(join(result.worktreePath, ".sops.yaml"))).toBe(false);
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repoDir,
encoding: "utf8",
}),
).toContain(expectedWorktreePath);
});
it("skips checkout-local worktree storage and creates the remaining includes", async () => {
const checkoutLocalPaseoHome = join(repoDir, ".dev", "paseo-home");
const projectHash = await deriveWorktreeProjectHash(repoDir);
const expectedWorktreePath = join(
checkoutLocalPaseoHome,
"worktrees",
projectHash,
"protected-include",
);
mkdirSync(join(checkoutLocalPaseoHome, "worktrees", projectHash), { recursive: true });
writeFileSync(join(repoDir, ".worktreeinclude"), [".dev/**", ".env", ""].join("\n"));
writeFileSync(join(repoDir, ".env"), "present\n");
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "protected-include",
source: {
kind: "branch-off",
baseBranch: "main",
branchName: "feature/protected-include",
},
runSetup: false,
paseoHome: checkoutLocalPaseoHome,
});
expect(result.worktreePath).toBe(expectedWorktreePath);
expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n");
expect(result.worktreeIncludeSummary?.skipped).toEqual([
expect.objectContaining({ raw: ".dev/**", reason: "unsafe" }),
]);
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repoDir,
encoding: "utf8",
}),
).toContain(expectedWorktreePath);
expect(
execFileSync("git", ["branch", "--list", "feature/protected-include"], {
cwd: repoDir,
encoding: "utf8",
}).trim(),
).toContain("feature/protected-include");
});
it("keeps includes when branching from a Paseo-managed worktree", async () => {
writeFileSync(join(repoDir, ".gitignore"), ".env\n");
writeFileSync(join(repoDir, ".worktreeinclude"), ".env\n");
execFileSync("git", ["add", ".gitignore", ".worktreeinclude"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include config"], {
cwd: repoDir,
});
writeFileSync(join(repoDir, ".env"), "source\n");
const sourceWorktree = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "include-source",
source: {
kind: "branch-off",
baseBranch: "main",
branchName: "feature/include-source",
},
runSetup: false,
paseoHome,
});
const nestedWorktree = await createLegacyWorktreeForTest({
cwd: sourceWorktree.worktreePath,
worktreeSlug: "include-nested",
source: {
kind: "branch-off",
baseBranch: "feature/include-source",
branchName: "feature/include-nested",
},
runSetup: false,
paseoHome,
});
expect(readFileSync(join(sourceWorktree.worktreePath, ".env"), "utf8")).toBe("source\n");
expect(readFileSync(join(nestedWorktree.worktreePath, ".env"), "utf8")).toBe("source\n");
expect(nestedWorktree.worktreeIncludeSummary?.skipped).toEqual([]);
});
it("skips a symlink include conflict and keeps the new worktree", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict");
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} }));
writeFileSync(join(repoDir, ".worktreeinclude"), "symlink paseo.json\n");
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "include-conflict",
source: {
kind: "branch-off",
baseBranch: "main",
branchName: "feature/include-conflict",
},
runSetup: false,
paseoHome,
});
expect(result.worktreePath).toBe(expectedWorktreePath);
expect(existsSync(expectedWorktreePath)).toBe(true);
expect(lstatSync(join(expectedWorktreePath, "paseo.json")).isSymbolicLink()).toBe(false);
expect(result.worktreeIncludeSummary?.skipped).toEqual([
expect.objectContaining({ raw: "symlink paseo.json", reason: "conflict" }),
]);
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repoDir,
encoding: "utf8",
}),
).toContain(expectedWorktreePath);
expect(
execFileSync("git", ["branch", "--list", "feature/include-conflict"], {
cwd: repoDir,
encoding: "utf8",
}).trim(),
).toContain("feature/include-conflict");
});
it("creates a worktree without error when no paseo.json exists in the main repo", async () => {
const result = await createLegacyWorktreeForTest({
cwd: repoDir,

Some files were not shown because too many files have changed in this diff Show More