Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] a078dd9bc5 Merge remote-tracking branch 'origin/main' into fix/gitlab-fork-checkout-8159 2026-11-02 09:54:43 +00:00
paseo-bot[bot]andquickwind eecac800b6 fix(gitlab): identify fork checkouts by their source project
Merge request authors do not necessarily own the source fork. Resolve its namespace and repository URLs before naming the checkout and configuring its push remote, retaining the target MR ref when the source returns 404.

Co-authored-by: quickwind <wallacewei@live.cn>
2026-11-02 09:54:18 +00:00
quickwind ea3f2ccd4a fix(gitlab): resolve headOwnerLogin and local branch name for fork merge requests 2026-10-29 12:03:10 +11:00
33 changed files with 352 additions and 1540 deletions
@@ -63,24 +63,6 @@ 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,
@@ -844,192 +826,6 @@ 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,6 +1,4 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { test } from "../support/fixtures";
import {
collapseFolder,
expandFolder,
@@ -10,7 +8,6 @@ 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";
@@ -52,66 +49,3 @@ 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,9 +320,6 @@ 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",
@@ -1,54 +0,0 @@
// 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",
);
@@ -1,44 +0,0 @@
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,15 +64,10 @@ 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,
@@ -440,7 +435,6 @@ export function FileExplorerPane({
const {
requestDirectoryListing,
invalidateDirectoryListings,
createEntry,
renameEntry,
duplicateEntry,
@@ -892,11 +886,9 @@ export function FileExplorerPane({
refreshExplorerDirectories({
hasWorkspaceScope,
expandedPaths,
showHiddenFiles: usePanelStore.getState().explorerShowHiddenFiles,
requestDirectoryListing,
invalidateDirectoryListings,
}),
[invalidateDirectoryListings, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
[expandedPaths, hasWorkspaceScope, requestDirectoryListing],
);
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
@@ -1432,7 +1424,7 @@ function toggleDirectory({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<DirectoryListingResult>;
) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): void {
if (!workspaceStateKey) {
@@ -1569,7 +1561,7 @@ async function initializeExplorer({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<DirectoryListingResult>;
) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): Promise<void> {
if (!hasWorkspaceScope || hasInitializedRef.current) {
@@ -1580,7 +1572,6 @@ async function initializeExplorer({
recordHistory: false,
setCurrentPath: false,
});
if (rootDirectory === DIRECTORY_LISTING_INVALIDATED) return;
if (!rootDirectory) {
hasInitializedRef.current = false;
return;
@@ -1602,10 +1593,7 @@ async function initializeExplorer({
const hiddenPersistedPaths = showHiddenFiles
? []
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
const restoredPathsWithHidden =
restoredPaths === DIRECTORY_LISTING_INVALIDATED
? DIRECTORY_LISTING_INVALIDATED
: [...restoredPaths, ...hiddenPersistedPaths];
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({
persistedExpandedPaths,
@@ -1615,6 +1603,39 @@ 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,26 +1,8 @@
import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react";
import { useCallback, useState, 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. */
@@ -38,24 +20,15 @@ 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}>
<OverlayClearanceContext.Provider value={overlayClearance}>
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
{children(floating ? headerHeight : 0)}
</OverlayHostContext.Provider>
</OverlayClearanceContext.Provider>
</View>
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
{floating ? (
<View style={styles.overlay} pointerEvents="box-none">
<EdgeFade
@@ -64,19 +37,11 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
backdrop="surfaceWorkspace"
style={styles.fade}
/>
<View
testID="floating-header"
collapsable={false}
pointerEvents="box-none"
onLayout={handleHeaderLayout}
>
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
{header}
</View>
</View>
) : null}
<View style={styles.statusOverlay} pointerEvents="box-none">
<PortalHost name={overlayHostName} />
</View>
</View>
);
}
@@ -90,13 +55,6 @@ const styles = StyleSheet.create({
flex: 1,
minHeight: 0,
},
statusOverlay: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
overlay: {
position: "absolute",
top: 0,
+2 -8
View File
@@ -1,7 +1,3 @@
import {
FloatingHeaderOverlay,
useFloatingHeaderOverlayClearance,
} from "@/components/floating-header";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import {
@@ -194,11 +190,9 @@ 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 : panelClearance;
const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
const content = (
<View
@@ -226,7 +220,7 @@ export function ToastViewport({
return createPortal(content, getOverlayRoot());
}
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content;
return 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>
);
}
@@ -1,31 +0,0 @@
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);
});
@@ -1,180 +0,0 @@
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]);
},
);
@@ -1,91 +0,0 @@
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,
}));
}
+1 -75
View File
@@ -1,10 +1,9 @@
import { describe, expect, it } from "vitest";
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { ExplorerEntry } from "@/stores/session-store";
import {
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
flattenExplorerTree,
reconcileRestoredExpandedPaths,
refreshExplorerDirectories,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
@@ -196,76 +195,3 @@ 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",
]);
});
});
+7 -58
View File
@@ -1,6 +1,5 @@
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;
@@ -17,13 +16,11 @@ interface FlattenExplorerTreeInput {
showHiddenFiles: boolean;
}
type RestoredExpandedPaths = string[] | typeof DIRECTORY_LISTING_INVALIDATED;
interface RestoreExpandedDirectoriesInput {
rootDirectory: ExplorerDirectory;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
}
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
@@ -36,7 +33,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
interface ReconcileRestoredExpandedPathsInput {
persistedExpandedPaths: ReadonlySet<string>;
currentExpandedPaths: ReadonlySet<string>;
restoredExpandedPaths: RestoredExpandedPaths;
restoredExpandedPaths: string[];
}
interface SetExpandedDirectoryPathInput {
@@ -88,7 +85,7 @@ export async function restoreExpandedDirectories({
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: RestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
const restoredPaths = ["."];
const restoredPathSet = new Set(restoredPaths);
let parentDirectories = [rootDirectory];
@@ -113,11 +110,9 @@ 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 || directory === DIRECTORY_LISTING_INVALIDATED) {
if (!directory) {
continue;
}
restoredPaths.push(directory.path);
@@ -133,7 +128,7 @@ export function showHiddenFilesAndRestoreExpandedDirectories({
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
showHiddenFiles();
return restoreExpandedDirectories({
rootDirectory,
@@ -148,8 +143,6 @@ 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) {
@@ -212,7 +205,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<DirectoryListingResult>;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
expandPaths: (paths: string[]) => void;
showHiddenFiles: () => void;
}): Promise<void> {
@@ -225,51 +218,7 @@ export async function revealExplorerDirectory(input: {
}
}
for (const path of paths) {
const directory = await input.requestDirectoryListing(path);
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) return;
if (!(await input.requestDirectoryListing(path))) 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,17 +25,12 @@ const iframeStyle = {
export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) {
const { t } = useTranslation();
// 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],
);
const document = useMemo(() => withPreviewCsp(html), [html]);
return (
<iframe
data-testid={testID}
title={t("panels.file.filePanels.builtin.html")}
src={source}
srcDoc={document}
sandbox={SANDBOX}
referrerPolicy="no-referrer"
style={iframeStyle}
@@ -1,18 +1,16 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
import {
useSessionStore,
type AgentFileExplorerState,
type ExplorerDirectory,
} 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,
@@ -24,6 +22,15 @@ 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;
@@ -68,21 +75,16 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
);
const updateExplorerState = useCallback(
(
updater: (prev: AgentFileExplorerState) => AgentFileExplorerState,
): AgentFileExplorerState | null => {
(updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => {
if (!workspaceStateKey) {
return null;
return;
}
let updated: AgentFileExplorerState | null = null;
setFileExplorer(serverId, (prev) => {
const next = new Map(prev);
const current = next.get(workspaceStateKey) ?? createExplorerState();
updated = updater(current);
next.set(workspaceStateKey, updated);
next.set(workspaceStateKey, updater(current));
return next;
});
return updated;
},
[serverId, setFileExplorer, workspaceStateKey],
);
@@ -91,23 +93,80 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
async (
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
): Promise<DirectoryListingResult> => {
): Promise<ExplorerDirectory | null> => {
if (!workspaceStateKey) {
return null;
}
const normalizedPath = path && path.length > 0 ? path : ".";
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);
},
});
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;
}
},
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
@@ -249,17 +308,10 @@ 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,7 +260,6 @@ interface ExplorerRequestState {
}
export interface AgentFileExplorerState {
listingGeneration: number;
directories: Map<string, ExplorerDirectory>;
isLoading: boolean;
lastError: string | null;
@@ -7317,7 +7317,6 @@ 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)!;
@@ -7360,7 +7359,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).toBeUndefined();
expect(stillRunning?.lastError).toBe("turn-b marker");
expect(stillRunning?.lastUsage).toEqual({ inputTokens: 7 });
expect(stillRunning?.pendingPermissions.has("turn-b-permission")).toBe(true);
expect(manager.getTimeline(snapshot.id)).toEqual(timelineBeforeStaleTerminals);
@@ -8684,118 +8683,6 @@ 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,7 +2840,6 @@ 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
@@ -4953,8 +4952,6 @@ 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,16 +399,14 @@ 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") {
// 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.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.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,7 +404,6 @@ 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",
});
@@ -518,7 +517,6 @@ describe("Suite A: Core Fixes", () => {
expect(notification.source).toEqual({
kind: "agent-notification",
agentId: childId,
serverId: daemon.daemon.getServerId(),
title: "Watched child",
event: "errored",
});
@@ -581,11 +579,7 @@ describe("Suite A: Core Fixes", () => {
type: "tool_call",
agentMessage: {
event: outcome,
sender: {
id: childId,
serverId: daemonHandle.daemon.getServerId(),
title: "Follow-up child",
},
sender: { id: childId, title: "Follow-up child" },
text: expect.stringContaining(`(Follow-up child) ${outcome}.`),
},
}),
@@ -643,7 +643,6 @@ interface PendingPermission {
resolve: (response: RequestPermissionResponse) => void;
reject: (error: Error) => void;
turnId: string | null;
toolCallId: string;
}
interface PendingUserMessage {
@@ -2645,7 +2644,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
resolve,
reject,
turnId: this.activeForegroundTurnId,
toolCallId: params.toolCall.toolCallId,
});
});
@@ -3209,9 +3207,6 @@ 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))];
}
@@ -3419,11 +3414,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.deliverTranslatedEvents(this.flushPendingUserMessage());
// 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,
);
// 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();
}
this.activeForegroundTurnId = null;
this.fallbackAssistantMessageId = null;
if (this.submittedUserMessageTurnId === event.turnId) {
@@ -3432,21 +3427,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.pushEvent(event);
}
private cancelPendingPermissions(
matches: (pending: PendingPermission) => boolean = () => true,
): void {
for (const [requestId, pending] of this.pendingPermissions) {
if (!matches(pending)) continue;
this.pendingPermissions.delete(requestId);
private cancelPendingPermissions(): void {
for (const pending of this.pendingPermissions.values()) {
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,7 +5,6 @@ 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";
@@ -13,7 +12,6 @@ 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 }) => {
@@ -27,16 +25,6 @@ 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");
@@ -51,84 +39,6 @@ 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[] {
@@ -137,10 +47,6 @@ 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"],
@@ -171,32 +77,8 @@ 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"
| "end_turn"
| "completed"
| "failed"
| "outside_turn"
| "outside_fail"
| "outside_cancel",
ending: "fail" | "cancel",
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-"));
@@ -209,7 +91,8 @@ async function withFakeACPAgent(
}
}
// Turn one ends while its permission request is still open.
// Turn one asks for permission, then fails or is cancelled while the request is
// still open.
// Later turns report whether that request was ever answered.
const fakeACPAgentScript = `
const readline = require("node:readline");
@@ -218,34 +101,15 @@ 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) {
if (message.id === "perm-1") permissionAnswer = message.result ?? { error: message.error };
if (message.id === "perm-outside") outsidePermissionAnswer = message.result ?? { error: message.error };
if (message.method === undefined && message.id === "perm-1") {
permissionAnswer = message.result ?? { error: message.error };
return;
}
if (message.method === "initialize") {
@@ -253,7 +117,6 @@ 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;
}
@@ -261,32 +124,35 @@ rl.on("line", (line) => {
const sessionId = message.params.sessionId;
promptCount += 1;
if (promptCount === 1) {
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;
}
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" },
],
},
});
setTimeout(() => {
if (ending === "cancel" || ending === "outside_cancel") {
if (ending === "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 answer = ending.startsWith("outside_") ? outsidePermissionAnswer : permissionAnswer;
const requestId = ending.startsWith("outside_") ? "perm-outside" : "perm-1";
const state = answer === null ? "unanswered" : "answered: " + JSON.stringify(answer);
const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer);
write({
method: "session/update",
params: {
sessionId,
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: requestId + " " + state } },
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } },
},
});
write({ id: message.id, result: { stopReason: "end_turn" } });
@@ -1600,111 +1600,6 @@ 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");
@@ -3845,17 +3740,14 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => {
},
);
test("a history resume of an agent whose folder is gone removes its session copy after replay and leaves the original unchanged", async () => {
test("a history resume of an agent whose folder is gone removes its session copy on close 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);
for await (const _event of session.streamHistory()) {
// Completion releases both the runtime and its temporary session copy.
}
expect(pi.latestSession().closeCalls).toBe(1);
await session.close();
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
@@ -253,7 +253,6 @@ interface PiRpcAgentSessionOptions {
currentModeId?: string | null;
persistedSessionFile?: string;
cleanup?: () => void;
closeAfterHistory?: boolean;
extensionTimeoutMs?: number;
logger: Logger;
usagePollScheduler?: PiUsagePollScheduler;
@@ -1256,7 +1255,6 @@ 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);
@@ -1288,7 +1286,6 @@ 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 {
@@ -1457,20 +1454,16 @@ export class PiRpcAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
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();
}
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,
);
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
@@ -2745,7 +2738,6 @@ 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,7 +153,6 @@ 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;
@@ -162,7 +161,6 @@ 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,
@@ -305,7 +303,6 @@ export class FakePiSession implements PiRuntimeSession {
}
async getMessages(): Promise<PiAgentMessage[]> {
if (this.getMessagesError) throw this.getMessagesError;
return this.messages;
}
@@ -425,9 +422,7 @@ export class FakePiSession implements PiRuntimeSession {
this.respondToExtensionUiRequest(id, { cancelled: true });
}
async close(): Promise<void> {
this.closeCalls += 1;
}
async close(): Promise<void> {}
emit(event: PiRuntimeEvent): void {
for (const subscriber of this.subscribers) {
@@ -2357,74 +2357,3 @@ 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,11 +6,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, expect, test, vi } from "vitest";
import {
getCheckoutStatus as readCheckoutStatus,
type CheckoutSnapshotFacts,
type CheckoutStatusGit,
} from "../utils/checkout-git.js";
import type { CheckoutSnapshotFacts, 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";
@@ -64,49 +60,6 @@ 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,7 +614,6 @@ 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>();
@@ -1093,12 +1092,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.workingTreeWatchResolutions.clear();
this.workingTreeWatchAliases.clear();
this.snapshotUpdatedListeners.clear();
// 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);
this.disposePromise = this.fileObserver.close();
return this.disposePromise;
}
@@ -3218,13 +3212,11 @@ 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,6 +806,25 @@ 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",
@@ -820,12 +839,23 @@ 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(" ")}`);
},
});
@@ -843,11 +873,17 @@ 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,31 +558,34 @@ describe("runWorktreeSetupInBackground", () => {
});
test("runs setup from an exact workspace subdirectory", async () => {
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-setup-test-")));
const { tempDir, repoDir } = createGitRepo();
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(workspaceCwd, "paseo.json"),
path.join(sourceWorkspaceCwd, "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(
{
@@ -597,16 +600,16 @@ describe("runWorktreeSetupInBackground", () => {
requestCwd: sourceWorkspaceCwd,
repoRoot: repoDir,
workspaceId: "ws-subdirectory-setup",
worktree,
worktree: createdWorktree,
shouldBootstrap: true,
slug: "feature-subdirectory-setup",
worktreePath: worktree.worktreePath,
worktreePath: createdWorktree.worktreePath,
workspaceCwd,
},
);
expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true);
expect(existsSync(path.join(worktree.worktreePath, "setup-cwd.txt"))).toBe(false);
expect(existsSync(path.join(createdWorktree.worktreePath, "setup-cwd.txt"))).toBe(false);
});
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
@@ -1,5 +1,7 @@
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";
@@ -463,9 +465,8 @@ describe("createGitLabService", () => {
it("maps a same-repo merge request view to a checkout target", async () => {
const { service, calls } = makeService(() => ok(JSON.stringify(OPEN_MR)));
await expect(
service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 }),
).resolves.toEqual({
const target = await service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 });
expect(target).toEqual({
number: 14,
baseRefName: "main",
headRefName: "release/v0.4.0",
@@ -479,30 +480,76 @@ describe("createGitLabService", () => {
isCrossRepository: false,
headRefKind: "branch",
});
expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]);
expect(
service.buildPrLocalBranchName?.({
headRef: target!.headRefName,
checkoutTarget: target!,
}),
).toBe("release/v0.4.0");
expect(calls).toEqual([["mr", "view", "14", "-F", "json"]]);
});
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.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"],
]);
},
);
await expect(
service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 }),
).resolves.toMatchObject({
number: 14,
headRefName: "release/v0.4.0",
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({
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 () => {
+34 -9
View File
@@ -60,6 +60,7 @@ 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",
@@ -144,6 +145,12 @@ 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(),
@@ -949,6 +956,16 @@ 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,
@@ -1183,10 +1200,23 @@ 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,
@@ -1195,15 +1225,10 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
{ remoteName: "origin", remoteRef: `refs/merge-requests/${mr.iid}/head` },
{ remoteName: "origin", remoteRef: `refs/heads/${mr.source_branch}` },
],
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,
headOwnerLogin: sourceProject?.namespace.full_path ?? null,
headRepositorySshUrl: sourceProject?.ssh_url_to_repo ?? null,
headRepositoryUrl: sourceProject?.http_url_to_repo ?? null,
isCrossRepository,
headRefKind: "branch",
};
},