Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 8df495b9c7 test(app): expose terminal input loss during reconnect replay 2026-11-02 22:03:38 +00:00
paseo-bot[bot]andhadan8977 e06adf7a3e Show new files in collapsed folders after Refresh (#8582)
* fix(app): show current contents of collapsed folders after Files refresh

Refresh only re-listed expanded folders, and expanding a folder skips the
request when a listing is cached, so a folder collapsed during Refresh
kept showing its old contents. Drop the cached listings Refresh does not
re-list. The refresh helper moves to file-explorer/tree.ts so it can be
tested.

* test(app): cover refreshing collapsed folders through the browser

Exercise the reported file creation, refresh and reopen sequence through a real daemon, then check refresh while expanded.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>

* fix(app): ignore directory replies invalidated by Refresh

A directory request started before Refresh could finish afterwards and restore a stale collapsed listing. Track the refresh generation in explorer state and accept completion only from the current generation. Keep directory loading and invalidation in the file-explorer model.

* fix(app): preserve expansion when Refresh cancels restoration

Keep an invalidated directory operation distinct from a failed listing. Propagate cancellation through saved-folder restoration so it preserves the current expansion state instead of committing an incomplete restored path set.

---------

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 21:59:28 +00:00
luke d2cc18b3a1 fix(agents): clear stale error attention when a new turn starts (#8083) 2026-11-02 21:02:09 +00:00
paseo-bot[bot] 5062d5ee90 fix(server): report completion after waking remote closed agents (#8575)
A queued prompt can leave the previous closed snapshot visible until dispatch. Do not treat that snapshot as terminal when the finish wait times out; retain permission resolution while awaiting the new turn's outcome.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 19:05:43 +00:00
paseo-bot[bot] 71dc9f44c9 fix(server): finish Git refreshes before daemon shutdown (#8579)
Retain admitted refresh promises until they settle, including after the last workspace subscriber leaves. Disposal closes admission and waits for those commands before callers can remove their working directories.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 18:29:15 +00:00
7495c20f78 fix(app): keep HTML section links inside the preview (#8559)
* fix(app): keep HTML preview fragment links inside the preview

A file previewed through `srcDoc` resolves `<a href="#section">` against the
app's URL, so the link loaded Paseo inside the iframe and the preview became
the Welcome screen. Handle same-document fragment clicks from a small script
in the preview document, and let the page's own handlers go first.

Closes #7465

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): preserve native HTML fragment navigation in previews

Give each HTML preview its own document URL so section links resolve within the file. Browser default navigation preserves cancellation by page handlers without an injected click interceptor.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 15:54:49 +00:00
Mohamed Boudra b92a34ab3d Keep reconnecting and updating status visible in native chats (#8568)
* fix(app): keep chat status toasts visible below floating headers

Compact native chats extend beneath the floating header. Panel toasts need measured header clearance and a layer above its fade; placing them below the fade leaves an excessive gap. Keep dock overlays outside keyboard translation so opening the keyboard cannot move them offscreen.

* test(app): verify rendered chat status spacing on native devices

Measure the real header and toast bounds with the software keyboard closed and open. The former arithmetic helper test passed even when the viewport stopped consuming header clearance; the native regression fails against that broken production wiring.

* test(app): exercise toast placement through host disconnection

Observe the public reconnecting state with host connectivity interrupted externally. Keep native bounds and software keyboard assertions while removing runtime evaluation and private timeline-owner mutations.
2026-11-02 22:49:53 +07:00
paseo-bot[bot]andRyan Winograd 8018231a00 fix(pi): release archived chat runtimes after history replay (#8554)
* fix(pi): close history-only runtimes after replay

Archived timeline reads resume a Pi RPC process to reconstruct the saved
conversation. The history-only session remained open after replay, so
browsing or synchronizing archived agents left one idle Pi process per
agent.

Mark history-purpose resumes for closure and close the runtime in
streamHistory's finally path. Interactive sessions remain resident, and
idempotent close keeps later manager cleanup safe.

* fix(pi): preserve history cleanup across current resume behavior

Keep the original session identity and missing-folder cleanup when releasing
history-only runtimes. Use an existing directory in the regression so the
newer resume fallback does not try to copy a nonexistent session fixture.
Cover interrupted and failed replay, interactive residency, and automatic
cleanup of a missing-folder history copy.

Co-authored-by: Ryan Winograd <ryan@thewinograds.com>

* test(pi): separate stopped and failed history replay cases

---------

Co-authored-by: Ryan Winograd <ryan@thewinograds.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 14:53:40 +00:00
555818f5f7 fix(acp): clear abandoned approvals without leaving providers blocked (#8548)
* fix(acp): clear permission requests a finished turn abandoned

An ACP agent can give up on a permission request itself and end its turn
normally. Paseo kept that request pending, so the approval card stayed up
with nothing listening, and the next prompt reported the agent as waiting.

Cancel a turn's open requests whenever that turn ends, cancel a request
when its tool reaches a terminal status, and publish the resolved event in
both cases. Requests raised outside a turn stay answerable.

Closes #7664

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(acp): keep permission cleanup consistent after failed turns

Failed and canceled turns clear every permission in the agent manager. Answer that same set in the ACP session so a request raised outside the foreground turn cannot leave the provider waiting after its approval disappears.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 13:22:46 +00:00
paseo-bot[bot] 27caf913f6 test(server): trim subdirectory setup fixture overhead (#8558)
Exercise setup against an existing checkout instead of constructing Git history and a worktree inside its deadline. Preserve the real shell command and both output-location assertions.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 12:53:00 +00:00
33 changed files with 1540 additions and 352 deletions
@@ -63,6 +63,24 @@ function htmlPreview(page: Page) {
};
}
async function followHtmlPreviewLink(page: Page, name: string): Promise<void> {
await htmlPreview(page).document.getByRole("link", { name, exact: true }).click();
}
async function expectPageHandlesFragment(
page: Page,
handler: "Document" | "Window",
): Promise<void> {
const preview = htmlPreview(page);
await followHtmlPreviewLink(page, `${handler} handled jump`);
await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`);
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).not.toBeInViewport();
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
}
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
const option = page.getByTestId("file-panel-bar").getByRole("button", {
name: view,
@@ -826,6 +844,192 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(preview.host).toBeVisible();
});
test("keeps HTML fragment links in the preview and scrolls to the target", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "html-fragments-" });
await writeFile(
path.join(workspace.repoPath, "fragments.html"),
`<!doctype html><html><body>
<a href="#section">Jump to section</a>
<div style="height: 1200px"></div>
<h2 id="section">Target section</h2>
</body></html>`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "fragments.html");
const preview = htmlPreview(page);
await expect(preview.document.getByRole("link", { name: "Jump to section" })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("fragment-before-click.png") });
await preview.document.getByRole("link", { name: "Jump to section" }).click();
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).toBeInViewport();
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section");
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan(
0,
);
await page.screenshot({ path: test.info().outputPath("fragment-after-click.png") });
});
test("lets an HTML page prevent fragment navigation", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "html-fragment-handler-" });
await writeFile(
path.join(workspace.repoPath, "handled.html"),
`<!doctype html><html><body>
<a href="#section" onclick="event.preventDefault(); document.querySelector('output').textContent = 'Page handled click'">Handled jump</a>
<output>Waiting for click</output>
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
</body></html>`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "handled.html");
const preview = htmlPreview(page);
await preview.document.getByRole("link", { name: "Handled jump" }).click();
await expect(preview.document.locator("output")).toHaveText("Page handled click");
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).not.toBeInViewport();
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
});
test("respects delegated HTML fragment handlers and normal URL parsing", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" });
await writeFile(
path.join(workspace.repoPath, "defaults.html"),
`<!doctype html><html><body>
<nav style="position: fixed; top: 0; background: white">
<a href="#section" id="document-handler">Document handled jump</a>
<a href="#section" id="window-handler">Window handled jump</a>
<a href=" \t#section\n">Whitespace jump</a>
<a href="#100%">Percent jump</a>
<output>Waiting for click</output>
</nav>
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
<div style="height: 1200px"></div><h2 id="100%">Percent target</h2>
<div style="height: 1200px"></div>
<script>
document.addEventListener('click', function(event) {
if (event.target.id === 'document-handler') {
event.preventDefault(); document.querySelector('output').textContent = 'Document handled click';
}
});
window.addEventListener('click', function(event) {
if (event.target.id === 'window-handler') {
event.preventDefault(); document.querySelector('output').textContent = 'Window handled click';
}
});
</script></body></html>`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "defaults.html");
const preview = htmlPreview(page);
await expectPageHandlesFragment(page, "Document");
await expectPageHandlesFragment(page, "Window");
await followHtmlPreviewLink(page, "Whitespace jump");
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).toBeInViewport();
await followHtmlPreviewLink(page, "Percent jump");
await expect(
preview.document.getByRole("heading", { name: "Percent target" }),
).toBeInViewport();
});
test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "html-fragment-targets-" });
await writeFile(
path.join(workspace.repoPath, "targets.html"),
`<!doctype html><html><head><style>:target { color: rgb(255, 0, 0); }</style></head><body>
<nav style="position: fixed; top: 0; background: white">
<a href="#section"><span>Nested jump</span></a>
<a href="#legacy">Named jump</a>
<a href="#encoded%20section">Encoded jump</a>
<a href="#missing">Missing jump</a>
<a href="#">Empty jump</a>
<output>Waiting for hashchange</output><output id="modified">Waiting for modified click</output>
</nav>
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
<div style="height: 1200px"></div><a name="legacy">Named target</a>
<div style="height: 1200px"></div><h2 id="encoded section">Encoded target</h2>
<div style="height: 1200px"></div>
<script>
window.addEventListener('hashchange', function () { document.querySelector('output').textContent = location.hash || '(empty)'; });
function modifiedClick(event) {
if (event.button === 1 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
document.getElementById('modified').textContent = String(event.defaultPrevented);
event.preventDefault();
}
}
window.addEventListener('click', modifiedClick);
window.addEventListener('auxclick', modifiedClick);
window.addEventListener('contextmenu', modifiedClick);
</script></body></html>`,
"utf8",
);
await workspace.navigateTo();
await openWorkspaceFile(page, "targets.html");
const preview = htmlPreview(page);
const body = preview.document.locator("body");
const nested = preview.document.getByText("Nested jump", { exact: true });
await nested.click();
await expect(preview.document.locator("output").first()).toHaveText("#section");
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).toBeInViewport();
await expect(preview.document.locator("#section")).toHaveCSS("color", "rgb(255, 0, 0)");
await body.evaluate(() => window.scrollTo(0, 0));
await nested.click();
await expect(
preview.document.getByRole("heading", { name: "Target section" }),
).toBeInViewport();
await preview.document.getByRole("link", { name: "Named jump" }).click();
await expect(preview.document.getByText("Named target", { exact: true })).toBeInViewport();
await expect(preview.document.locator("output").first()).toHaveText("#legacy");
await preview.document.getByRole("link", { name: "Encoded jump" }).click();
await expect(
preview.document.getByRole("heading", { name: "Encoded target" }),
).toBeInViewport();
await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section");
const scrollY = await body.evaluate(() => window.scrollY);
await preview.document.getByRole("link", { name: "Missing jump" }).click();
expect(await body.evaluate(() => location.hash)).toBe("#missing");
expect(await body.evaluate(() => window.scrollY)).toBe(scrollY);
await preview.document.getByRole("link", { name: "Empty jump" }).click();
await expect(preview.document.locator("output").first()).toHaveText("(empty)");
expect(await body.evaluate(() => window.scrollY)).toBe(0);
for (const modifier of ["Control", "Meta", "Shift", "Alt"] as const) {
await nested.click({ modifiers: [modifier] });
await expect(preview.document.locator("#modified")).toHaveText("false");
}
await nested.click({ button: "middle" });
await expect(preview.document.locator("#modified")).toHaveText("false");
expect(await body.evaluate(() => location.hash)).toBe("");
expect(await body.evaluate(() => window.scrollY)).toBe(0);
});
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
@@ -1,4 +1,6 @@
import { test } from "../support/fixtures";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import {
collapseFolder,
expandFolder,
@@ -8,6 +10,7 @@ import {
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
import { delayDirectoryListingResponse } from "../support/helpers/directory-listing-gate";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
@@ -49,3 +52,66 @@ test.describe("File explorer collapse", () => {
await expectExplorerEntryVisible(page, "guide.md");
});
});
test("Files Refresh reveals new files when a cached collapsed folder is reopened", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
await collapseFolder(page, "docs");
await expectExplorerEntryHidden(page, "guide.md");
await createFileOutsidePaseo("docs/NEW.md");
await refreshFiles(page);
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
await expectExplorerEntryVisible(page, "NEW.md");
await createFileOutsidePaseo("docs/NEXT.md");
await refreshFiles(page);
await expectExplorerEntryVisible(page, "NEXT.md");
});
async function refreshFiles(page: import("@playwright/test").Page): Promise<void> {
const refresh = page.getByRole("button", { name: "Refresh files", exact: true });
await refresh.click();
await expect(refresh).toBeEnabled();
}
async function createFileOutsidePaseo(filePath: string): Promise<void> {
await writeFile(path.join(workspace.repoPath, filePath), "# New output\n");
}
test("Files Refresh ignores an old reply for a folder collapsed while loading", async ({
page,
}) => {
const reply = await delayDirectoryListingResponse(page, "docs");
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "docs");
await reply.waitUntilHeld();
await collapseFolder(page, "docs");
await createFileOutsidePaseo("docs/DELAYED.md");
await refreshFiles(page);
await reply.release();
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "DELAYED.md");
});
test("Files Refresh preserves expanded folders while their saved view is restoring", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
const reply = await delayDirectoryListingResponse(page, "docs");
await page.reload();
await openFileExplorer(page);
await reply.waitUntilHeld();
await refreshFiles(page);
await expectExplorerEntryVisible(page, "guide.md");
await reply.release();
await expectExplorerEntryVisible(page, "guide.md");
});
@@ -320,6 +320,9 @@ test.describe("Terminal protocol queries", () => {
await pasteImage(page, pngBytes(8));
await upload.waitUntilHeld();
// Widen the replay window to expose typed input being discarded during reconnect.
const browser = await page.context().newCDPSession(page);
await browser.send("Emulation.setCPUThrottlingRate", { rate: 6 });
await upload.disconnect();
await expect(page.getByTestId("app-toast-message")).toHaveText(
"Failed to paste file: Host is not connected",
@@ -0,0 +1,54 @@
// Open an existing compact chat, with the keyboard closed, then interrupt its host connection.
// Hold that interruption externally (for example, stop a disposable SSH TCP forward).
// Leave the daemon running. This test observes the public reconnecting state.
// node placement.mjs <session> <platform> <coordinate-scale> <composer-label> <artifacts>
// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1).
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv;
assert(
session && ["android", "ios"].includes(platform) && composerLabel && artifacts,
"Pass all five arguments",
);
const scale = Number(coordinateScale);
assert(scale > 0, "Pass a positive coordinate scale");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], {
encoding: "utf8",
timeout: 90_000,
});
}
function capture(name) {
const snapshot = ad("snapshot", "--raw", "--json");
writeFileSync(path.join(artifacts, `${name}.json`), snapshot);
ad("screenshot", path.join(artifacts, `${name}.png`));
const nodes = JSON.parse(snapshot).data.nodes;
const header = nodes.find((node) => node.identifier === "floating-header");
const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast");
assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present");
assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds");
const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale;
assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`);
return toast.rect.y / scale;
}
ad("wait", 'id="agent-reconnecting-toast"', "20000");
const closedTop = capture("keyboard-closed");
ad("press", `label=${JSON.stringify(composerLabel)}`);
const opened = ad("snapshot", "--raw", "--json");
const nodes = JSON.parse(opened).data.nodes;
assert(
nodes.some(
(node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""),
),
"Use a real visible software keyboard; open Android sessions with --no-test-ime",
);
const openTop = capture("keyboard-open");
assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast");
console.log(
"PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open",
);
@@ -0,0 +1,44 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
function listingResponse(message: string | Buffer) {
try {
const envelope = JSON.parse(message.toString());
return envelope.message?.type === "file_explorer_response" ? envelope.message.payload : null;
} catch {
return null;
}
}
/** Delay one real directory response after the daemon has read its contents. */
export async function delayDirectoryListingResponse(page: Page, directoryPath: string) {
let held: { socket: WebSocketRoute; message: string | Buffer; requestId: string } | null = null;
let resolveHeld!: () => void;
const heldResponse = new Promise<void>((resolve) => {
resolveHeld = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
const server = socket.connectToServer();
socket.onMessage((message) => server.send(message));
server.onMessage((message) => {
const response = listingResponse(message);
if (!held && response?.mode === "list" && response.path === directoryPath) {
held = { socket, message, requestId: response.requestId };
resolveHeld();
return;
}
socket.send(message);
});
});
return {
waitUntilHeld: () => heldResponse,
release: async () => {
if (!held) throw new Error("Directory response must be held before release");
held.socket.send(held.message);
// Let the delivered reply update the UI before the user reopens the folder.
await page.evaluate(
() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
},
};
}
@@ -64,10 +64,15 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
DIRECTORY_LISTING_INVALIDATED,
type DirectoryListingResult,
} from "@/file-explorer/directory-listings";
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import {
flattenExplorerTree,
reconcileRestoredExpandedPaths,
refreshExplorerDirectories,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
@@ -435,6 +440,7 @@ export function FileExplorerPane({
const {
requestDirectoryListing,
invalidateDirectoryListings,
createEntry,
renameEntry,
duplicateEntry,
@@ -886,9 +892,11 @@ export function FileExplorerPane({
refreshExplorerDirectories({
hasWorkspaceScope,
expandedPaths,
showHiddenFiles: usePanelStore.getState().explorerShowHiddenFiles,
requestDirectoryListing,
invalidateDirectoryListings,
}),
[expandedPaths, hasWorkspaceScope, requestDirectoryListing],
[invalidateDirectoryListings, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
);
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
@@ -1424,7 +1432,7 @@ function toggleDirectory({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
) => Promise<DirectoryListingResult>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): void {
if (!workspaceStateKey) {
@@ -1561,7 +1569,7 @@ async function initializeExplorer({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
) => Promise<DirectoryListingResult>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): Promise<void> {
if (!hasWorkspaceScope || hasInitializedRef.current) {
@@ -1572,6 +1580,7 @@ async function initializeExplorer({
recordHistory: false,
setCurrentPath: false,
});
if (rootDirectory === DIRECTORY_LISTING_INVALIDATED) return;
if (!rootDirectory) {
hasInitializedRef.current = false;
return;
@@ -1593,7 +1602,10 @@ async function initializeExplorer({
const hiddenPersistedPaths = showHiddenFiles
? []
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
const restoredPathsWithHidden =
restoredPaths === DIRECTORY_LISTING_INVALIDATED
? DIRECTORY_LISTING_INVALIDATED
: [...restoredPaths, ...hiddenPersistedPaths];
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({
persistedExpandedPaths,
@@ -1603,39 +1615,6 @@ async function initializeExplorer({
);
}
async function refreshExplorerDirectories({
hasWorkspaceScope,
expandedPaths,
requestDirectoryListing,
}: {
hasWorkspaceScope: boolean;
expandedPaths: Set<string>;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
}): Promise<null> {
if (!hasWorkspaceScope) {
return null;
}
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
const directoryPaths = Array.from(expandedPaths).filter(
(path) => showHiddenFiles || !isHiddenExplorerPath(path),
);
if (!directoryPaths.includes(".")) {
directoryPaths.unshift(".");
}
await Promise.all(
directoryPaths.map((path) =>
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
),
);
return null;
}
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
if (!state) {
return ".";
@@ -1,8 +1,26 @@
import { useCallback, useState, type ReactNode } from "react";
import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Portal, PortalHost } from "@gorhom/portal";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { EdgeFade } from "@/components/ui/edge-fade";
const OverlayClearanceContext = createContext(0);
const OverlayHostContext = createContext<string | null>(null);
/** Keep status overlays above the fade without lifting the scrolling content. */
export function FloatingHeaderOverlay({ children }: { children: ReactNode }) {
const hostName = useContext(OverlayHostContext);
const active = useRetainedPanelActive();
if (!hostName) return children;
return active ? <Portal hostName={hostName}>{children}</Portal> : null;
}
/** Clearance for stationary overlays inside this layout, measured to the header bottom. */
export function useFloatingHeaderOverlayClearance(): number {
return useContext(OverlayClearanceContext);
}
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
const FADE_OVERHANG = 48;
/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */
@@ -20,15 +38,24 @@ interface FloatingHeaderLayoutProps {
* same tree position in both modes, so toggling `floating` never remounts them.
*/
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
const overlayHostName = useId();
const [headerHeight, setHeaderHeight] = useState(0);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
}, []);
const overlayClearance = floating ? headerHeight : 0;
return (
<View style={styles.root}>
{floating ? null : header}
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
<View style={styles.content}>
<OverlayClearanceContext.Provider value={overlayClearance}>
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
{children(floating ? headerHeight : 0)}
</OverlayHostContext.Provider>
</OverlayClearanceContext.Provider>
</View>
{floating ? (
<View style={styles.overlay} pointerEvents="box-none">
<EdgeFade
@@ -37,11 +64,19 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
backdrop="surfaceWorkspace"
style={styles.fade}
/>
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
<View
testID="floating-header"
collapsable={false}
pointerEvents="box-none"
onLayout={handleHeaderLayout}
>
{header}
</View>
</View>
) : null}
<View style={styles.statusOverlay} pointerEvents="box-none">
<PortalHost name={overlayHostName} />
</View>
</View>
);
}
@@ -55,6 +90,13 @@ const styles = StyleSheet.create({
flex: 1,
minHeight: 0,
},
statusOverlay: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
overlay: {
position: "absolute",
top: 0,
+8 -2
View File
@@ -1,3 +1,7 @@
import {
FloatingHeaderOverlay,
useFloatingHeaderOverlayClearance,
} from "@/components/floating-header";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import {
@@ -190,9 +194,11 @@ export function ToastViewport({
return clearTimer;
}, [clearTimer, durationMs, scheduleDismiss, toastId]);
const panelClearance = useFloatingHeaderOverlayClearance();
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
const topOffset =
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
const content = (
<View
@@ -220,7 +226,7 @@ export function ToastViewport({
return createPortal(content, getOverlayRoot());
}
return content;
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content;
}
function ToastCard({
@@ -179,8 +179,8 @@ export function ComposerDock({
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
</ComposerViewportContent>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
{overlay}
</ComposerViewport>
);
}
@@ -214,8 +214,8 @@ export function ComposerDock({
{composer}
</View>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
{overlay}
</ComposerViewport>
);
}
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import ts from "typescript";
import { expect, it } from "vitest";
it("keeps dock overlays outside keyboard translation in every native layout", () => {
const source = ts.createSourceFile(
"dock.tsx",
readFileSync(new URL("./index.native.tsx", import.meta.url), "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
let surfaces = 0;
function visit(node: ts.Node) {
if (
ts.isJsxElement(node) &&
node.openingElement.tagName.getText(source) === "KeyboardTranslateView"
) {
surfaces++;
function assertStationary(child: ts.Node) {
if (ts.isJsxExpression(child))
expect(child.expression?.getText(source)).not.toBe("overlay");
ts.forEachChild(child, assertStationary);
}
ts.forEachChild(node, assertStationary);
}
ts.forEachChild(node, visit);
}
visit(source);
expect(surfaces).toBe(2);
});
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store";
import {
refreshExplorerDirectories,
restoreExpandedDirectories,
showHiddenFilesAndRestoreExpandedDirectories,
reconcileRestoredExpandedPaths,
} from "./tree";
import {
DIRECTORY_LISTING_INVALIDATED,
invalidateExplorerDirectoryListings,
loadExplorerDirectory,
} from "./directory-listings";
function directory(path: string, names: string[]): ExplorerDirectory {
return {
path,
entries: names.map((name) => ({
path: `${path}/${name}`,
name,
kind: "file",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
})),
};
}
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 explorer() {
let state: AgentFileExplorerState = {
listingGeneration: 0,
directories: new Map([[".", directory(".", ["docs"])]]),
isLoading: false,
lastError: null,
pendingRequest: null,
currentPath: ".",
history: ["."],
lastVisitedPath: ".",
selectedEntryPath: null,
};
const updateState = (update: (state: AgentFileExplorerState) => AgentFileExplorerState) => {
state = update(state);
return state;
};
return {
state: () => state,
load: (path: string, result: Promise<ExplorerDirectory>) =>
loadExplorerDirectory({
path,
updateState,
listDirectory: () => result,
fallbackError: "Directory listing failed",
options: { recordHistory: false, setCurrentPath: false },
}),
refresh: (retained: readonly string[]) =>
invalidateExplorerDirectoryListings(updateState, retained),
};
}
describe("directory listings across Refresh", () => {
it("ignores an earlier reply for a collapsed folder while the fresh root is loading", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh(["."]);
const fresh = deferred<ExplorerDirectory>();
const freshLoad = model.load(".", fresh.promise);
old.resolve(directory("docs", ["OLD.md"]));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().directories.has("docs")).toBe(false);
expect(model.state().isLoading).toBe(true);
expect(model.state().pendingRequest?.path).toBe(".");
const root = directory(".", ["docs", "NEW.md"]);
fresh.resolve(root);
await expect(freshLoad).resolves.toEqual(root);
expect(model.state().directories.get(".")).toEqual(root);
expect(model.state().isLoading).toBe(false);
});
it("does not let a pre-refresh reply replace a newer listing of the same folder", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh([".", "docs"]);
const fresh = directory("docs", ["NEW.md"]);
await model.load("docs", Promise.resolve(fresh));
old.resolve(directory("docs", ["OLD.md"]));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().directories.get("docs")).toEqual(fresh);
});
it("ignores errors from requests invalidated by Refresh", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh(["."]);
const fresh = deferred<ExplorerDirectory>();
const freshLoad = model.load(".", fresh.promise);
old.reject(new Error("Old request failed"));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().lastError).toBeNull();
expect(model.state().isLoading).toBe(true);
fresh.resolve(directory(".", ["docs"]));
await freshLoad;
});
it("keeps expanded listings visible and discards collapsed listings on Refresh", async () => {
const model = explorer();
const docs = directory("docs", ["guide.md"]);
await model.load("docs", Promise.resolve(docs));
await model.load("assets", Promise.resolve(directory("assets", ["logo.png"])));
model.refresh([".", "docs"]);
expect(model.state().directories.get("docs")).toEqual(docs);
expect(model.state().directories.has("assets")).toBe(false);
expect(model.state().currentPath).toBe(".");
expect(model.state().history).toEqual(["."]);
});
});
it.each([false, true])(
"preserves saved expansion when Refresh invalidates restoration (hidden: %s)",
async (hidden) => {
const model = explorer();
const folder = hidden ? ".hidden" : "docs";
const root: ExplorerDirectory = {
path: ".",
entries: [
{
name: folder,
path: folder,
kind: "directory",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
},
],
};
const saved = new Set([".", folder]);
const pending = deferred<ExplorerDirectory>();
const input = {
rootDirectory: root,
persistedExpandedPaths: saved,
requestDirectoryListing: (path: string) => model.load(path, pending.promise),
};
const restoration = hidden
? showHiddenFilesAndRestoreExpandedDirectories({ ...input, showHiddenFiles: () => {} })
: restoreExpandedDirectories({ ...input, showHiddenFiles: false });
const fresh = directory(folder, ["NEW.md"]);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: saved,
showHiddenFiles: hidden,
invalidateDirectoryListings: model.refresh,
requestDirectoryListing: (path) =>
model.load(path, Promise.resolve(path === "." ? root : fresh)),
});
pending.resolve(directory(folder, ["OLD.md"]));
const restored = await restoration;
const expanded = reconcileRestoredExpandedPaths({
persistedExpandedPaths: saved,
currentExpandedPaths: saved,
restoredExpandedPaths: restored,
});
expect(model.state().directories.get(folder)).toEqual(fresh);
expect(expanded).toEqual([".", folder]);
},
);
@@ -0,0 +1,91 @@
import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store";
export const DIRECTORY_LISTING_INVALIDATED = Symbol("directory-listing-invalidated");
export type DirectoryListingResult =
| ExplorerDirectory
| null
| typeof DIRECTORY_LISTING_INVALIDATED;
type UpdateExplorerState = (
update: (state: AgentFileExplorerState) => AgentFileExplorerState,
) => AgentFileExplorerState | null;
function pushHistory(history: string[], path: string): string[] {
const normalizedHistory = history.length === 0 ? ["."] : history;
return normalizedHistory.at(-1) === path ? normalizedHistory : [...normalizedHistory, path];
}
export async function loadExplorerDirectory({
path,
options,
updateState,
listDirectory,
fallbackError,
}: {
path: string;
options?: { recordHistory?: boolean; setCurrentPath?: boolean };
updateState: UpdateExplorerState;
listDirectory: () => Promise<ExplorerDirectory>;
fallbackError: string;
}): Promise<DirectoryListingResult> {
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
const initial = updateState((state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path, mode: "list" },
...(shouldSetCurrentPath
? {
currentPath: path,
history: shouldRecordHistory ? pushHistory(state.history, path) : state.history,
lastVisitedPath: path,
}
: {}),
}));
if (!initial) return null;
const generation = initial.listingGeneration;
try {
const directory = await listDirectory();
let accepted = false;
updateState((state) => {
if (state.listingGeneration !== generation) return state;
accepted = true;
const directories = new Map(state.directories);
directories.set(directory.path, directory);
return { ...state, directories, isLoading: false, lastError: null, pendingRequest: null };
});
return accepted ? directory : DIRECTORY_LISTING_INVALIDATED;
} catch (error) {
let invalidated = false;
updateState((state) => {
if (state.listingGeneration !== generation) {
invalidated = true;
return state;
}
return {
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : fallbackError,
pendingRequest: null,
};
});
return invalidated ? DIRECTORY_LISTING_INVALIDATED : null;
}
}
/** Refresh invalidates earlier replies as well as listings of collapsed folders. */
export function invalidateExplorerDirectoryListings(
updateState: UpdateExplorerState,
retainedPaths: readonly string[],
): void {
const retained = new Set(retainedPaths);
updateState((state) => ({
...state,
listingGeneration: state.listingGeneration + 1,
directories: new Map([...state.directories].filter(([path]) => retained.has(path))),
isLoading: false,
pendingRequest: null,
lastError: null,
}));
}
+75 -1
View File
@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest";
import type { ExplorerEntry } from "@/stores/session-store";
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import {
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
flattenExplorerTree,
reconcileRestoredExpandedPaths,
refreshExplorerDirectories,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
@@ -195,3 +196,76 @@ describe("file explorer tree", () => {
await expect(restoration).resolves.toEqual([".", ".hidden"]);
});
});
describe("refreshExplorerDirectories", () => {
function makeListing(path: string, names: string[]): ExplorerDirectory {
return {
path,
entries: names.map((name) => ({
name,
path: path === "." ? name : `${path}/${name}`,
kind: "file" as const,
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
})),
};
}
function createExplorer(disk: Record<string, string[]>, cached: Record<string, string[]>) {
const directories = new Map(
Object.entries(cached).map(([path, names]) => [path, makeListing(path, names)]),
);
return {
directories,
requestDirectoryListing: async (path: string) => {
const listing = makeListing(path, disk[path] ?? []);
directories.set(path, listing);
return listing;
},
invalidateDirectoryListings: (retainedPaths: readonly string[]) => {
const retained = new Set(retainedPaths);
for (const path of directories.keys()) if (!retained.has(path)) directories.delete(path);
},
};
}
it("does not keep a stale listing for a collapsed folder", async () => {
// docs/ was expanded and collapsed earlier, then NEW.md was created in it.
const explorer = createExplorer(
{ ".": ["docs"], docs: ["README.md", "NEW.md"] },
{ ".": ["docs"], docs: ["README.md"] },
);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: new Set(["."]),
showHiddenFiles: false,
requestDirectoryListing: explorer.requestDirectoryListing,
invalidateDirectoryListings: explorer.invalidateDirectoryListings,
});
// Expanding docs/ only fetches when it has no cached listing.
expect(explorer.directories.has("docs")).toBe(false);
expect(explorer.directories.get(".")?.entries.map((entry) => entry.name)).toEqual(["docs"]);
});
it("refreshes expanded folders and keeps their listings", async () => {
const explorer = createExplorer(
{ ".": ["src"], src: ["a.ts", "b.ts"] },
{ ".": ["src"], src: ["a.ts"] },
);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: new Set([".", "src"]),
showHiddenFiles: false,
requestDirectoryListing: explorer.requestDirectoryListing,
invalidateDirectoryListings: explorer.invalidateDirectoryListings,
});
expect(explorer.directories.get("src")?.entries.map((entry) => entry.name)).toEqual([
"a.ts",
"b.ts",
]);
});
});
+58 -7
View File
@@ -1,5 +1,6 @@
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state";
import { DIRECTORY_LISTING_INVALIDATED, type DirectoryListingResult } from "./directory-listings";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
@@ -16,11 +17,13 @@ interface FlattenExplorerTreeInput {
showHiddenFiles: boolean;
}
type RestoredExpandedPaths = string[] | typeof DIRECTORY_LISTING_INVALIDATED;
interface RestoreExpandedDirectoriesInput {
rootDirectory: ExplorerDirectory;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
}
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
@@ -33,7 +36,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
interface ReconcileRestoredExpandedPathsInput {
persistedExpandedPaths: ReadonlySet<string>;
currentExpandedPaths: ReadonlySet<string>;
restoredExpandedPaths: string[];
restoredExpandedPaths: RestoredExpandedPaths;
}
interface SetExpandedDirectoryPathInput {
@@ -85,7 +88,7 @@ export async function restoreExpandedDirectories({
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
}: RestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
const restoredPaths = ["."];
const restoredPathSet = new Set(restoredPaths);
let parentDirectories = [rootDirectory];
@@ -110,9 +113,11 @@ export async function restoreExpandedDirectories({
const requestedDirectories = await Promise.all(
pathsToRequest.map((path) => requestDirectoryListing(path)),
);
if (requestedDirectories.includes(DIRECTORY_LISTING_INVALIDATED))
return DIRECTORY_LISTING_INVALIDATED;
parentDirectories = [];
for (const directory of requestedDirectories) {
if (!directory) {
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) {
continue;
}
restoredPaths.push(directory.path);
@@ -128,7 +133,7 @@ export function showHiddenFilesAndRestoreExpandedDirectories({
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
showHiddenFiles();
return restoreExpandedDirectories({
rootDirectory,
@@ -143,6 +148,8 @@ export function reconcileRestoredExpandedPaths({
currentExpandedPaths,
restoredExpandedPaths,
}: ReconcileRestoredExpandedPathsInput): string[] {
if (restoredExpandedPaths === DIRECTORY_LISTING_INVALIDATED)
return Array.from(currentExpandedPaths);
const reconciledPaths = new Set(restoredExpandedPaths);
for (const path of persistedExpandedPaths) {
@@ -205,7 +212,7 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption):
/** Load and expand the path to a directory selected outside the tree. */
export async function revealExplorerDirectory(input: {
directoryPath: string;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
expandPaths: (paths: string[]) => void;
showHiddenFiles: () => void;
}): Promise<void> {
@@ -218,7 +225,51 @@ export async function revealExplorerDirectory(input: {
}
}
for (const path of paths) {
if (!(await input.requestDirectoryListing(path))) return;
const directory = await input.requestDirectoryListing(path);
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) return;
}
input.expandPaths(paths);
}
/**
* Files Refresh: re-lists the expanded directories (and the root), and drops the cached
* listing of every other directory. Expanding a directory only fetches when it has no
* cached listing, so keeping a collapsed directory's old listing would show it as it was
* before the refresh.
*/
export async function refreshExplorerDirectories({
hasWorkspaceScope,
expandedPaths,
showHiddenFiles,
requestDirectoryListing,
invalidateDirectoryListings,
}: {
hasWorkspaceScope: boolean;
expandedPaths: Set<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<DirectoryListingResult>;
invalidateDirectoryListings: (retainedPaths: readonly string[]) => void;
}): Promise<null> {
if (!hasWorkspaceScope) {
return null;
}
const directoryPaths = Array.from(expandedPaths).filter(
(path) => showHiddenFiles || !isHiddenExplorerPath(path),
);
if (!directoryPaths.includes(".")) {
directoryPaths.unshift(".");
}
invalidateDirectoryListings(directoryPaths);
await Promise.all(
directoryPaths.map((path) =>
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
),
);
return null;
}
@@ -25,12 +25,17 @@ const iframeStyle = {
export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) {
const { t } = useTranslation();
const document = useMemo(() => withPreviewCsp(html), [html]);
// A document URL lets the browser resolve fragment links within the preview.
// srcDoc would inherit the app URL and navigate the frame back into Paseo.
const source = useMemo(
() => `data:text/html;charset=utf-8,${encodeURIComponent(withPreviewCsp(html))}`,
[html],
);
return (
<iframe
data-testid={testID}
title={t("panels.file.filePanels.builtin.html")}
srcDoc={document}
src={source}
sandbox={SANDBOX}
referrerPolicy="no-referrer"
style={iframeStyle}
@@ -1,16 +1,18 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
useSessionStore,
type AgentFileExplorerState,
type ExplorerDirectory,
} from "@/stores/session-store";
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
import { usePanelStore } from "@/stores/panel-store";
import { revealExplorerDirectory } from "@/file-explorer/tree";
import {
loadExplorerDirectory,
type DirectoryListingResult,
invalidateExplorerDirectoryListings,
} from "@/file-explorer/directory-listings";
import { parentExplorerPath } from "@/utils/explorer-paths";
function createExplorerState(): AgentFileExplorerState {
return {
listingGeneration: 0,
directories: new Map(),
isLoading: false,
lastError: null,
@@ -22,15 +24,6 @@ function createExplorerState(): AgentFileExplorerState {
};
}
function pushHistory(history: string[], path: string): string[] {
const normalizedHistory = history.length === 0 ? ["."] : history;
const last = normalizedHistory[normalizedHistory.length - 1];
if (last === path) {
return normalizedHistory;
}
return [...normalizedHistory, path];
}
export interface FileExplorerWorkspaceScope {
workspaceId?: string | null;
workspaceRoot?: string | null;
@@ -75,16 +68,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
);
const updateExplorerState = useCallback(
(updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => {
(
updater: (prev: AgentFileExplorerState) => AgentFileExplorerState,
): AgentFileExplorerState | null => {
if (!workspaceStateKey) {
return;
return null;
}
let updated: AgentFileExplorerState | null = null;
setFileExplorer(serverId, (prev) => {
const next = new Map(prev);
const current = next.get(workspaceStateKey) ?? createExplorerState();
next.set(workspaceStateKey, updater(current));
updated = updater(current);
next.set(workspaceStateKey, updated);
return next;
});
return updated;
},
[serverId, setFileExplorer, workspaceStateKey],
);
@@ -93,80 +91,23 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
async (
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
): Promise<ExplorerDirectory | null> => {
): Promise<DirectoryListingResult> => {
if (!workspaceStateKey) {
return null;
}
const normalizedPath = path && path.length > 0 ? path : ".";
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
updateExplorerState((state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: normalizedPath, mode: "list" },
...(shouldSetCurrentPath
? {
currentPath: normalizedPath,
history: shouldRecordHistory
? pushHistory(state.history, normalizedPath)
: state.history,
lastVisitedPath: normalizedPath,
}
: {}),
}));
if (!normalizedWorkspaceRoot) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return null;
}
if (!client) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return null;
}
try {
const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: null,
pendingRequest: null,
directories: state.directories,
};
const directories = new Map(state.directories);
directories.set(directory.path, directory);
nextState.directories = directories;
return nextState;
});
return directory;
} catch (error) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError:
error instanceof Error
? error.message
: t("workspace.fileExplorer.errors.failedToListDirectory"),
pendingRequest: null,
}));
return null;
}
return loadExplorerDirectory({
path: normalizedPath,
options,
updateState: updateExplorerState,
fallbackError: t("workspace.fileExplorer.errors.failedToListDirectory"),
listDirectory: async () => {
if (!normalizedWorkspaceRoot)
throw new Error(t("workspace.fileExplorer.states.unavailable"));
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
return client.listDirectory(normalizedWorkspaceRoot, normalizedPath);
},
});
},
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
@@ -308,10 +249,17 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[updateExplorerState],
);
const invalidateDirectoryListings = useCallback(
(retainedPaths: readonly string[]) =>
invalidateExplorerDirectoryListings(updateExplorerState, retainedPaths),
[updateExplorerState],
);
return {
workspaceStateKey,
revealDirectory,
requestDirectoryListing,
invalidateDirectoryListings,
requestFileDownloadToken,
requestFileBytes,
createEntry,
+1
View File
@@ -260,6 +260,7 @@ interface ExplorerRequestState {
}
export interface AgentFileExplorerState {
listingGeneration: number;
directories: Map<string, ExplorerDirectory>;
isLoading: boolean;
lastError: string | null;
@@ -7317,6 +7317,7 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy
const running = manager.getAgent(snapshot.id);
expect(running?.lifecycle).toBe("running");
expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("turn-b");
expect(running?.lastError).toBeUndefined();
});
const active = manager.getAgent(snapshot.id)!;
@@ -7359,7 +7360,7 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy
const stillRunning = manager.getAgent(snapshot.id);
expect(stillRunning?.lifecycle).toBe("running");
expect(stillRunning ? toAgentPayload(stillRunning).activeTurn?.turnId : null).toBe("turn-b");
expect(stillRunning?.lastError).toBe("turn-b marker");
expect(stillRunning?.lastError).toBeUndefined();
expect(stillRunning?.lastUsage).toEqual({ inputTokens: 7 });
expect(stillRunning?.pendingPermissions.has("turn-b-permission")).toBe(true);
expect(manager.getTimeline(snapshot.id)).toEqual(timelineBeforeStaleTerminals);
@@ -8683,6 +8684,118 @@ test("clearAgentAttention on errored agent stays cleared until a new error trans
expect(persistedAfterSecondFailure?.attentionReason).toBe("error");
});
test.each([
["autonomous", "turn_completed", "idle", "finished", undefined],
["autonomous", "turn_failed", "error", "error", "Retry failed."],
["foreground", "turn_completed", "idle", "finished", undefined],
["foreground", "turn_failed", "error", "error", "Retry failed."],
] as const)(
"%s recovery clears error attention before %s",
async (mode, terminal, status, expectedReason, lastError) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-recovery-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
class RecoverySession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
return { turnId: "recovered-turn" };
}
}
class RecoveryClient extends TestAgentClient {
readonly session = new RecoverySession({ provider: "codex", cwd: workdir });
override async createSession(): Promise<AgentSession> {
return this.session;
}
}
const client = new RecoveryClient();
const attentionReasons: Array<"finished" | "error" | "permission"> = [];
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
onAgentAttention: ({ reason }) => attentionReasons.push(reason),
});
onTestFinished(async () => {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const running = waitForAgentLifecycle(manager, agent.id, "running");
client.session.pushEvent({ type: "turn_started", provider: "codex", turnId: "failed-turn" });
await running;
const failed = waitForAgentLifecycle(manager, agent.id, "error");
client.session.pushEvent({
type: "turn_failed",
provider: "codex",
turnId: "failed-turn",
error: "Selected model is at capacity.",
});
await failed;
await vi.waitFor(() => expect(manager.hasInFlightRun(agent.id)).toBe(false));
expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({
status: "error",
attentionReason: "error",
lastError: "Selected model is at capacity.",
});
const recovered = waitForAgentLifecycle(manager, agent.id, "running");
const stream =
mode === "foreground"
? drainAsyncGenerator(manager.streamAgent(agent.id, "Continue after capacity recovers."))
: null;
if (mode === "autonomous") {
client.session.pushEvent({
type: "turn_started",
provider: "codex",
turnId: "recovered-turn",
});
}
await recovered;
await manager.flush();
expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({
status: "running",
requiresAttention: false,
attentionReason: null,
});
expect(manager.getAgent(agent.id)?.lastError).toBeUndefined();
expect(await storage.get(agent.id)).toMatchObject({
lastStatus: "running",
requiresAttention: false,
attentionReason: null,
});
expect((await storage.get(agent.id))?.lastError).toBeUndefined();
const settled = waitForAgentLifecycle(manager, agent.id, status);
client.session.pushEvent({
type: terminal,
provider: "codex",
turnId: "recovered-turn",
...(terminal === "turn_failed" ? { error: "Retry failed." } : {}),
});
await settled;
await stream;
await manager.flush();
expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({
status,
requiresAttention: true,
attentionReason: expectedReason,
});
expect(manager.getAgent(agent.id)?.lastError).toBe(lastError);
expect(await storage.get(agent.id)).toMatchObject({
lastStatus: status,
requiresAttention: true,
attentionReason: expectedReason,
});
expect((await storage.get(agent.id))?.lastError).toBe(lastError);
expect(attentionReasons).toEqual(["error", expectedReason]);
},
);
test("streamAgent clears pending run when startTurn fails before a turn id exists", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-start-turn-failure-"));
const storagePath = join(workdir, "agents");
@@ -2840,6 +2840,7 @@ export class AgentManager {
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.attention = { requiresAttention: false };
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
// AgentManager owns the accepted-turn boundary. Publish liveness before the canonical
@@ -4952,6 +4953,8 @@ export class AgentManager {
if (eventTurnId) {
this.openActiveTurn(agent, eventTurnId, new Date());
}
agent.lastError = undefined;
agent.attention = { requiresAttention: false };
agent.lifecycle = "running";
this.emitState(agent);
}
@@ -399,14 +399,16 @@ function remoteObservation(client: DaemonClient): HostObservation {
// A new turn may start between the snapshot and the current-response read.
// Bound that wait and refresh state instead of attributing the old event to it.
const current = await client.waitForFinish(agentId, 100);
if (current.status === "timeout")
snapshot = (await client.fetchAgent(agentId))?.agent ?? null;
else {
if (!current.final && current.error && current.error !== `Agent not found: ${agentId}`)
throw new Error(current.error);
snapshot = current.final;
lastMessage = current.lastMessage;
if (current.status === "timeout") {
// Queued delivery can leave a closed/error snapshot visible before the new turn.
// A timed-out wait has not established an outcome for that queued work.
if (event?.type === "permission_resolved") handler(snapshot, event);
return;
}
if (!current.final && current.error && current.error !== `Agent not found: ${agentId}`)
throw new Error(current.error);
snapshot = current.final;
lastMessage = current.lastMessage;
}
handler(snapshot, event, lastMessage);
};
@@ -404,6 +404,7 @@ describe("Suite A: Core Fixes", () => {
expect(parseAgentMessage(callerPrompts[0]).source).toEqual({
kind: "agent-notification",
agentId: childId,
serverId: daemon.daemon.getServerId(),
title: "Notification child",
event: "finished",
});
@@ -517,6 +518,7 @@ describe("Suite A: Core Fixes", () => {
expect(notification.source).toEqual({
kind: "agent-notification",
agentId: childId,
serverId: daemon.daemon.getServerId(),
title: "Watched child",
event: "errored",
});
@@ -579,7 +581,11 @@ describe("Suite A: Core Fixes", () => {
type: "tool_call",
agentMessage: {
event: outcome,
sender: { id: childId, title: "Follow-up child" },
sender: {
id: childId,
serverId: daemonHandle.daemon.getServerId(),
title: "Follow-up child",
},
text: expect.stringContaining(`(Follow-up child) ${outcome}.`),
},
}),
@@ -643,6 +643,7 @@ interface PendingPermission {
resolve: (response: RequestPermissionResponse) => void;
reject: (error: Error) => void;
turnId: string | null;
toolCallId: string;
}
interface PendingUserMessage {
@@ -2644,6 +2645,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
resolve,
reject,
turnId: this.activeForegroundTurnId,
toolCallId: params.toolCall.toolCallId,
});
});
@@ -3207,6 +3209,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
snapshot = this.toolSnapshotTransformer(snapshot);
}
this.toolCalls.set(toolCallId, snapshot);
if (update.status === "completed" || update.status === "failed") {
this.cancelPendingPermissions((pending) => pending.toolCallId === toolCallId);
}
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
}
@@ -3414,11 +3419,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.deliverTranslatedEvents(this.flushPendingUserMessage());
// A turn that ends without completing leaves no way to answer its open
// permission requests, so tell the agent they were cancelled.
if (event.type !== "turn_completed") {
this.cancelPendingPermissions();
}
// Failed and canceled turns clear every request in the manager. Answer the
// same set here so an outside-turn request cannot keep the provider blocked.
this.cancelPendingPermissions(
(pending) => event.type !== "turn_completed" || pending.turnId === event.turnId,
);
this.activeForegroundTurnId = null;
this.fallbackAssistantMessageId = null;
if (this.submittedUserMessageTurnId === event.turnId) {
@@ -3427,11 +3432,21 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.pushEvent(event);
}
private cancelPendingPermissions(): void {
for (const pending of this.pendingPermissions.values()) {
private cancelPendingPermissions(
matches: (pending: PendingPermission) => boolean = () => true,
): void {
for (const [requestId, pending] of this.pendingPermissions) {
if (!matches(pending)) continue;
this.pendingPermissions.delete(requestId);
pending.resolve({ outcome: { outcome: "cancelled" } });
this.pushEvent({
type: "permission_resolved",
provider: this.provider,
requestId,
resolution: { behavior: "deny", message: "Permission request canceled" },
turnId: pending.turnId ?? undefined,
});
}
this.pendingPermissions.clear();
}
private emitBootstrapThreadEvent(): void {
@@ -5,6 +5,7 @@ import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { AgentManager } from "../agent-manager.js";
import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
@@ -12,6 +13,7 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
test.each([
{ ending: "fail", terminalEvent: "turn_failed" },
{ ending: "cancel", terminalEvent: "turn_canceled" },
{ ending: "end_turn", terminalEvent: "turn_completed" },
] as const)(
"answers a permission request left open by a $terminalEvent turn with cancelled",
async ({ ending, terminalEvent }) => {
@@ -25,6 +27,16 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
await waitForEvent(events, terminalEvent);
expect(hasEvent(events, "permission_requested")).toBe(true);
expect(session.getPendingPermissions()).toEqual([]);
const requested = permissionRequests(events)[0]!;
expect(resolvedEvents(events)).toEqual([
{
type: "permission_resolved",
provider: "acp",
requestId: requested.request.id,
resolution: { behavior: "deny", message: "Permission request canceled" },
turnId: requested.turnId,
},
]);
const replyStart = events.length;
await session.startTurn("turn two");
@@ -39,6 +51,84 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
});
},
);
test.each(["completed", "failed"] as const)(
"cancels only the matching permission when its tool becomes %s",
async (status) => {
await withFakeACPAgent(status, async (command, cwd) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.createSession({ provider: "acp", cwd });
try {
const events = collectEvents(session);
await session.startTurn("turn one");
await waitForToolUpdate(events);
expect(pendingToolIds(session)).toEqual(["tool-2"]);
expect(resolvedEvents(events)).toEqual([
expect.objectContaining({ requestId: permissionRequests(events)[0]!.request.id }),
]);
} finally {
await session.close();
}
});
},
);
test("keeps a permission raised outside a turn answerable after another turn completes", async () => {
await withFakeACPAgent("outside_turn", async (command, cwd) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.createSession({ provider: "acp", cwd });
try {
const outside = session.getPendingPermissions();
expect(pendingToolIds(session)).toEqual(["tool-outside"]);
const events = collectEvents(session);
await session.startTurn("turn one");
await waitForEvent(events, "turn_completed");
expect(session.getPendingPermissions()).toEqual(outside);
await session.respondToPermission(outside[0]!.id, { behavior: "allow" });
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
});
test.each(["fail", "cancel"] as const)(
"settles an outside-turn permission that the manager clears when a turn ends with %s",
async (ending) => {
await withFakeACPAgent(`outside_${ending}`, async (command, cwd) => {
const logger = createTestLogger();
const client = new GenericACPAgentClient({
logger,
command,
waitForInitialCommands: false,
});
const manager = new AgentManager({ clients: { acp: client }, logger });
const agent = await manager.createAgent({ provider: "acp", cwd }, undefined, {
workspaceId: undefined,
});
try {
await waitForManagedPermission(manager, agent.id);
if (ending === "fail") {
await expect(manager.runAgent(agent.id, "turn one")).rejects.toThrow(
"turn failed with a permission open",
);
} else {
await expect(manager.runAgent(agent.id, "turn one")).resolves.toMatchObject({
canceled: true,
});
}
expect(manager.getPendingPermissions(agent.id)).toEqual([]);
const result = await manager.runAgent(agent.id, "turn two");
expect(result.finalText).toBe(
'perm-outside answered: {"outcome":{"outcome":"cancelled"}}',
);
} finally {
await manager.closeAgent(agent.id);
await manager.flush();
}
});
},
);
});
function collectEvents(session: AgentSession): AgentStreamEvent[] {
@@ -47,6 +137,10 @@ function collectEvents(session: AgentSession): AgentStreamEvent[] {
return events;
}
async function waitForManagedPermission(manager: AgentManager, agentId: string): Promise<void> {
await expect.poll(() => manager.getPendingPermissions(agentId).length).toBe(1);
}
function hasEvent(
events: AgentStreamEvent[],
type: AgentStreamEvent["type"],
@@ -77,8 +171,32 @@ function assistantText(events: AgentStreamEvent[]): string {
.join("");
}
function permissionRequests(events: AgentStreamEvent[]) {
return events.filter((event) => event.type === "permission_requested");
}
function resolvedEvents(events: AgentStreamEvent[]) {
return events.filter((event) => event.type === "permission_resolved");
}
function pendingToolIds(session: AgentSession) {
return session.getPendingPermissions().map((request) => request.metadata?.toolCallId);
}
async function waitForToolUpdate(events: AgentStreamEvent[]): Promise<void> {
await expect.poll(() => assistantText(events)).toBe("tool terminal");
}
async function withFakeACPAgent(
ending: "fail" | "cancel",
ending:
| "fail"
| "cancel"
| "end_turn"
| "completed"
| "failed"
| "outside_turn"
| "outside_fail"
| "outside_cancel",
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-"));
@@ -91,8 +209,7 @@ async function withFakeACPAgent(
}
}
// Turn one asks for permission, then fails or is cancelled while the request is
// still open.
// Turn one ends while its permission request is still open.
// Later turns report whether that request was ever answered.
const fakeACPAgentScript = `
const readline = require("node:readline");
@@ -101,15 +218,34 @@ const ending = process.argv[2];
const rl = readline.createInterface({ input: process.stdin });
let promptCount = 0;
let permissionAnswer = null;
let outsidePermissionAnswer = null;
function write(message) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
}
function ask(sessionId, requestId, toolCallId) {
const toolCall = { toolCallId, title: "echo hello", kind: "execute", status: "pending" };
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
write({
id: requestId,
method: "session/request_permission",
params: {
sessionId,
toolCall,
options: [
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
],
},
});
}
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === undefined && message.id === "perm-1") {
permissionAnswer = message.result ?? { error: message.error };
if (message.method === undefined) {
if (message.id === "perm-1") permissionAnswer = message.result ?? { error: message.error };
if (message.id === "perm-outside") outsidePermissionAnswer = message.result ?? { error: message.error };
return;
}
if (message.method === "initialize") {
@@ -117,6 +253,7 @@ rl.on("line", (line) => {
return;
}
if (message.method === "session/new") {
if (ending.startsWith("outside_")) ask("session-1", "perm-outside", "tool-outside");
write({ id: message.id, result: { sessionId: "session-1" } });
return;
}
@@ -124,35 +261,32 @@ rl.on("line", (line) => {
const sessionId = message.params.sessionId;
promptCount += 1;
if (promptCount === 1) {
const toolCall = { toolCallId: "tool-1", title: "echo hello", kind: "execute", status: "pending" };
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
write({
id: "perm-1",
method: "session/request_permission",
params: {
sessionId,
toolCall,
options: [
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
],
},
});
ask(sessionId, "perm-1", "tool-1");
if (ending === "completed" || ending === "failed") {
ask(sessionId, "perm-2", "tool-2");
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: ending } } });
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "tool terminal" } } } });
return;
}
setTimeout(() => {
if (ending === "cancel") {
if (ending === "cancel" || ending === "outside_cancel") {
write({ id: message.id, result: { stopReason: "cancelled" } });
} else if (ending === "end_turn" || ending === "outside_turn") {
write({ id: message.id, result: { stopReason: "end_turn" } });
} else {
write({ id: message.id, error: { code: -32603, message: "turn failed with a permission open" } });
}
}, 50);
return;
}
const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer);
const answer = ending.startsWith("outside_") ? outsidePermissionAnswer : permissionAnswer;
const requestId = ending.startsWith("outside_") ? "perm-outside" : "perm-1";
const state = answer === null ? "unanswered" : "answered: " + JSON.stringify(answer);
write({
method: "session/update",
params: {
sessionId,
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } },
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: requestId + " " + state } },
},
});
write({ id: message.id, result: { stopReason: "end_turn" } });
@@ -1600,6 +1600,111 @@ describe("PiRpcAgentSession", () => {
]);
});
test("closes a history-only Pi runtime after replaying its timeline", async () => {
const pi = new FakePi();
pi.queueSessionSetup((runtimeSession) => {
runtimeSession.messages = [
{ role: "user", content: "Old question" },
{ role: "assistant", content: [{ type: "text", text: "Old answer" }] },
];
});
const client: import("../../agent-sdk-types.js").AgentClient = createClient(pi);
const session = await client.resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session",
metadata: { cwd: tmpdir() },
},
{},
undefined,
{ purpose: "history" },
);
onTestFinished(() => session.close());
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) history.push(event);
expect(history).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "timeline",
provider: "pi",
item: expect.objectContaining({ type: "assistant_message", text: "Old answer" }),
}),
]),
);
expect(pi.latestSession().closeCalls).toBe(1);
await session.close();
expect(pi.latestSession().closeCalls).toBe(1);
});
test("closes a history-only Pi runtime when replay is stopped", async () => {
const pi = new FakePi();
pi.queueSessionSetup((runtimeSession) => {
runtimeSession.messages = [
{ role: "user", content: "Old question" },
{ role: "assistant", content: [{ type: "text", text: "Old answer" }] },
];
});
const session = await createClient(pi).resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session",
metadata: { cwd: tmpdir() },
},
{},
undefined,
{ purpose: "history" },
);
onTestFinished(() => session.close());
const history = session.streamHistory();
expect((await history.next()).done).toBe(false);
await history.return();
expect(pi.latestSession().closeCalls).toBe(1);
});
test("closes a history-only Pi runtime when replay fails", async () => {
const pi = new FakePi();
pi.queueSessionSetup((runtimeSession) => {
runtimeSession.getMessagesError = new Error("History unavailable");
});
const session = await createClient(pi).resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session",
metadata: { cwd: tmpdir() },
},
{},
undefined,
{ purpose: "history" },
);
onTestFinished(() => session.close());
const history = session.streamHistory();
await expect(history.next()).rejects.toThrow("History unavailable");
expect(pi.latestSession().closeCalls).toBe(1);
});
test("keeps an interactive Pi runtime open after replaying history", async () => {
const pi = new FakePi();
const session = await createClient(pi).resumeSession({
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session",
metadata: { cwd: tmpdir() },
});
onTestFinished(() => session.close());
for await (const _event of session.streamHistory()) {
// Hydrate the session before its next interactive prompt.
}
expect(pi.latestSession().closeCalls).toBe(0);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ provider: "pi" });
});
test("resumes a session whose model was removed on the model Pi falls back to", async () => {
const pi = new FakePi();
pi.removeModel("9router/deepseek-v4-flash");
@@ -3740,14 +3845,17 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => {
},
);
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
test("a history resume of an agent whose folder is gone removes its session copy after replay and leaves the original unchanged", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
const sessionCopy = pi.recordedLaunches[0]!.session!;
expect(existsSync(sessionCopy)).toBe(true);
await session.close();
for await (const _event of session.streamHistory()) {
// Completion releases both the runtime and its temporary session copy.
}
expect(pi.latestSession().closeCalls).toBe(1);
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
@@ -253,6 +253,7 @@ interface PiRpcAgentSessionOptions {
currentModeId?: string | null;
persistedSessionFile?: string;
cleanup?: () => void;
closeAfterHistory?: boolean;
extensionTimeoutMs?: number;
logger: Logger;
usagePollScheduler?: PiUsagePollScheduler;
@@ -1255,6 +1256,7 @@ export class PiRpcAgentSession implements AgentSession {
this.currentModeId = options.currentModeId ?? null;
this.persistedSessionFile = options.persistedSessionFile;
this.cleanup = options.cleanup;
this.closeAfterHistory = options.closeAfterHistory ?? false;
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
this.logger = options.logger;
this.extensionHost = createPiExtensionHost(this.logger);
@@ -1286,6 +1288,7 @@ export class PiRpcAgentSession implements AgentSession {
private readonly config: AgentSessionConfig;
private readonly persistedSessionFile?: string;
private readonly cleanup?: () => void;
private readonly closeAfterHistory: boolean;
private readonly extensionTimeoutMs: number;
get id(): string | null {
@@ -1454,16 +1457,20 @@ export class PiRpcAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.requestEntryCapture("history");
yield* streamPiHistory(
this.provider,
await this.runtimeSession.getMessages(),
this.contextUserEntries,
{},
// At most eight 2 MiB child files per replay; later cards retain their summaries.
createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024),
this.closeController.signal,
);
try {
await this.requestEntryCapture("history");
yield* streamPiHistory(
this.provider,
await this.runtimeSession.getMessages(),
this.contextUserEntries,
{},
// At most eight 2 MiB child files per replay; later cards retain their summaries.
createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024),
this.closeController.signal,
);
} finally {
if (this.closeAfterHistory) await this.close();
}
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
@@ -2738,6 +2745,7 @@ export class PiRpcAgentClient implements AgentClient {
capabilities: capabilitiesForSession(mcp !== null),
persistedSessionFile: sessionCopy ? sessionFile : undefined,
cleanup: cleanupTempFiles,
closeAfterHistory: options?.purpose === "history",
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
logger: this.logger,
usagePollScheduler: this.usagePollScheduler,
@@ -153,6 +153,7 @@ export class FakePiSession implements PiRuntimeSession {
// The user entries on the current branch that getMessages() replays.
contextUserEntries: FakePiUserEntry[] = [];
abortRequested = false;
closeCalls = 0;
readonly canceledExtensionUiRequests: string[] = [];
readonly extensionUiResponses: Array<{
id: string;
@@ -161,6 +162,7 @@ export class FakePiSession implements PiRuntimeSession {
setModelResult: PiModel | null = null;
models: PiModel[] = [];
messages: PiAgentMessage[] = [];
getMessagesError: Error | null = null;
stats: PiSessionStats = {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
@@ -303,6 +305,7 @@ export class FakePiSession implements PiRuntimeSession {
}
async getMessages(): Promise<PiAgentMessage[]> {
if (this.getMessagesError) throw this.getMessagesError;
return this.messages;
}
@@ -422,7 +425,9 @@ export class FakePiSession implements PiRuntimeSession {
this.respondToExtensionUiRequest(id, { cancelled: true });
}
async close(): Promise<void> {}
async close(): Promise<void> {
this.closeCalls += 1;
}
emit(event: PiRuntimeEvent): void {
for (const subscriber of this.subscribers) {
@@ -2357,3 +2357,74 @@ test("a pre-write host budget rejection leaves sibling routes connected", async
true,
]);
}, 30000);
test.each([false, true])(
"remote prompt to a closed agent (archived=%s) reports its new turn instead of stale closure",
{ timeout: 30000 },
async (archived) => {
const parentPrompts: string[] = [];
const started = Promise.withResolvers<void>();
const finish = Promise.withResolvers<void>();
const a = await daemon({
agentClients: createTestAgentClients({
onStartTurn: (prompt) => parentPrompts.push(String(prompt)),
}),
});
const b = await daemon({
agentClients: createTestAgentClients({
onStartTurn: () => started.resolve(),
beforeTurnComplete: () => finish.promise,
}),
});
cleanup.push(async () => finish.resolve());
const origin = await api(a);
const destination = await api(b);
await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
const remoteId = (await online(origin.paseo)).hosts[0]!.serverId!;
const workspace = await origin.paseo.workspaces.create({
source: { kind: "directory", path: a.staticDir },
});
const remoteWorkspace = await destination.paseo.workspaces.create({
source: { kind: "directory", path: b.staticDir },
});
const parent = await workspace.agents.create({
config: { provider: "claude/claude-test", modeId: "full-access" },
title: "Closed target caller",
});
const child = await remoteWorkspace.agents.create({
config: { provider: "claude/claude-test", modeId: "full-access" },
title: "Closed target",
});
if (archived) await child.archive();
else await b.daemon.agentManager.closeAgent(child.id);
expect((await destination.client.fetchAgent(child.id))?.agent.status).toBe("closed");
const mcp = new Client({ name: "closed-target-caller", version: "1" });
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`),
),
);
cleanup.push(() => mcp.close());
const result = await mcp.callTool({
name: "send_agent_prompt",
arguments: { serverId: remoteId, agentId: child.id, prompt: "Finish the new task" },
});
expect(result.isError).not.toBe(true);
expect(result.structuredContent).toMatchObject({ deliveryStatus: "queued", status: "closed" });
await started.promise;
expect(parentPrompts).toEqual([]);
finish.resolve();
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1);
expect(parentPrompts[0]).toContain('event="finished"');
expect(parentPrompts[0]).not.toContain('event="closed"');
expect((await destination.client.fetchAgent(child.id))?.agent).toMatchObject({
status: "idle",
archivedAt: null,
});
await expect
.poll(async () =>
JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")),
)
.toEqual([]);
},
);
@@ -6,7 +6,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, expect, test, vi } from "vitest";
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
import {
getCheckoutStatus as readCheckoutStatus,
type CheckoutSnapshotFacts,
type CheckoutStatusGit,
} from "../utils/checkout-git.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { createFileObserver } from "./file-observer/index.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
@@ -60,6 +64,49 @@ afterEach(async () => {
}
});
test.each([false, true])(
"disposal waits for an admitted Git refresh after observation ends (unsubscribed: %s)",
async (unsubscribeBeforeDisposal) => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = realpathSync(mkdtempSync(path.join(artifacts, "git-refresh-shutdown-")));
execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
const entered = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(root, "home"),
deps: {
getCheckoutStatus: async (...args) => {
entered.resolve();
await release.promise;
return readCheckoutStatus(...args);
},
},
});
const subscription = service.registerWorkspace({ cwd: root }, () => {});
try {
await entered.promise;
if (unsubscribeBeforeDisposal) subscription.unsubscribe();
let disposed = false;
const closing = service.dispose().then(() => {
disposed = true;
return undefined;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(disposed).toBe(false);
release.resolve();
await closing;
expect(disposed).toBe(true);
} finally {
release.resolve();
subscription.unsubscribe();
await service.dispose();
rmSync(root, { recursive: true, force: true });
}
},
);
test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => {
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-")));
const repo = path.join(root, "repo");
@@ -614,6 +614,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly disposeController = new AbortController();
private disposed = false;
private disposePromise: Promise<void> | null = null;
private readonly pendingRefreshes = new Set<Promise<WorkspaceGitRuntimeSnapshot>>();
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>();
@@ -1092,7 +1093,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.workingTreeWatchResolutions.clear();
this.workingTreeWatchAliases.clear();
this.snapshotUpdatedListeners.clear();
this.disposePromise = this.fileObserver.close();
// Unsubscribing can remove a target while its Git command still owns the cwd.
// Stop admission first, then drain those refreshes before releasing the service.
this.disposePromise = Promise.all([
this.fileObserver.close(),
Promise.allSettled(this.pendingRefreshes),
]).then(() => undefined);
return this.disposePromise;
}
@@ -3212,11 +3218,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
const promise = this.runWorkspaceRefreshLoop(target, request).finally(() => {
this.pendingRefreshes.delete(promise);
const state = target.refreshState;
if (state.status === "in-flight" && state.promise === promise) {
target.refreshState = { status: "idle" };
}
});
this.pendingRefreshes.add(promise);
target.refreshState = {
status: "in-flight",
promise,
@@ -806,25 +806,6 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
test("checks out the authoritative GitLab MR ref before a same-named origin branch", async () => {
const { tempDir, repoDir, paseoHome } = createGitLabMrWithConflictingOriginBranchRepo();
cleanupPaths.push(tempDir);
const sourceRemote = path.join(tempDir, "fork.git");
execFileSync("git", ["clone", "--bare", path.join(tempDir, "origin.git"), sourceRemote], {
stdio: "pipe",
});
const mrHead = execFileSync(
"git",
[
`--git-dir=${path.join(tempDir, "origin.git")}`,
"rev-parse",
"refs/merge-requests/14/head",
],
{ encoding: "utf8" },
).trim();
execFileSync("git", [
`--git-dir=${sourceRemote}`,
"update-ref",
"refs/heads/feature/gitlab-mr",
mrHead,
]);
const gitlab = createGitLabService({
resolveGlabPath: async () => "/usr/bin/glab",
resolveRemoteUrl: async () => "git@gitlab.example.com:example-group/example-project.git",
@@ -839,23 +820,12 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
state: "opened",
source_branch: "feature/gitlab-mr",
target_branch: "main",
author: { username: "mr-author" },
source_project_id: 202,
target_project_id: 101,
}),
stderr: "",
};
}
if (args[0] === "api" && args[1] === "projects/202") {
return {
stdout: JSON.stringify({
namespace: { full_path: "team" },
ssh_url_to_repo: sourceRemote,
http_url_to_repo: "https://gitlab.example.com/team/fork.git",
}),
stderr: "",
};
}
throw new Error(`unexpected glab args: ${args.join(" ")}`);
},
});
@@ -873,17 +843,11 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
expect(result.intent).toMatchObject({
kind: "checkout-change-request",
forge: "gitlab",
headRepository: "team/fork",
localBranchName: "team/feature/gitlab-mr",
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feature/gitlab-mr" },
],
});
expect(result.worktree.branchName).toBe("team/feature/gitlab-mr");
expect(getGitConfigValue(result.worktree.worktreePath, "remote.paseo-pr-14.url")).toBe(
sourceRemote,
);
expect(readFileSync(path.join(result.worktree.worktreePath, "README.md"), "utf8")).toBe(
"authoritative merge request head\n",
);
@@ -558,34 +558,31 @@ describe("runWorktreeSetupInBackground", () => {
});
test("runs setup from an exact workspace subdirectory", async () => {
const { tempDir, repoDir } = createGitRepo();
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-setup-test-")));
cleanupPaths.push(tempDir);
const repoDir = path.join(tempDir, "source");
const sourceWorkspaceCwd = path.join(repoDir, "packages", "app");
const worktree: WorktreeConfig = {
branchName: "feature-subdirectory-setup",
worktreePath: path.join(tempDir, "worktree"),
};
const workspaceCwd = path.join(worktree.worktreePath, "packages", "app");
// Setup consumes an existing workspace. Git creation is covered by the workflow tests.
mkdirSync(sourceWorkspaceCwd, { recursive: true });
mkdirSync(workspaceCwd, { recursive: true });
execFileSync("git", ["init", "-b", worktree.branchName], {
cwd: worktree.worktreePath,
stdio: "pipe",
});
writeFileSync(
path.join(sourceWorkspaceCwd, "paseo.json"),
path.join(workspaceCwd, "paseo.json"),
JSON.stringify({
worktree: {
setup: ["pwd > setup-cwd.txt"],
},
}),
);
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add app setup"], {
cwd: repoDir,
stdio: "pipe",
});
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createLegacyWorktreeForTest({
branchName: "feature-subdirectory-setup",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-subdirectory-setup",
paseoHome,
});
const workspaceCwd = path.join(createdWorktree.worktreePath, "packages", "app");
await runWorktreeSetupInBackground(
{
@@ -600,16 +597,16 @@ describe("runWorktreeSetupInBackground", () => {
requestCwd: sourceWorkspaceCwd,
repoRoot: repoDir,
workspaceId: "ws-subdirectory-setup",
worktree: createdWorktree,
worktree,
shouldBootstrap: true,
slug: "feature-subdirectory-setup",
worktreePath: createdWorktree.worktreePath,
worktreePath: worktree.worktreePath,
workspaceCwd,
},
);
expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true);
expect(existsSync(path.join(createdWorktree.worktreePath, "setup-cwd.txt"))).toBe(false);
expect(existsSync(path.join(worktree.worktreePath, "setup-cwd.txt"))).toBe(false);
});
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
@@ -1,7 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveWorktreeCreationIntent } from "../server/resolve-worktree-creation-intent.js";
import type { PullRequestCommandStatus } from "./forge-service.js";
import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline";
import type { GitLabStatusFacts } from "./gitlab-facts.js";
@@ -465,8 +463,9 @@ describe("createGitLabService", () => {
it("maps a same-repo merge request view to a checkout target", async () => {
const { service, calls } = makeService(() => ok(JSON.stringify(OPEN_MR)));
const target = await service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 });
expect(target).toEqual({
await expect(
service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 }),
).resolves.toEqual({
number: 14,
baseRefName: "main",
headRefName: "release/v0.4.0",
@@ -480,76 +479,30 @@ describe("createGitLabService", () => {
isCrossRepository: false,
headRefKind: "branch",
});
expect(
service.buildPrLocalBranchName?.({
headRef: target!.headRefName,
checkoutTarget: target!,
}),
).toBe("release/v0.4.0");
expect(calls).toEqual([["mr", "view", "14", "-F", "json"]]);
expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]);
});
it.each(["contributor", "team"])(
"resolves the source project rather than the MR author for %s forks",
async (owner) => {
const { service, calls } = makeService((args) => {
if (args[0] === "api")
return ok(
JSON.stringify({
namespace: { full_path: owner },
ssh_url_to_repo: `git@gitlab.example.com:${owner}/fork.git`,
http_url_to_repo: `https://gitlab.example.com/${owner}/fork.git`,
}),
);
return ok(
JSON.stringify({ ...OPEN_MR, source_project_id: 202, author: { username: "mr-author" } }),
);
});
const intent = await resolveWorktreeCreationIntent(
{
action: "checkout",
checkoutSource: { kind: "change_request", forge: "gitlab", number: 14 },
},
"/repo",
{ forge: "gitlab", forgeService: service, resolveDefaultBranch: async () => "main" },
);
expect(intent).toMatchObject({
kind: "checkout-change-request",
headRepositoryOwner: owner,
headRepository: `${owner}/fork`,
pushRemoteUrl: `git@gitlab.example.com:${owner}/fork.git`,
localBranchName: `${owner}/release/v0.4.0`,
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
{ remoteName: "origin", remoteRef: "refs/heads/release/v0.4.0" },
],
});
expect(calls).toEqual([
["mr", "view", "14", "-F", "json"],
["api", "projects/202"],
]);
},
);
it("marks fork merge request checkout targets as cross-repository", async () => {
const { service } = makeService(() =>
ok(
JSON.stringify({
...OPEN_MR,
source_project_id: 202,
target_project_id: 101,
}),
),
);
it("keeps the target MR ref usable when its source project is inaccessible", async () => {
const { service } = makeService((args) => {
if (args[0] === "api") throw { code: 1, stderr: "404 Project Not Found" };
return ok(JSON.stringify({ ...OPEN_MR, source_project_id: 202 }));
});
const target = await service.getPullRequestCheckoutTarget!({ cwd: "/repo", number: 14 });
expect(target).toMatchObject({
await expect(
service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 }),
).resolves.toMatchObject({
number: 14,
headRefName: "release/v0.4.0",
isCrossRepository: true,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/merge-requests/14/head" },
{ remoteName: "origin", remoteRef: "refs/heads/release/v0.4.0" },
],
});
expect(
service.buildPrLocalBranchName!({ headRef: target.headRefName, checkoutTarget: target }),
).toBe("pr-14/release/v0.4.0");
});
it("creates a merge request and parses the URL and iid from glab output", async () => {
+9 -34
View File
@@ -60,7 +60,6 @@ import {
} from "@getpaseo/protocol/check-traits";
import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline";
import { isGitLabStatusFacts, type GitLabStatusFacts } from "./gitlab-facts.js";
import { buildForkLocalBranchName } from "../utils/change-request-checkout.js";
const GLAB_ENV = {
GIT_TERMINAL_PROMPT: "0",
@@ -145,12 +144,6 @@ const GitLabPipelineDetailsSchema = z
})
.passthrough();
const GitLabSourceProjectSchema = z.object({
namespace: z.object({ full_path: z.string() }),
ssh_url_to_repo: z.string(),
http_url_to_repo: z.string(),
});
const GitLabMergeRequestSchema = z
.object({
iid: z.number(),
@@ -956,16 +949,6 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
return runJson(["mr", "view", ref, "-F", "json"], { cwd }, GitLabMergeRequestSchema);
}
async function loadSourceProject(cwd: string, projectId: number) {
try {
return await runJson(["api", `projects/${projectId}`], { cwd }, GitLabSourceProjectSchema);
} catch (error) {
// The target's MR ref remains fetchable even if the source project is private.
if (error instanceof GlabCommandError && /\b404\b/.test(error.stderr)) return null;
throw error;
}
}
async function listMergeRequestsBySourceBranch(
cwd: string,
sourceBranch: string,
@@ -1200,23 +1183,10 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
return mr.source_branch;
},
buildPrLocalBranchName({ headRef, checkoutTarget }) {
return buildForkLocalBranchName({ headRef, ...checkoutTarget });
},
async getPullRequestCheckoutTarget(
input: GetPullRequestOptions,
): Promise<PullRequestCheckoutTarget> {
const mr = await viewMergeRequest(input.cwd, String(input.number));
const isCrossRepository =
mr.source_project_id !== undefined &&
mr.source_project_id !== null &&
mr.target_project_id !== undefined &&
mr.target_project_id !== null &&
mr.source_project_id !== mr.target_project_id;
const sourceProject = isCrossRepository
? await loadSourceProject(input.cwd, mr.source_project_id!)
: null;
return {
number: mr.iid,
baseRefName: mr.target_branch,
@@ -1225,10 +1195,15 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
{ remoteName: "origin", remoteRef: `refs/merge-requests/${mr.iid}/head` },
{ remoteName: "origin", remoteRef: `refs/heads/${mr.source_branch}` },
],
headOwnerLogin: sourceProject?.namespace.full_path ?? null,
headRepositorySshUrl: sourceProject?.ssh_url_to_repo ?? null,
headRepositoryUrl: sourceProject?.http_url_to_repo ?? null,
isCrossRepository,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository:
mr.source_project_id !== undefined &&
mr.source_project_id !== null &&
mr.target_project_id !== undefined &&
mr.target_project_id !== null &&
mr.source_project_id !== mr.target_project_id,
headRefKind: "branch",
};
},