Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] f71591d5a3 test(server): accept reset connections from timed-out Git helpers
Force-killed Windows helpers reset their TCP sockets. Silent-remote fixtures must handle ECONNRESET while continuing to fail on unexpected socket errors.
2026-10-25 16:26:14 +00:00
paseo-bot[bot] d5a83362a7 fix(server): stop Git transport helpers on command timeout
Killing only the direct Git process left HTTP and SSH helpers holding connections after a fetch timed out. Use the existing process-tree cleanup so repeated refreshes do not accumulate those helpers.
2026-10-25 16:26:14 +00:00
paseo-bot[bot] d0fd2e5320 fix(client): close failed SSH tunnels without crashing callers (#7876)
Route SSH stdin stream errors through the existing tunnel failure handler so pending writes cannot emit an unhandled EPIPE. Reproduce the peer abort using real OpenSSH in an isolated caller process.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-25 15:50:06 +00:00
paseo-bot[bot]androyenheart 5dc350034e Keep large Import Session lists responsive on Web (#7872)
* Virtualize long Import Session Web lists while preserving native and short-list scrolling

* fix(app): measure import list headers through the existing layout event

Keep the virtualized import list usable in the existing component harness without introducing an observer mock. Import React for the unit JSX transform.

Co-authored-by: royenheart <royenheart@outlook.com>

---------

Co-authored-by: royenheart <royenheart@outlook.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-25 15:05:12 +00:00
4845e1da58 fix(app): link bare URLs with ports, colons and user info as one link (#7859)
MD4C's permissive URL autolink read the host, never a ":port", and
accepted ":" as a link boundary. "http://127.0.0.1:8059/?token=abc"
linked only "http://127.0.0.1". A colon in the path ended the link the
same way.

Port the upstream md4c fix into the dependency patch:
- 3da1937: accept single-component hosts and a numeric port after an
  explicit scheme
- f895c2b: scan the URL tail continuously and trim terminal
  punctuation and excess closing parentheses
- 1645761: refuse an empty hostname
- f9e5eb2: stop the tail rescan from going quadratic on long lines

Also refuse the autolink when a colon after the host continues the URL
("user:pass@host"). Upstream does not parse user info, and the
single-component host rule would link only "http://user". The
text-link pattern links these URLs whole.

Rebuild the web parser with emsdk 4.0.10, which reproduces the
existing glue. That version has no GROWABLE_ARRAYBUFFERS setting, and
the existing artifact was built without it.

The fallback text-link pattern handles URLs MD4C rejects, like
user@host and IPv6 hosts. It now stops at quotes and unbalanced
parentheses and leaves trailing sentence punctuation outside the link.

Closes #7440

Co-authored-by: Mariotaku <git@mariotaku.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:01:31 +00:00
paseo-bot[bot]andDavin 72e3316f4f fix(opencode): show model details on v2 subagent rows (#7799)
The v2 child descriptor omitted the session's presentation facts, leaving
completed children with title-only rows. Reuse the existing subtitle
formatter for both live updates and reconciliation.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Davin <128177533+Davnn1@users.noreply.github.com>
2026-10-24 13:58:06 +00:00
Okan YILDIRIM 566218d682 fix(claude): avoid probing file targets during history replay (#7789) 2026-10-24 11:58:30 +00:00
Okan YILDIRIM b8bb335c56 fix(app): close comboboxes when their retained panel becomes inactive (#7783)
Close open comboboxes through their existing handler and remove their
presentation before a retained chat can freeze its closing update.

The compact regression came from unmounting the sheet while its visibility
tracker survived in Combobox. Its non-closed phase blocked the next present().
Keep the tracker inside the sheet component so they unmount together, and
preserve the desktop closed-state guard in Combobox.

Cover draft and existing-chat model selectors and the compact tab switcher
through Settings, Back, and reopening. The desktop regressions also verify
Escape dismissal.
2026-10-24 07:25:30 +00:00
paseo-ai[bot] e01a38d943 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-24 06:13:55 +00:00
45 changed files with 1362 additions and 268 deletions
-6
View File
@@ -1,11 +1,5 @@
# Changelog # Changelog
## 0.13.1 - 2026-10-26
### Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt))
## 0.13.0 - 2026-10-24 ## 0.13.0 - 2026-10-24
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
-6
View File
@@ -1,11 +1,5 @@
# 更新日志 # 更新日志
## 0.13.1 - 2026-10-26
### 修复
- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献)
## 0.13.0 - 2026-10-24 ## 0.13.0 - 2026-10-24
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。 折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU= sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
+28 -28
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.13.1", "version": "0.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.13.1", "version": "0.13.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
@@ -35695,7 +35695,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.13.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
@@ -37961,12 +37961,12 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.13.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.1", "@getpaseo/server": "0.13.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -38213,10 +38213,10 @@
}, },
"packages/client": { "packages/client": {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.13.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.1", "@getpaseo/relay": "0.13.0",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -38228,7 +38228,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.13.1", "version": "0.13.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -38472,7 +38472,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.13.1", "version": "0.13.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
@@ -42124,7 +42124,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.13.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3", "@codemirror/legacy-modes": "^6.5.3",
@@ -42153,15 +42153,15 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.13.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^1.4.0", "@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.0",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.2.3", "react": "19.2.3",
@@ -42171,8 +42171,8 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.0",
"react": ">=19.1.0 <20", "react": ">=19.1.0 <20",
"react-native": ">=0.81.5", "react-native": ">=0.81.5",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -42194,7 +42194,7 @@
}, },
"packages/protocol": { "packages/protocol": {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.13.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"semver": "^7.8.5", "semver": "^7.8.5",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -42221,7 +42221,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.13.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"@noble/ciphers": "^2.4.0", "@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0", "@noble/curves": "^2.4.0",
@@ -42442,16 +42442,16 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.13.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.1", "@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.1", "@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.1", "@getpaseo/relay": "0.13.0",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
@@ -43372,7 +43372,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.13.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1", "@cloudflare/workers-types": "^4.20260317.1",
@@ -44982,7 +44982,7 @@
}, },
"plugins": { "plugins": {
"name": "@getpaseo/builtin-plugins", "name": "@getpaseo/builtin-plugins",
"version": "0.13.1", "version": "0.13.0",
"dependencies": { "dependencies": {
"@getpaseo/plugin": "*", "@getpaseo/plugin": "*",
"zod": "^4.4.3" "zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.13.1", "version": "0.13.0",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents", "description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [ "keywords": [
@@ -229,6 +229,143 @@ test("captures the desktop import sheet and command-center entry", async ({ page
}); });
}); });
test("large import lists keep mounted rows bounded and remain scrollable", async ({
page,
}, testInfo) => {
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
await mkdir(fixtureDirectory, { recursive: true });
try {
const newest = Date.now();
for (let index = 0; index < 200; index += 1) {
const id = `virtualized-${String(index).padStart(3, "0")}`;
const file = path.join(fixtureDirectory, `${id}.jsonl`);
await writeFile(
file,
`${JSON.stringify({
type: "user",
sessionId: id,
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
message: {
role: "user",
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
},
})}\n`,
);
const timestamp = new Date(newest - index * 10);
await utimes(file, timestamp, timestamp);
}
for (const width of [1280, 390]) {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
await flow.openGlobally();
const rows = page.locator('[data-testid^="import-session-session-"]');
const scrollToEnd = async (end = true) => {
// Scroll the body's real overflow element. Virtual rows need not exist yet.
await rows.first().evaluate((row, atEnd) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
element = element.parentElement;
}
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = atEnd ? element.scrollHeight : 0;
element.dispatchEvent(new Event("scroll"));
}, end);
};
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
const loadMore = page.getByTestId("import-session-load-more");
await expect(async () => {
await scrollToEnd();
await expect(loadMore).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await loadMore.click();
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
}
await expect(async () => {
await scrollToEnd();
await expect(
page.getByTestId("import-session-session-claude-virtualized-199"),
).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await expect.poll(() => rows.count()).toBeLessThan(100);
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
if (width === 1280) {
const row = (index: number) =>
page.getByTestId(
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
);
await expect(async () => {
await scrollToEnd(false);
await expect(row(0)).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await row(0).focus();
for (let index = 1; index <= 30; index += 1) {
await page.keyboard.press("Tab");
await expect(row(index)).toBeFocused();
}
for (let index = 29; index >= 15; index -= 1) {
await page.keyboard.press("Shift+Tab");
await expect(row(index)).toBeFocused();
}
const list = page.getByTestId("import-session-list");
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
const listener = await list.evaluateHandle((node, eventName) => {
const promise = new Promise<void>((resolve) => {
node.addEventListener(eventName, () => resolve(), { once: true });
});
return { promise };
}, event);
try {
await page.keyboard.press(key);
await listener.evaluate((value) => value.promise);
} finally {
await listener.dispose();
}
};
const beforePageDown = await list.evaluate((node) => node.scrollTop);
await scrollWithKey("scroll", "PageDown");
await expect
.poll(() => list.evaluate((node) => node.scrollTop))
.toBeGreaterThan(beforePageDown);
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(15)).toBeFocused();
await expect(row(199)).toBeInViewport();
// Home must remain at the first row after the native page scroll ends.
await scrollWithKey("scrollend", "Home");
await expect(row(0)).toBeInViewport();
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(199)).toBeInViewport();
await row(199).focus();
await scrollWithKey("scroll", "PageUp");
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
await scrollWithKey("scrollend", "End");
await expect(row(199)).toBeInViewport();
await expect(row(199)).toBeFocused();
await testInfo.attach("keyboard-scroll-owner", {
body: JSON.stringify(
await list.evaluate((node) => ({
focusedRow: document.activeElement?.getAttribute("data-testid"),
scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
})),
),
contentType: "application/json",
});
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
}
await flow.close();
}
} finally {
await rm(fixtureDirectory, { recursive: true, force: true });
}
});
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> { async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const outputPath = testInfo.outputPath(name); const outputPath = testInfo.outputPath(name);
await page.screenshot({ path: outputPath, fullPage: true }); await page.screenshot({ path: outputPath, fullPage: true });
@@ -43,6 +43,8 @@ import {
} from "../support/helpers/settings"; } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions"; import { expectAppRoute } from "../support/helpers/route-assertions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function openWorkspace( async function openWorkspace(
page: import("@playwright/test").Page, page: import("@playwright/test").Page,
@@ -53,6 +55,74 @@ async function openWorkspace(
} }
test.describe("Settings sidebar navigation", () => { test.describe("Settings sidebar navigation", () => {
test("opening Settings closes the composer model selector until it is opened again", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
});
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
page,
}, testInfo) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-existing-model-",
title: "Settings model selector",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
});
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => { test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
@@ -147,6 +217,42 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => { test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } }); test.use({ viewport: { width: 390, height: 844 } });
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-tab-switcher-",
title: "Compact tab switcher",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
await switcher.click();
await expect(newAgent).toBeInViewport();
// Navigate without dismissing the sheet through its backdrop first.
await page
.getByTestId("menu-button")
.filter({ visible: true })
.first()
.dispatchEvent("click");
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
await expectAppRoute(page, buildSettingsRoute());
await expect(newAgent).toHaveCount(0);
await closeCompactSettings(page);
await expect(switcher).toBeVisible();
await expect(newAgent).toHaveCount(0);
await switcher.click();
await expect(newAgent).toBeInViewport();
} finally {
await workspace.cleanup();
}
});
test("opens compact app and host details and returns through the settings list", async ({ test("opens compact app and host details and returns through the settings list", async ({
page, page,
}) => { }) => {
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
await expectMobileAgentSidebarVisible(this.page); await expectMobileAgentSidebarVisible(this.page);
} }
await openGlobalNewWorkspaceComposer(this.page); await openGlobalNewWorkspaceComposer(this.page);
const button = this.page.getByTestId("new-workspace-import-session"); const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
await expect(button).toHaveAccessibleName("Import session"); await expect(button).toHaveAccessibleName("Import session");
await expect(button).toBeInViewport(); await expect(button).toBeInViewport();
} }
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
await this.openFromNewWorkspace(); await this.openFromNewWorkspace();
} }
async openFromNewWorkspace() { async openFromNewWorkspace() {
await this.page.getByTestId("new-workspace-import-session").click(); await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
await this.expectSheetReady(); await this.expectSheetReady();
} }
async openFromWorkspaceHeader() { async openFromWorkspaceHeader() {
@@ -86,7 +86,29 @@ export class ImportSessionFlow {
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width); expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
} }
async revealSession(id: string) { async revealSession(id: string) {
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded(); const target = this.page.getByTestId(rowTestId(id));
if ((await target.count()) === 0) {
await this.scrollList(false);
await expect(async () => {
if ((await target.count()) === 0) await this.scrollList(true);
await expect(target).toHaveCount(1, { timeout: 100 });
}).toPass({ timeout: 10_000 });
}
await target.scrollIntoViewIfNeeded();
await expect(target).toBeInViewport();
}
private async scrollList(forward: boolean) {
await this.page
.locator('[data-testid^="import-session-session-"]')
.first()
.evaluate((row, advance) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY))
element = element.parentElement;
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = advance ? element.scrollTop + element.clientHeight * 0.8 : 0;
element.dispatchEvent(new Event("scroll"));
}, forward);
} }
async search(query: string) { async search(query: string) {
await this.page.getByTestId("import-session-search").fill(query); await this.page.getByTestId("import-session-search").fill(query);
@@ -100,7 +122,8 @@ export class ImportSessionFlow {
} }
async loadMore() { async loadMore() {
await this.page.getByTestId("import-session-load-more").click(); await this.page.getByTestId("import-session-load-more").click();
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible(); await this.revealSession("fixture-root-20");
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0); await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
} }
async retryProvider(provider: string, label: string) { async retryProvider(provider: string, label: string) {
@@ -111,6 +134,7 @@ export class ImportSessionFlow {
await expect(this.page.getByRole("progressbar")).toHaveCount(0); await expect(this.page.getByRole("progressbar")).toHaveCount(0);
} }
async importSession(id: string) { async importSession(id: string) {
await this.revealSession(id);
await this.page.getByTestId(rowTestId(id)).click(); await this.page.getByTestId(rowTestId(id)).click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, { await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
timeout: 30_000, timeout: 30_000,
@@ -138,7 +162,7 @@ export class ImportSessionFlow {
); );
} }
async close() { async close() {
await this.page.keyboard.press("Escape"); await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0); await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
} }
async expectCommandCenterMatch() { async expectCommandCenterMatch() {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.13.1", "version": "0.13.0",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -0,0 +1,4 @@
import type { ImportSessionListComponent } from "./import-session-list.types";
// Native keeps the sheet-owned ScrollView until its gestures and keyboard are verified.
export const ImportSessionList: ImportSessionListComponent | null = null;
@@ -0,0 +1,13 @@
import type { ReactElement, ReactNode } from "react";
export interface ImportSessionListProps<Item> {
data: ReadonlyArray<Item>;
keyExtractor: (item: Item) => string;
renderItem: (item: Item) => ReactNode;
header: ReactElement | null;
footer: ReactElement | null;
}
export type ImportSessionListComponent = <Item>(
props: ImportSessionListProps<Item>,
) => ReactElement;
@@ -0,0 +1,141 @@
import React, {
useCallback,
useMemo,
useRef,
useState,
type CSSProperties,
type FocusEvent,
type KeyboardEvent,
} from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual";
import type { ImportSessionListProps } from "./import-session-list.types";
const SCROLLER_STYLE: CSSProperties = {
flex: 1,
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
overscrollBehavior: "contain",
};
const CONTENT_STYLE: CSSProperties = { width: "100%", position: "relative" };
export function ImportSessionList<Item>({
data,
keyExtractor,
renderItem,
header,
footer,
}: ImportSessionListProps<Item>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [headerHeight, setHeaderHeight] = useState(0);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const indexByKey = useMemo(
() => new Map(data.map((item, index) => [keyExtractor(item), index])),
[data, keyExtractor],
);
const focusedIndex = focusedKey === null ? undefined : indexByKey.get(focusedKey);
const rangeExtractor = useCallback(
(range: Range) => {
const visible = defaultRangeExtractor(range);
if (focusedIndex === undefined) return visible;
// Keep focus and its Tab neighbours mounted when PageDown/End moves the window.
const focused = [focusedIndex - 1, focusedIndex, focusedIndex + 1].filter(
(index) => index >= 0 && index < data.length,
);
return [...new Set([...visible, ...focused])].sort((left, right) => left - right);
},
[data.length, focusedIndex],
);
const handleFocus = useCallback((event: FocusEvent<HTMLDivElement>) => {
const row = event.target.closest<HTMLElement>("[data-session-key]");
setFocusedKey(row?.dataset.sessionKey ?? null);
}, []);
const handleBlur = useCallback((event: FocusEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null);
}, []);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(event.nativeEvent.layout.height);
}, []);
const getScrollElement = useCallback(() => scrollRef.current, []);
const getItemKey = useCallback(
(index: number) => keyExtractor(data[index]!),
[data, keyExtractor],
);
const estimateSize = useCallback(() => 88, []);
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement,
getItemKey,
estimateSize,
scrollMargin: headerHeight,
overscan: 5,
rangeExtractor,
});
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const target = event.target;
if (
event.defaultPrevented ||
!(target instanceof HTMLElement) ||
!event.currentTarget.contains(target) ||
target.isContentEditable ||
target.closest("input, textarea, select")
)
return;
if (event.key === "End") {
event.preventDefault();
// Reconcile the landing position as variable-height rows are measured.
virtualizer.scrollToIndex(data.length - 1, { align: "end" });
} else if (event.key === "Home") {
event.preventDefault();
virtualizer.scrollToOffset(0);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
// Native page-scroll animations can keep moving after Home or End.
const direction = event.key === "PageDown" ? 1 : -1;
virtualizer.scrollBy(direction * event.currentTarget.clientHeight);
}
},
[data.length, virtualizer],
);
const virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const contentStyle = useMemo(() => ({ ...CONTENT_STYLE, height: totalSize }), [totalSize]);
const rowStyle = useCallback(
(offset: number): CSSProperties => ({
...CONTENT_STYLE,
position: "absolute",
top: 0,
transform: `translateY(${offset}px)`,
}),
[],
);
return (
<div
ref={scrollRef}
style={SCROLLER_STYLE}
data-testid="import-session-list"
onFocusCapture={handleFocus}
onBlurCapture={handleBlur}
onKeyDown={handleKeyDown}
>
<View onLayout={handleHeaderLayout}>{header}</View>
<div style={contentStyle}>
{virtualRows.map((row) => (
<div
key={row.key}
data-index={row.index}
data-session-key={row.key}
ref={virtualizer.measureElement}
style={rowStyle(row.start - headerHeight)}
>
{renderItem(data[row.index]!)}
</div>
))}
</div>
<div>{footer}</div>
</div>
);
}
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -11,6 +11,7 @@ import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ImportSessionList } from "@/components/import-session-list";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons"; import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
@@ -371,7 +372,33 @@ function ImportSessionActivityTime({ date }: { date: string }) {
return <Text style={styles.rowMeta}>{label}</Text>; return <Text style={styles.rowMeta}>{label}</Text>;
} }
function getSessionRowKey(entry: FetchRecentProviderSessionEntry): string {
return `${entry.providerId}:${entry.providerHandleId}`;
}
function hasListHeader(
input: SheetStatusMessagesProps & {
showFilter: boolean;
hasProviderErrors: boolean;
},
): boolean {
return (
input.showFilter ||
!input.isClientReady ||
input.isSnapshotUnsupported ||
input.hasNoImportableProviders ||
(input.isLoadingSessions && !input.hasRows) ||
input.importErrored ||
input.hasProviderErrors
);
}
function SessionRows({ function SessionRows({
visible,
onClose,
header,
listHeader,
listFooter,
serverId, serverId,
entries, entries,
disabled, disabled,
@@ -379,6 +406,11 @@ function SessionRows({
resolveFolder, resolveFolder,
onImportSession, onImportSession,
}: { }: {
visible: boolean;
onClose: () => void;
header: SheetHeader;
listHeader: ReactNode;
listFooter: ReactNode;
serverId: string | null; serverId: string | null;
entries: ReadonlyArray<FetchRecentProviderSessionEntry>; entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
disabled: boolean; disabled: boolean;
@@ -386,20 +418,69 @@ function SessionRows({
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null; resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void; onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) { }) {
// Keep short results content-sized; only long lists need a bounded viewport.
const virtualized = ImportSessionList !== null && entries.length > 8;
const renderSessionRow = useCallback(
(entry: FetchRecentProviderSessionEntry) => (
<ImportSessionSheetRow
key={getSessionRowKey(entry)}
serverId={serverId}
entry={entry}
disabled={disabled}
importing={importingSessionKey === getSessionRowKey(entry)}
folder={resolveFolder(entry)}
onImportSession={onImportSession}
/>
),
[serverId, disabled, importingSessionKey, resolveFolder, onImportSession],
);
const renderItem = useCallback(
(item: FetchRecentProviderSessionEntry) => (
<View style={styles.listRow}>{renderSessionRow(item)}</View>
),
[renderSessionRow],
);
const virtualHeader = useMemo(
() => (listHeader ? <View style={styles.listHeader}>{listHeader}</View> : null),
[listHeader],
);
const virtualFooter = useMemo(
() => <View style={styles.listFooter}>{listFooter}</View>,
[listFooter],
);
return ( return (
<View style={styles.list}> <AdaptiveModalSheet
{entries.map((entry) => ( visible={visible}
<ImportSessionSheetRow onClose={onClose}
key={`${entry.providerId}:${entry.providerHandleId}`} header={header}
serverId={serverId} testID="import-session-sheet"
entry={entry} desktopMaxWidth={560}
disabled={disabled} desktopHeight={virtualized ? "85%" : undefined}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`} snapPoints={IMPORT_SHEET_SNAP_POINTS}
folder={resolveFolder(entry)} scrollable={!virtualized}
onImportSession={onImportSession} contentStyle={virtualized ? styles.listBody : undefined}
/> >
))} {ImportSessionList && virtualized ? (
</View> <View style={styles.list}>
<ImportSessionList
data={entries}
keyExtractor={getSessionRowKey}
renderItem={renderItem}
header={virtualHeader}
footer={virtualFooter}
/>
</View>
) : (
<>
{listHeader}
{entries.length > 0 ? (
<View style={styles.smallList}>{entries.map(renderSessionRow)}</View>
) : null}
{listFooter}
</>
)}
</AdaptiveModalSheet>
); );
} }
@@ -726,85 +807,124 @@ export function ImportSessionSheet({
}); });
const showFilter = filterProviders.length > 1; const showFilter = filterProviders.length > 1;
const showLoadMore = hasMoreSessions(queries, pageLimit); const showLoadMore = hasMoreSessions(queries, pageLimit);
const showListHeader = hasListHeader({
showFilter,
isClientReady: Boolean(client),
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
hasRows: visibleEntries.length > 0,
importErrored: importMutation.isError,
hasProviderErrors: providerErrorRows.length > 0,
});
const listHeader = useMemo(
() =>
showListHeader ? (
<View style={styles.listHeaderContent}>
{showFilter ? (
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
value={selectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
/>
</View>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
importErrored={importMutation.isError}
/>
{providerErrorRows.length > 0 ? (
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
) : null}
</View>
) : null,
[
showListHeader,
showFilter,
filterTriggerStyle,
handleFilterOpen,
selectedProvider,
theme.colors.foregroundMuted,
SelectedProviderIcon,
selectedProviderLabel,
filterComboboxOptions,
handleFilterSelect,
renderFilterOption,
isFilterOpen,
client,
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
visibleEntries.length,
importMutation.isError,
providerErrorRows,
handleRetryProvider,
],
);
const listFooter = useMemo(
() => (
<>
{showLoadMore ? (
<View style={styles.footer}>
<Button
variant="ghost"
onPress={handleLoadMore}
disabled={isRefreshing}
testID="import-session-load-more"
>
{t("importSession.actions.loadMore")}
</Button>
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</>
),
[showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle],
);
return ( return (
<AdaptiveModalSheet <SessionRows
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
header={header} header={header}
testID="import-session-sheet" listHeader={listHeader}
desktopMaxWidth={560} listFooter={listFooter}
snapPoints={IMPORT_SHEET_SNAP_POINTS} serverId={serverId}
> entries={visibleEntries}
{showFilter ? ( disabled={importMutation.isPending}
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}> importingSessionKey={importingSessionKey}
<Pressable resolveFolder={resolveFolder}
onPress={handleFilterOpen} onImportSession={handleImportSession}
style={filterTriggerStyle} />
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
value={selectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
/>
</View>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
importErrored={importMutation.isError}
/>
{providerErrorRows.length > 0 ? (
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
) : null}
{visibleEntries.length > 0 ? (
<SessionRows
serverId={serverId}
entries={visibleEntries}
disabled={importMutation.isPending}
importingSessionKey={importingSessionKey}
resolveFolder={resolveFolder}
onImportSession={handleImportSession}
/>
) : null}
{showLoadMore ? (
<View style={styles.footer}>
<Button
variant="ghost"
onPress={handleLoadMore}
disabled={isRefreshing}
testID="import-session-load-more"
>
{t("importSession.actions.loadMore")}
</Button>
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</AdaptiveModalSheet>
); );
} }
@@ -853,9 +973,31 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
}, },
listBody: {
gap: 0,
},
list: { list: {
flex: 1,
minHeight: 0,
marginHorizontal: -theme.spacing[2],
},
listHeader: {
marginHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
listHeaderContent: {
gap: theme.spacing[4],
},
smallList: {
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
listFooter: {
marginHorizontal: theme.spacing[2],
},
listRow: {
marginHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
footer: { footer: {
paddingTop: theme.spacing[2], paddingTop: theme.spacing[2],
alignItems: "center", alignItems: "center",
@@ -249,6 +249,35 @@ describe("bare URL sentence punctuation", () => {
]); ]);
}); });
it.each([
["http://127.0.0.1:8059/?token=t-1", ""],
["http://example.com:8080/a", "."],
["http://localhost:8059/?token=t-1", "."],
["http://localhost:8059/?token=t-1", ")"],
["http://user@127.0.0.1:8059/?token=t-1", '"'],
["http://user:pass@127.0.0.1:8059/?token=t-1", ""],
["http://localhost", ":"],
["http://[::1]:8059/?token=t-1", "'"],
["https://github.com/o/r/compare/main...user:repo:fix/branch", ""],
["https://example.com/a:b/c", ":"],
])("links %s whole and leaves %j outside", async (url, punctuation) => {
const prepared = prepareMarkdownSource(`See ${url}${punctuation} now`);
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
expect(tree.children?.[0]?.children).toEqual([
{ type: "Text", content: "See " },
{
type: "Link",
attributes: expect.objectContaining({ url }),
children: [{ type: "Text", content: url }],
},
{ type: "Text", content: `${punctuation} now` },
]);
});
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])( it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
"preserves punctuation in explicit destinations: %s", "preserves punctuation in explicit destinations: %s",
async (source) => { async (source) => {
@@ -27,11 +27,17 @@ const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PAT
function createFilePattern(path: string): RegExp { function createFilePattern(path: string): RegExp {
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape. // The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
return new RegExp( return new RegExp(
String.raw`(?:https?://[^\s<>]+|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`, String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g", "g",
); );
} }
// Matches bare URLs that MD4C does not autolink, like single-label or bracketed hosts.
// Quotes and unbalanced closing parentheses end the URL. Trailing sentence punctuation stays outside.
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const FILE_PATTERN = createFilePattern(PROSE_PATH); const FILE_PATTERN = createFilePattern(PROSE_PATH);
function linkMetadata(inlineCodePaths: ReadonlySet<string>) { function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
// The engine accepts regexes on both platforms. Classify complete code spans // The engine accepts regexes on both platforms. Classify complete code spans
@@ -2,7 +2,6 @@
* @vitest-environment jsdom * @vitest-environment jsdom
*/ */
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
@@ -487,20 +486,6 @@ describe("sidebar group visibility", () => {
}, },
); );
it("treats placements without a hydrated entry as Done", () => {
const items = entries([...Array(8).fill("done"), "running", "done"]);
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
for (const entriesByKey of [new Map(), hydrated]) {
const { result } = renderHook(() =>
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
);
expect(result.current.canToggle).toBe(true);
expect(names(result.current.visibleItems)).toEqual(
entriesByKey.size === 0 ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-9"],
);
}
});
it("shows eight active rows once with no redundant toggle", () => { it("shows eight active rows once with no redundant toggle", () => {
const items = entries([ const items = entries([
"running", "running",
@@ -99,7 +99,6 @@ import {
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import { import {
SidebarWorkspaceRowFrame, SidebarWorkspaceRowFrame,
@@ -1594,7 +1593,11 @@ function ProjectBlock({
onToggleWorkspacePin: ToggleSidebarWorkspacePin; onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) { }) {
const visibilityPolicy = useMemo( const visibilityPolicy = useMemo(
() => createWorkspaceVisibilityPolicy<SidebarWorkspacePlacement>(workspaceEntriesByKey), () => ({
kind: "workspaces" as const,
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
}),
[workspaceEntriesByKey], [workspaceEntriesByKey],
); );
const { const {
@@ -4,18 +4,6 @@ export type SidebarGroupVisibilityPolicy<T> =
| { kind: "pinned" } | { kind: "pinned" }
| { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket }; | { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket };
// A placement can outrun its entry: a retained sidebar that is inactive (compact layout with the
// sidebar closed) or still waiting for sessions keeps an empty entry map. A missing entry renders
// no row, so it counts as Done and claims no slot past the limit.
export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string }>(
entriesByKey: ReadonlyMap<string, { statusBucket: SidebarStateBucket }>,
): SidebarGroupVisibilityPolicy<T> {
return {
kind: "workspaces",
getStatusBucket: (item) => entriesByKey.get(item.workspaceKey)?.statusBucket ?? "done",
};
}
export function selectSidebarGroupVisibility<T>( export function selectSidebarGroupVisibility<T>(
items: readonly T[], items: readonly T[],
expanded: boolean, expanded: boolean,
+32 -23
View File
@@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -910,13 +911,9 @@ function resolveInitialActiveIndex(
return selectedIndex >= 0 ? selectedIndex : fallbackIndex; return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
} }
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
interface MobileBodyProps { interface MobileBodyProps {
bottomSheetRef: BottomSheetVisibility["sheetRef"]; isOpen: boolean;
snapPoints: string[]; snapPoints: string[];
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
handleIndicatorStyle: { backgroundColor: string }; handleIndicatorStyle: { backgroundColor: string };
titleColor: string; titleColor: string;
title: string; title: string;
@@ -944,7 +941,22 @@ interface MobileBodyProps {
safeAreaBottom: number; safeAreaBottom: number;
} }
function MobileComboboxBody(props: MobileBodyProps): ReactElement { function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
const active = useRetainedPanelActive();
if (!active) return null;
// The visibility tracker must leave the tree with its sheet.
return <MobileComboboxSheet {...props} />;
}
function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: props.isOpen,
onClose: props.onClose,
});
const comboboxTitleStyle = useMemo( const comboboxTitleStyle = useMemo(
() => [styles.comboboxTitle, { color: props.titleColor }], () => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor], [props.titleColor],
@@ -979,13 +991,13 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
return ( return (
<IsolatedBottomSheetModal <IsolatedBottomSheetModal
ref={props.bottomSheetRef} ref={bottomSheetRef}
contextBridge={null} contextBridge={null}
snapPoints={props.snapPoints} snapPoints={props.snapPoints}
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
onChange={props.handleSheetChange} onChange={handleSheetChange}
onDismiss={props.handleSheetDismiss} onDismiss={handleSheetDismiss}
backdropOpacity={0.45} backdropOpacity={0.45}
enablePanDownToClose enablePanDownToClose
backgroundComponent={ComboboxSheetBackground} backgroundComponent={ComboboxSheetBackground}
@@ -1168,7 +1180,8 @@ function DesktopComboboxOptionsBody(props: {
); );
} }
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
const active = useRetainedPanelActive();
const handleDesktopKey = props.handleDesktopKey; const handleDesktopKey = props.handleDesktopKey;
const handleWebOverlayKeyDown = useCallback( const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
@@ -1190,6 +1203,9 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
[props.refs, setWebOverlayScope], [props.refs, setWebOverlayScope],
); );
// Remove the portal before a retained chat can freeze its closing update.
if (!active) return null;
const overlay = ( const overlay = (
<OverlayLayerProvider layer={props.overlayLayer}> <OverlayLayerProvider layer={props.overlayLayer}>
<View <View
@@ -1309,6 +1325,7 @@ export function Combobox({
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch"); const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select"); const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const active = useRetainedPanelActive();
const floatingLayer = useOverlayLayer("floating"); const floatingLayer = useOverlayLayer("floating");
const safeAreaInsets = useSafeAreaInsets(); const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground; const titleColor = theme.colors.foreground;
@@ -1348,6 +1365,10 @@ export function Combobox({
bumpSearchResetKey(); bumpSearchResetKey();
}, [setOpen, setSearchQueryWithCallback]); }, [setOpen, setSearchQueryWithCallback]);
useEffect(() => {
if (!active && isOpen) handleClose();
}, [active, isOpen, handleClose]);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey); useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
const collisionPadding = useMemo(computeCollisionPadding, []); const collisionPadding = useMemo(computeCollisionPadding, []);
@@ -1413,16 +1434,6 @@ export function Combobox({
referenceWidth, referenceWidth,
}); });
const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: isOpen,
isEnabled: isMobile,
onClose: handleClose,
});
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const sanitizedSearchValue = searchQuery.trim(); const sanitizedSearchValue = searchQuery.trim();
const showCustomOption = useMemo( const showCustomOption = useMemo(
@@ -1572,10 +1583,8 @@ export function Combobox({
if (isMobile) { if (isMobile) {
return ( return (
<MobileComboboxBody <MobileComboboxBody
bottomSheetRef={bottomSheetRef} isOpen={isOpen}
snapPoints={snapPoints} snapPoints={snapPoints}
handleSheetChange={handleSheetChange}
handleSheetDismiss={handleSheetDismiss}
handleIndicatorStyle={handleIndicatorStyle} handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor} titleColor={titleColor}
title={resolvedTitle} title={resolvedTitle}
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.13.1", "version": "0.13.0",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"bin": { "bin": {
"paseo": "bin/paseo" "paseo": "bin/paseo"
@@ -28,9 +28,9 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.1", "@getpaseo/server": "0.13.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.13.1", "version": "0.13.0",
"description": "Paseo client SDK package", "description": "Paseo client SDK package",
"files": [ "files": [
"dist", "dist",
@@ -47,8 +47,8 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.1", "@getpaseo/relay": "0.13.0",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -0,0 +1,55 @@
import { execFile, spawnSync } from "node:child_process";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const run = promisify(execFile);
const hasSsh = !spawnSync("ssh", ["-V"]).error;
test.skipIf(!hasSsh)(
"an SSH peer abort during a pending write closes the tunnel without crashing its caller",
async () => {
// Isolate the caller so an unhandled stream error fails this assertion instead
// of crashing the test worker. Both the TCP peer and OpenSSH are real.
const { stdout } = await run(
process.execPath,
[
"--import",
"tsx",
"--input-type=module",
"-e",
`
import { createServer, createConnection } from 'node:net';
import { once } from 'node:events';
import { createSshTunnel } from ${JSON.stringify(new URL("./ssh-tunnel.ts", import.meta.url).href)};
const remote = createServer(socket => socket.destroy());
remote.listen(0, '127.0.0.1');
await once(remote, 'listening');
const tunnel = await createSshTunnel({
host: '127.0.0.1', sshPort: remote.address().port, daemonPort: 6767,
});
const [host, port] = tunnel.endpoint.split(':');
const client = createConnection({ host, port: Number(port) });
client.on('error', () => undefined);
try {
const closed = new Promise(resolve => client.once('close', resolve));
await once(client, 'connect');
client.write(Buffer.alloc(8 * 1024 * 1024, 65));
await closed;
console.log(JSON.stringify({ closed: client.destroyed, failure: tunnel.failureDetail() }));
} finally {
client.destroy();
tunnel.close();
await new Promise(resolve => remote.close(resolve));
}
`,
],
{ timeout: 10_000 },
);
expect(JSON.parse(stdout)).toEqual({
closed: true,
failure: expect.any(String),
});
expect(JSON.parse(stdout).failure).not.toBe("");
},
);
+4 -2
View File
@@ -54,10 +54,12 @@ export function createSshTunnel(target: SshTransportTarget): Promise<SshTunnel>
child.stderr.on("data", (chunk: Buffer | string) => { child.stderr.on("data", (chunk: Buffer | string) => {
stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT); stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
}); });
child.on("error", (error) => { const fail = (error: Error) => {
failure = error.message; failure = error.message;
acceptedSocket.destroy(error); acceptedSocket.destroy(error);
}); };
child.on("error", fail);
child.stdin.on("error", fail);
child.on("exit", (code, signal) => { child.on("exit", (code, signal) => {
if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal); if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal);
acceptedSocket.destroy(failure ? new Error(failure) : undefined); acceptedSocket.destroy(failure ? new Error(failure) : undefined);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.13.1", "version": "0.13.0",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh", "homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.13.1", "version": "0.13.0",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"keywords": [ "keywords": [
"ExpoTwoWayAudio", "ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.13.1", "version": "0.13.0",
"files": [ "files": [
"dist", "dist",
"!dist/**/*.map", "!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.13.1", "version": "0.13.0",
"description": "Paseo plugin SDK package", "description": "Paseo plugin SDK package",
"files": [ "files": [
"dist", "dist",
@@ -72,8 +72,8 @@
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.0",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.2.3", "react": "19.2.3",
@@ -83,8 +83,8 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.0",
"react": ">=19.1.0 <20", "react": ">=19.1.0 <20",
"react-native": ">=0.81.5", "react-native": ">=0.81.5",
"zod": "^4.4.3" "zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.13.1", "version": "0.13.0",
"description": "Paseo shared protocol schemas and wire types", "description": "Paseo shared protocol schemas and wire types",
"files": [ "files": [
"dist", "dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.13.1", "version": "0.13.0",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"files": [ "files": [
"dist", "dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.13.1", "version": "0.13.0",
"description": "Paseo backend server", "description": "Paseo backend server",
"files": [ "files": [
"dist/server", "dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.1", "@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.1", "@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.1", "@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.1", "@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.1", "@getpaseo/relay": "0.13.0",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
@@ -1,4 +1,4 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import pino from "pino"; import pino from "pino";
@@ -7,6 +7,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js"; import { claudeProjectDirSync } from "./project-dir.js";
import { withClaudeReplayHistory } from "./transcript-history.js"; import { withClaudeReplayHistory } from "./transcript-history.js";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import type { ClaudeQueryFactory } from "./query.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
/** /**
* A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline. * A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline.
@@ -29,19 +32,18 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string; let configDir: string;
let records: LogRecord[]; let records: LogRecord[];
async function resume(): Promise<void> { async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }), logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
queryFactory: vi.fn(() => { queryFactory:
throw new Error("history load must not start a query"); queryFactory ??
}), (() => {
throw new Error("history load must not start a query");
}),
resolveBinary: async () => process.execPath,
resolveVersion: async () => "2.1.220", resolveVersion: async () => "2.1.220",
}); });
const session = await client.resumeSession( return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
{ provider: "claude", sessionId: SESSION_ID },
{ cwd },
);
await session.close();
} }
function transcriptPath(): string { function transcriptPath(): string {
@@ -58,12 +60,218 @@ describe("ClaudeAgentSession persisted history load", () => {
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs(); vi.unstubAllEnvs();
rmSync(tempRoot, { recursive: true, force: true }); rmSync(tempRoot, { recursive: true, force: true });
}); });
test.each(["file_path", "files"])(
"replays %s without probing targets and preserves live existence checks",
async (field) => {
const existingPath = path.join(cwd, "existing.txt");
const missingPath = path.join(cwd, "missing.txt");
writeFileSync(existingPath, "existing");
const targets = [existingPath, missingPath];
const inputs = targets.map((target) => ({
[field]: field === "files" ? [target] : target,
}));
const toolBlocks = inputs.map((input, index) => ({
type: "tool_use",
id: `change-${index}`,
name: "FileChange",
input,
}));
const resultBlocks = toolBlocks.map((block) => ({
type: "tool_result",
tool_use_id: block.id,
content: "done",
}));
const transcript = [
{ type: "user", message: { content: "Change files" } },
{ type: "assistant", message: { content: toolBlocks } },
{ type: "user", message: { content: resultBlocks } },
{ type: "assistant", message: { content: "Finished" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), transcript.map((entry) => JSON.stringify(entry)).join("\n"));
// Observe the real filesystem calls; neither existing nor missing targets are mocked.
const exists = vi.spyOn(fs, "existsSync");
const liveMessages = [
{ type: "assistant", message: { content: toolBlocks } },
{ type: "user", message: { content: resultBlocks } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
const queryFactory = vi.fn();
queryFactory.mockReturnValue({
async next() {
const value = liveMessages.shift();
return value ? { done: false, value } : { done: true, value: undefined };
},
close() {},
async return() {
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
});
const session = await resume(queryFactory);
try {
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) history.push(event);
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual(
[],
);
const running = inputs.map((input, index) => ({
type: "tool_call",
callId: `change-${index}`,
name: "FileChange",
status: "running",
error: null,
detail: { type: "unknown", input, output: null },
}));
const completed = inputs.map((input, index) => ({
type: "tool_call",
callId: `change-${index}`,
name: "FileChange",
status: "completed",
error: null,
detail: {
type: "unknown",
input,
output: {
output: "done",
files: [{ path: index === 0 ? "existing.txt" : "missing.txt", kind: "update" }],
},
},
}));
expect(history).toEqual(
[
{ type: "user_message", text: "Change files" },
...running,
...completed,
{ type: "assistant_message", text: "Finished" },
].map((item) => ({ type: "timeline", provider: "claude", timestamp: undefined, item })),
);
exists.mockClear();
const live = await collectSessionTurnEvents(session, "Change files again");
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual([
[existingPath],
[missingPath],
]);
const liveCompleted = live.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed"
? [event.item]
: [],
);
const missing = completed[1];
expect(liveCompleted).toEqual([
completed[0],
{
...missing,
detail: {
...missing.detail,
output: { output: "done", files: [{ path: "missing.txt", kind: "add" }] },
},
},
]);
} finally {
await session.close();
}
},
);
test.each(["parent", "subagent"])(
"replays %s Write and Edit calls without probing their targets",
async (scope) => {
const filePath = path.join(cwd, "unavailable", "file.txt");
const content = ["Write", "Edit"].map((name) => ({
type: "tool_use",
id: name,
name,
input: { file_path: filePath },
}));
const entry = { type: "assistant", message: { content } };
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
if (scope === "subagent") {
const directory = path.join(path.dirname(transcriptPath()), SESSION_ID, "subagents");
mkdirSync(directory, { recursive: true });
writeFileSync(
path.join(directory, "agent-child.meta.json"),
JSON.stringify({ toolUseId: "task" }),
);
writeFileSync(
path.join(directory, "agent-child.jsonl"),
JSON.stringify({
...entry,
isSidechain: true,
agentId: "child",
}),
);
writeFileSync(
transcriptPath(),
JSON.stringify({
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "task",
name: "Task",
input: { description: "Change files" },
},
],
},
}),
);
} else {
writeFileSync(transcriptPath(), JSON.stringify(entry));
}
const exists = vi.spyOn(fs, "existsSync");
const session = await resume();
try {
const items = [];
for await (const event of session.streamHistory()) {
if (scope === "parent" && event.type === "timeline") items.push(event.item);
if (event.type === "provider_subagent" && event.event.type === "timeline") {
items.push(event.event.item);
}
}
expect(exists.mock.calls.filter(([target]) => target === filePath)).toEqual([]);
expect(items).toEqual([
{
type: "tool_call",
callId: "Write",
name: "Write",
status: "running",
error: null,
detail: { type: "write", filePath },
},
{
type: "tool_call",
callId: "Edit",
name: "Edit",
status: "running",
error: null,
detail: { type: "edit", filePath },
},
]);
} finally {
await session.close();
}
},
);
test("logs the resolved transcript path when the transcript does not exist", async () => { test("logs the resolved transcript path when the transcript does not exist", async () => {
await resume(); await (await resume()).close();
expect(records).toContainEqual( expect(records).toContainEqual(
expect.objectContaining({ expect.objectContaining({
@@ -79,7 +287,7 @@ describe("ClaudeAgentSession persisted history load", () => {
// A directory at the transcript path exists but cannot be read as a file. // A directory at the transcript path exists but cannot be read as a file.
mkdirSync(transcriptPath(), { recursive: true }); mkdirSync(transcriptPath(), { recursive: true });
await resume(); await (await resume()).close();
expect(records).toContainEqual( expect(records).toContainEqual(
expect.objectContaining({ expect.objectContaining({
@@ -148,7 +356,7 @@ describe("ClaudeAgentSession persisted history load", () => {
chmodSync(path.join(configDir, "projects"), 0o000); chmodSync(path.join(configDir, "projects"), 0o000);
try { try {
await resume(); await (await resume()).close();
} finally { } finally {
chmodSync(path.join(configDir, "projects"), 0o755); chmodSync(path.join(configDir, "projects"), 0o755);
} }
@@ -2326,6 +2326,7 @@ class ClaudeAgentSession implements AgentSession {
{ type: "provider_subagent" } { type: "provider_subagent" }
>[] = []; >[] = [];
private historyPending = false; private historyPending = false;
private replayingHistory = false;
private turnState: TurnState = "idle"; private turnState: TurnState = "idle";
private nextTurnOrdinal = 1; private nextTurnOrdinal = 1;
private cancelCurrentTurn: (() => void) | null = null; private cancelCurrentTurn: (() => void) | null = null;
@@ -5253,6 +5254,7 @@ class ClaudeAgentSession implements AgentSession {
private loadPersistedHistory(sessionId: string): void { private loadPersistedHistory(sessionId: string): void {
let historyPath: string | null = null; let historyPath: string | null = null;
this.replayingHistory = true;
try { try {
this.taskState.reset(); this.taskState.reset();
historyPath = this.resolveHistoryPath(sessionId); historyPath = this.resolveHistoryPath(sessionId);
@@ -5272,6 +5274,8 @@ class ClaudeAgentSession implements AgentSession {
{ err: error, sessionId, historyPath }, { err: error, sessionId, historyPath },
"Failed to load Claude history from transcript", "Failed to load Claude history from transcript",
); );
} finally {
this.replayingHistory = false;
} }
} }
@@ -6039,6 +6043,11 @@ class ClaudeAgentSession implements AgentSession {
} }
private detectFileKind(filePath: string): string { private detectFileKind(filePath: string): string {
// A historical target may be on an unreachable mount; its current state says nothing
// about whether the past operation created or updated it.
if (this.replayingHistory) {
return "update";
}
try { try {
return fs.existsSync(filePath) ? "update" : "add"; return fs.existsSync(filePath) ? "update" : "add";
} catch { } catch {
@@ -0,0 +1,80 @@
import type { SessionInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import { SessionChildren } from "./children.js";
function setup() {
const harness = new V2Harness();
const child: SessionInfo = {
...harness.info,
id: "child",
parentID: "session",
title: "Check the worker model",
agent: "worker",
model: { providerID: "test", id: "worker-model", variant: "xhigh" },
tokens: { input: 10000, output: 3000, reasoning: 2000, cache: { read: 3000, write: 800 } },
};
harness.api.session.list = async (input) => ({
data: input?.parentID === "session" ? [child] : [],
cursor: {},
});
harness.api.session.get = async () => child;
const events: AgentStreamEvent[] = [];
const children = new SessionChildren({
client: () => harness.api,
id: "session",
emit: (event) => events.push(event),
reconcilePermissions: async () => {},
});
const upserts = () =>
events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
return { children, child, upserts };
}
describe("OpenCode v2 child presentation", () => {
test("restores agent, model, variant and all token categories in the child subtitle", async () => {
const { children, upserts } = setup();
await children.reconcile("session");
expect(upserts()).toEqual([
expect.objectContaining({
id: "child",
title: "Check the worker model",
parentSubagentId: null,
status: "completed",
subtitle: "worker · worker-model · Extra High · 18.8k tokens",
}),
]);
await children.reconcile("session");
expect(upserts()).toHaveLength(1);
});
test("publishes changed child facts on live events even when its timestamp is unchanged", async () => {
const { children, child, upserts } = setup();
await children.observe({ id: "created", created: 1, type: "session.created", data: child });
child.model = { providerID: "test", id: "next-model", variant: "low" };
child.tokens.output += 1200;
await children.observe({
id: "finished",
created: 2,
type: "session.execution.succeeded",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child" },
});
expect(upserts().map((event) => event.subtitle)).toEqual([
"worker · worker-model · Extra High · 18.8k tokens",
"worker · next-model · Low · 20k tokens",
]);
});
test("keeps available agent information before a child has a model or token usage", async () => {
const { children, child, upserts } = setup();
delete child.model;
child.tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
await children.reconcile("session");
expect(upserts()[0]).toMatchObject({ title: "Check the worker model", subtitle: "worker" });
});
});
@@ -7,6 +7,7 @@ import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js"; import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js";
import { messages } from "./history.js"; import { messages } from "./history.js";
import { buildOpenCodeSubagentSubtitle } from "../subagent-presentation.js";
import type { V2Api } from "./api.js"; import type { V2Api } from "./api.js";
interface ChildrenOptions { interface ChildrenOptions {
@@ -65,11 +66,23 @@ export class SessionChildren {
if (active[info.id]) status = "running"; if (active[info.id]) status = "running";
else if (info.outcome === "failed") status = "failed"; else if (info.outcome === "failed") status = "failed";
else if (info.outcome === "interrupted") status = "canceled"; else if (info.outcome === "interrupted") status = "canceled";
const subtitle = buildOpenCodeSubagentSubtitle({
agentName: info.agent,
modelId: info.model?.id,
variant: info.model?.variant,
totalTokens:
info.tokens.input +
info.tokens.output +
info.tokens.reasoning +
info.tokens.cache.read +
info.tokens.cache.write,
});
const presentation: ProviderSubagentInputEvent = { const presentation: ProviderSubagentInputEvent = {
type: "upsert", type: "upsert",
id: info.id, id: info.id,
parentSubagentId: info.parentID === this.options.id ? null : info.parentID, parentSubagentId: info.parentID === this.options.id ? null : info.parentID,
title: info.title ?? null, title: info.title ?? null,
...(subtitle ? { subtitle } : {}),
status, status,
cwd: info.location.directory, cwd: info.location.directory,
timestamp: new Date(info.time.updated).toISOString(), timestamp: new Date(info.time.updated).toISOString(),
@@ -16,7 +16,6 @@ interface FakeSpawnBehavior {
interface FakeSpawnController { interface FakeSpawnController {
activeCount: number; activeCount: number;
nextPid: number;
peakActiveCount: number; peakActiveCount: number;
processes: FakeChildProcess[]; processes: FakeChildProcess[];
queue: FakeSpawnBehavior[]; queue: FakeSpawnBehavior[];
@@ -25,7 +24,6 @@ interface FakeSpawnController {
const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
activeCount: 0, activeCount: 0,
nextPid: 1000,
peakActiveCount: 0, peakActiveCount: 0,
processes: [], processes: [],
queue: [], queue: [],
@@ -35,7 +33,6 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
} }
this.activeCount = 0; this.activeCount = 0;
this.nextPid = 1000;
this.peakActiveCount = 0; this.peakActiveCount = 0;
this.processes = []; this.processes = [];
this.queue = []; this.queue = [];
@@ -43,7 +40,8 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
})); }));
class FakeChildProcess extends EventEmitter { class FakeChildProcess extends EventEmitter {
public readonly pid: number; // This is not an OS process; tree cleanup must use the direct-child fallback.
public readonly pid = undefined;
public readonly stderr = new EventEmitter(); public readonly stderr = new EventEmitter();
public readonly stdout = new EventEmitter(); public readonly stdout = new EventEmitter();
public killed = false; public killed = false;
@@ -57,8 +55,6 @@ class FakeChildProcess extends EventEmitter {
public constructor(behavior: FakeSpawnBehavior) { public constructor(behavior: FakeSpawnBehavior) {
super(); super();
this.behavior = behavior; this.behavior = behavior;
this.pid = fakeSpawnController.nextPid;
fakeSpawnController.nextPid += 1;
fakeSpawnController.activeCount += 1; fakeSpawnController.activeCount += 1;
fakeSpawnController.peakActiveCount = Math.max( fakeSpawnController.peakActiveCount = Math.max(
+2 -1
View File
@@ -13,6 +13,7 @@ import {
submitGitCommandTrace, submitGitCommandTrace,
} from "./git-command-trace.js"; } from "./git-command-trace.js";
import { spawnProcess } from "./spawn.js"; import { spawnProcess } from "./spawn.js";
import { signalProcessTree } from "./tree-kill.js";
import { import {
GitProcessScheduler, GitProcessScheduler,
type GitProcessPriority, type GitProcessPriority,
@@ -422,7 +423,7 @@ function executeGitCommand<Output>(
timer = setTimeout(() => { timer = setTimeout(() => {
timeoutError = new Error(`Git command timed out after ${timeout}ms: ${command}`); timeoutError = new Error(`Git command timed out after ${timeout}ms: ${command}`);
child.kill("SIGKILL"); void signalProcessTree(child, "SIGKILL");
settle(() => reject(timeoutError)); settle(() => reject(timeoutError));
if (processExit) { if (processExit) {
settleTimeoutTrace(processExit.exitCode, processExit.signal); settleTimeoutTrace(processExit.exitCode, processExit.signal);
@@ -1,12 +1,17 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { createServer, type Socket } from "node:net";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { runGitCommand } from "./run-git-command.js"; import { runGitCommand } from "./run-git-command.js";
const tempDirs: string[] = []; const tempDirs: string[] = [];
function allowConnectionReset(error: NodeJS.ErrnoException): void {
if (error.code !== "ECONNRESET") throw error;
}
function makeTempRepo(): string { function makeTempRepo(): string {
const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-")); const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-"));
tempDirs.push(repo); tempDirs.push(repo);
@@ -20,6 +25,49 @@ afterEach(() => {
}); });
describe("runGitCommand shell behavior", () => { describe("runGitCommand shell behavior", () => {
it.each(["http", "ssh"])(
"closes a stalled %s fetch connection on timeout and admits the next command",
async (protocol) => {
const repo = makeTempRepo();
await runGitCommand(["init"], { cwd: repo });
const sockets: Socket[] = [];
const server = createServer((socket) => {
sockets.push(socket);
// Force-killing a Windows peer resets the connection instead of sending EOF.
socket.on("error", allowConnectionReset);
// Consume the request but never answer, keeping the real transport helper busy.
socket.resume();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing TCP address");
const remote = `${protocol}://127.0.0.1:${address.port}/repo.git`;
try {
await expect(
runGitCommand(["fetch", remote, "main"], {
cwd: repo,
timeout: 2000,
envOverlay: {
NO_PROXY: "127.0.0.1",
no_proxy: "127.0.0.1",
GIT_SSH_COMMAND: "ssh -F none -o BatchMode=yes",
},
}),
).rejects.toThrow("Git command timed out after 2000ms");
expect(sockets).toHaveLength(1);
await expect.poll(() => sockets[0]?.destroyed, { timeout: 3000 }).toBe(true);
await expect(runGitCommand(["--version"], { cwd: repo })).resolves.toMatchObject({
exitCode: 0,
truncated: false,
});
} finally {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
},
);
it("passes git arguments directly instead of through the platform shell", async () => { it("passes git arguments directly instead of through the platform shell", async () => {
const repo = makeTempRepo(); const repo = makeTempRepo();
const literalName = "%PASEO_GIT_SHELL_SENTINEL%"; const literalName = "%PASEO_GIT_SHELL_SENTINEL%";
+9 -1
View File
@@ -55,10 +55,18 @@ function createLegacyWorktreeForTest(
}); });
} }
function allowConnectionReset(error: NodeJS.ErrnoException): void {
if (error.code !== "ECONNRESET") throw error;
}
// A remote that accepts connections and never answers, like a VPN-only host while off the VPN. // A remote that accepts connections and never answers, like a VPN-only host while off the VPN.
async function startSilentRemote(): Promise<{ url: string; close: () => Promise<void> }> { async function startSilentRemote(): Promise<{ url: string; close: () => Promise<void> }> {
const connections = new Set<Socket>(); const connections = new Set<Socket>();
const server = createServer((socket) => connections.add(socket)); const server = createServer((socket) => {
connections.add(socket);
// Timeout cleanup force-kills the peer, which can reset its connection on Windows.
socket.on("error", allowConnectionReset);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo; const { port } = server.address() as AddressInfo;
return { return {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.13.1", "version": "0.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/builtin-plugins", "name": "@getpaseo/builtin-plugins",
"version": "0.13.1", "version": "0.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {