Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 20665a18cd chore(release): cut 0.13.1 2026-10-26 07:06:46 +01:00
Mohamed Boudra 7225c0fe12 docs(release): add 0.13.1 hotfix notes 2026-10-26 07:01:23 +01:00
Budi f9ac58b181 fix(app): keep the sidebar alive when a workspace entry has not hydrated (#7896)
ProjectBlock asserted that every placement in a project has an entry in
workspaceEntriesByKey. A retained sidebar that is inactive (compact layout
with the sidebar closed) or still waiting for sessions keeps an empty map,
and Show background stops the project list from being pruned to hydrated
entries. From the eighth workspace on, selectSidebarGroupVisibility asked
for the status of a missing entry and the app crashed on start.

Count a missing entry as Done. It renders no row, so it should not claim a
slot past the limit.

Closes #7869

Co-authored-by: brucey0x <11062155+brucey0x@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 6bc4b73c42)
2026-10-26 06:38:56 +01:00
45 changed files with 267 additions and 1361 deletions
+6
View File
@@ -1,5 +1,11 @@
# 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,5 +1,11 @@
# 更新日志 # 更新日志
## 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、插件文件面板,以及插件翻译。
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
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-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg= sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
+28 -28
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.13.0", "version": "0.13.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.13.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.0", "@getpaseo/protocol": "0.13.1",
"@getpaseo/server": "0.13.0", "@getpaseo/server": "0.13.1",
"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.0", "version": "0.13.1",
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.13.0", "@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.0", "@getpaseo/relay": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.0", "@getpaseo/protocol": "0.13.1",
"@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.0", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.0", "@getpaseo/protocol": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "@getpaseo/client": "0.13.1",
"@getpaseo/highlight": "0.13.0", "@getpaseo/highlight": "0.13.1",
"@getpaseo/plugin": "0.13.0", "@getpaseo/plugin": "0.13.1",
"@getpaseo/protocol": "0.13.0", "@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.0", "@getpaseo/relay": "0.13.1",
"@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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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,143 +229,6 @@ 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,8 +43,6 @@ 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,
@@ -55,74 +53,6 @@ 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);
@@ -217,42 +147,6 @@ 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").filter({ visible: true }); const button = this.page.getByTestId("new-workspace-import-session");
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").filter({ visible: true }).click(); await this.page.getByTestId("new-workspace-import-session").click();
await this.expectSheetReady(); await this.expectSheetReady();
} }
async openFromWorkspaceHeader() { async openFromWorkspaceHeader() {
@@ -86,29 +86,7 @@ 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) {
const target = this.page.getByTestId(rowTestId(id)); await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
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);
@@ -122,8 +100,7 @@ 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 this.revealSession("fixture-root-20"); await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
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) {
@@ -134,7 +111,6 @@ 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,
@@ -162,7 +138,7 @@ export class ImportSessionFlow {
); );
} }
async close() { async close() {
await this.page.getByRole("button", { name: "Close", exact: true }).last().click(); await this.page.keyboard.press("Escape");
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.0", "version": "0.13.1",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -1,4 +0,0 @@
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;
@@ -1,13 +0,0 @@
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;
@@ -1,141 +0,0 @@
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, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { 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,7 +11,6 @@ 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";
@@ -372,33 +371,7 @@ 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,
@@ -406,11 +379,6 @@ 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;
@@ -418,69 +386,20 @@ 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. return (
const virtualized = ImportSessionList !== null && entries.length > 8; <View style={styles.list}>
const renderSessionRow = useCallback( {entries.map((entry) => (
(entry: FetchRecentProviderSessionEntry) => (
<ImportSessionSheetRow <ImportSessionSheetRow
key={getSessionRowKey(entry)} key={`${entry.providerId}:${entry.providerHandleId}`}
serverId={serverId} serverId={serverId}
entry={entry} entry={entry}
disabled={disabled} disabled={disabled}
importing={importingSessionKey === getSessionRowKey(entry)} importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
folder={resolveFolder(entry)} folder={resolveFolder(entry)}
onImportSession={onImportSession} 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 (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={header}
testID="import-session-sheet"
desktopMaxWidth={560}
desktopHeight={virtualized ? "85%" : undefined}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
scrollable={!virtualized}
contentStyle={virtualized ? styles.listBody : undefined}
>
{ImportSessionList && virtualized ? (
<View style={styles.list}>
<ImportSessionList
data={entries}
keyExtractor={getSessionRowKey}
renderItem={renderItem}
header={virtualHeader}
footer={virtualFooter}
/>
</View> </View>
) : (
<>
{listHeader}
{entries.length > 0 ? (
<View style={styles.smallList}>{entries.map(renderSessionRow)}</View>
) : null}
{listFooter}
</>
)}
</AdaptiveModalSheet>
); );
} }
@@ -807,20 +726,16 @@ 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, return (
isClientReady: Boolean(client), <AdaptiveModalSheet
isSnapshotUnsupported, visible={visible}
hasNoImportableProviders, onClose={onClose}
isLoadingSessions, header={header}
hasRows: visibleEntries.length > 0, testID="import-session-sheet"
importErrored: importMutation.isError, desktopMaxWidth={560}
hasProviderErrors: providerErrorRows.length > 0, snapPoints={IMPORT_SHEET_SNAP_POINTS}
}); >
const listHeader = useMemo(
() =>
showListHeader ? (
<View style={styles.listHeaderContent}>
{showFilter ? ( {showFilter ? (
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}> <View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable <Pressable
@@ -866,34 +781,16 @@ export function ImportSessionSheet({
{providerErrorRows.length > 0 ? ( {providerErrorRows.length > 0 ? (
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} /> <ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
) : null} ) : null}
</View> {visibleEntries.length > 0 ? (
) : null, <SessionRows
[ serverId={serverId}
showListHeader, entries={visibleEntries}
showFilter, disabled={importMutation.isPending}
filterTriggerStyle, importingSessionKey={importingSessionKey}
handleFilterOpen, resolveFolder={resolveFolder}
selectedProvider, onImportSession={handleImportSession}
theme.colors.foregroundMuted, />
SelectedProviderIcon, ) : null}
selectedProviderLabel,
filterComboboxOptions,
handleFilterSelect,
renderFilterOption,
isFilterOpen,
client,
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
visibleEntries.length,
importMutation.isError,
providerErrorRows,
handleRetryProvider,
],
);
const listFooter = useMemo(
() => (
<>
{showLoadMore ? ( {showLoadMore ? (
<View style={styles.footer}> <View style={styles.footer}>
<Button <Button
@@ -907,24 +804,7 @@ export function ImportSessionSheet({
</View> </View>
) : null} ) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null} {showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</> </AdaptiveModalSheet>
),
[showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle],
);
return (
<SessionRows
visible={visible}
onClose={onClose}
header={header}
listHeader={listHeader}
listFooter={listFooter}
serverId={serverId}
entries={visibleEntries}
disabled={importMutation.isPending}
importingSessionKey={importingSessionKey}
resolveFolder={resolveFolder}
onImportSession={handleImportSession}
/>
); );
} }
@@ -973,31 +853,9 @@ 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,35 +249,6 @@ 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,17 +27,11 @@ 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`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`, String.raw`(?:https?://[^\s<>]+|(?<![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,6 +2,7 @@
* @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";
@@ -486,6 +487,20 @@ 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,6 +99,7 @@ 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,
@@ -1593,11 +1594,7 @@ 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,6 +4,18 @@ 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,
+23 -32
View File
@@ -28,7 +28,6 @@ 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";
@@ -911,9 +910,13 @@ function resolveInitialActiveIndex(
return selectedIndex >= 0 ? selectedIndex : fallbackIndex; return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
} }
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
interface MobileBodyProps { interface MobileBodyProps {
isOpen: boolean; bottomSheetRef: BottomSheetVisibility["sheetRef"];
snapPoints: string[]; snapPoints: string[];
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
handleIndicatorStyle: { backgroundColor: string }; handleIndicatorStyle: { backgroundColor: string };
titleColor: string; titleColor: string;
title: string; title: string;
@@ -941,22 +944,7 @@ interface MobileBodyProps {
safeAreaBottom: number; safeAreaBottom: number;
} }
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null { function MobileComboboxBody(props: MobileBodyProps): ReactElement {
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],
@@ -991,13 +979,13 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
return ( return (
<IsolatedBottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={props.bottomSheetRef}
contextBridge={null} contextBridge={null}
snapPoints={props.snapPoints} snapPoints={props.snapPoints}
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
onChange={handleSheetChange} onChange={props.handleSheetChange}
onDismiss={handleSheetDismiss} onDismiss={props.handleSheetDismiss}
backdropOpacity={0.45} backdropOpacity={0.45}
enablePanDownToClose enablePanDownToClose
backgroundComponent={ComboboxSheetBackground} backgroundComponent={ComboboxSheetBackground}
@@ -1180,8 +1168,7 @@ function DesktopComboboxOptionsBody(props: {
); );
} }
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null { function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
const active = useRetainedPanelActive();
const handleDesktopKey = props.handleDesktopKey; const handleDesktopKey = props.handleDesktopKey;
const handleWebOverlayKeyDown = useCallback( const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
@@ -1203,9 +1190,6 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
[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
@@ -1325,7 +1309,6 @@ 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;
@@ -1365,10 +1348,6 @@ 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, []);
@@ -1434,6 +1413,16 @@ 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(
@@ -1583,8 +1572,10 @@ export function Combobox({
if (isMobile) { if (isMobile) {
return ( return (
<MobileComboboxBody <MobileComboboxBody
isOpen={isOpen} bottomSheetRef={bottomSheetRef}
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.0", "version": "0.13.1",
"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.0", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.0", "@getpaseo/protocol": "0.13.1",
"@getpaseo/server": "0.13.0", "@getpaseo/server": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.0", "@getpaseo/relay": "0.13.1",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -1,55 +0,0 @@
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("");
},
);
+2 -4
View File
@@ -54,12 +54,10 @@ 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);
}); });
const fail = (error: Error) => { child.on("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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.0", "@getpaseo/protocol": "0.13.1",
"@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.0", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.0", "@getpaseo/protocol": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"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.0", "@getpaseo/client": "0.13.1",
"@getpaseo/highlight": "0.13.0", "@getpaseo/highlight": "0.13.1",
"@getpaseo/plugin": "0.13.0", "@getpaseo/plugin": "0.13.1",
"@getpaseo/protocol": "0.13.0", "@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.0", "@getpaseo/relay": "0.13.1",
"@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 fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { 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,9 +7,6 @@ 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.
@@ -32,18 +29,19 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string; let configDir: string;
let records: LogRecord[]; let records: LogRecord[];
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> { async function resume(): Promise<void> {
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: queryFactory: vi.fn(() => {
queryFactory ??
(() => {
throw new Error("history load must not start a query"); throw new Error("history load must not start a query");
}), }),
resolveBinary: async () => process.execPath,
resolveVersion: async () => "2.1.220", resolveVersion: async () => "2.1.220",
}); });
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd }); const session = await client.resumeSession(
{ provider: "claude", sessionId: SESSION_ID },
{ cwd },
);
await session.close();
} }
function transcriptPath(): string { function transcriptPath(): string {
@@ -60,218 +58,12 @@ 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 (await resume()).close(); await resume();
expect(records).toContainEqual( expect(records).toContainEqual(
expect.objectContaining({ expect.objectContaining({
@@ -287,7 +79,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 (await resume()).close(); await resume();
expect(records).toContainEqual( expect(records).toContainEqual(
expect.objectContaining({ expect.objectContaining({
@@ -356,7 +148,7 @@ describe("ClaudeAgentSession persisted history load", () => {
chmodSync(path.join(configDir, "projects"), 0o000); chmodSync(path.join(configDir, "projects"), 0o000);
try { try {
await (await resume()).close(); await resume();
} finally { } finally {
chmodSync(path.join(configDir, "projects"), 0o755); chmodSync(path.join(configDir, "projects"), 0o755);
} }
@@ -2326,7 +2326,6 @@ 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;
@@ -5254,7 +5253,6 @@ 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);
@@ -5274,8 +5272,6 @@ 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;
} }
} }
@@ -6043,11 +6039,6 @@ 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 {
@@ -1,80 +0,0 @@
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,7 +7,6 @@ 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 {
@@ -66,23 +65,11 @@ 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,6 +16,7 @@ interface FakeSpawnBehavior {
interface FakeSpawnController { interface FakeSpawnController {
activeCount: number; activeCount: number;
nextPid: number;
peakActiveCount: number; peakActiveCount: number;
processes: FakeChildProcess[]; processes: FakeChildProcess[];
queue: FakeSpawnBehavior[]; queue: FakeSpawnBehavior[];
@@ -24,6 +25,7 @@ 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: [],
@@ -33,6 +35,7 @@ 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 = [];
@@ -40,8 +43,7 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
})); }));
class FakeChildProcess extends EventEmitter { class FakeChildProcess extends EventEmitter {
// This is not an OS process; tree cleanup must use the direct-child fallback. public readonly pid: number;
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;
@@ -55,6 +57,8 @@ 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(
+1 -2
View File
@@ -13,7 +13,6 @@ 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,
@@ -423,7 +422,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}`);
void signalProcessTree(child, "SIGKILL"); child.kill("SIGKILL");
settle(() => reject(timeoutError)); settle(() => reject(timeoutError));
if (processExit) { if (processExit) {
settleTimeoutTrace(processExit.exitCode, processExit.signal); settleTimeoutTrace(processExit.exitCode, processExit.signal);
@@ -1,17 +1,12 @@
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);
@@ -25,49 +20,6 @@ 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%";
+1 -9
View File
@@ -55,18 +55,10 @@ 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) => { const server = createServer((socket) => connections.add(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.0", "version": "0.13.1",
"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.0", "version": "0.13.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {