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
32 changed files with 154 additions and 648 deletions
+6
View File
@@ -1,5 +1,11 @@
# 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
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
折叠屏手机布局、可将工作委派给其他机器的 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",
"version": "0.13.0",
"version": "0.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.13.0",
"version": "0.13.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -35695,7 +35695,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37961,12 +37961,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/server": "0.13.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38213,10 +38213,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -38228,7 +38228,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.13.0",
"version": "0.13.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38472,7 +38472,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.0",
"version": "0.13.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -42124,7 +42124,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -42153,15 +42153,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -42171,8 +42171,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -42194,7 +42194,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42221,7 +42221,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -42442,16 +42442,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/client": "0.13.1",
"@getpaseo/highlight": "0.13.1",
"@getpaseo/plugin": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -43372,7 +43372,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -44982,7 +44982,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.13.0",
"version": "0.13.1",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.0",
"version": "0.13.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -43,8 +43,6 @@ import {
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
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(
page: import("@playwright/test").Page,
@@ -55,74 +53,6 @@ async function openWorkspace(
}
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 }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -217,42 +147,6 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
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 ({
page,
}) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.0",
"version": "0.13.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -2,6 +2,7 @@
* @vitest-environment jsdom
*/
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 type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
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", () => {
const items = entries([
"running",
@@ -99,6 +99,7 @@ import {
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
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 {
SidebarWorkspaceRowFrame,
@@ -1593,11 +1594,7 @@ function ProjectBlock({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const visibilityPolicy = useMemo(
() => ({
kind: "workspaces" as const,
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
}),
() => createWorkspaceVisibilityPolicy<SidebarWorkspacePlacement>(workspaceEntriesByKey),
[workspaceEntriesByKey],
);
const {
@@ -4,6 +4,18 @@ export type SidebarGroupVisibilityPolicy<T> =
| { kind: "pinned" }
| { 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>(
items: readonly T[],
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -911,9 +910,13 @@ function resolveInitialActiveIndex(
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
}
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
interface MobileBodyProps {
isOpen: boolean;
bottomSheetRef: BottomSheetVisibility["sheetRef"];
snapPoints: string[];
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
handleIndicatorStyle: { backgroundColor: string };
titleColor: string;
title: string;
@@ -941,22 +944,7 @@ interface MobileBodyProps {
safeAreaBottom: number;
}
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,
});
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
const comboboxTitleStyle = useMemo(
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
@@ -991,13 +979,13 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
return (
<IsolatedBottomSheetModal
ref={bottomSheetRef}
ref={props.bottomSheetRef}
contextBridge={null}
snapPoints={props.snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
onChange={props.handleSheetChange}
onDismiss={props.handleSheetDismiss}
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={ComboboxSheetBackground}
@@ -1180,8 +1168,7 @@ function DesktopComboboxOptionsBody(props: {
);
}
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
const active = useRetainedPanelActive();
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
const handleDesktopKey = props.handleDesktopKey;
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
@@ -1203,9 +1190,6 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
[props.refs, setWebOverlayScope],
);
// Remove the portal before a retained chat can freeze its closing update.
if (!active) return null;
const overlay = (
<OverlayLayerProvider layer={props.overlayLayer}>
<View
@@ -1325,7 +1309,6 @@ export function Combobox({
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
const active = useRetainedPanelActive();
const floatingLayer = useOverlayLayer("floating");
const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground;
@@ -1365,10 +1348,6 @@ export function Combobox({
bumpSearchResetKey();
}, [setOpen, setSearchQueryWithCallback]);
useEffect(() => {
if (!active && isOpen) handleClose();
}, [active, isOpen, handleClose]);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
const collisionPadding = useMemo(computeCollisionPadding, []);
@@ -1434,6 +1413,16 @@ export function Combobox({
referenceWidth,
});
const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: isOpen,
isEnabled: isMobile,
onClose: handleClose,
});
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption = useMemo(
@@ -1583,8 +1572,10 @@ export function Combobox({
if (isMobile) {
return (
<MobileComboboxBody
isOpen={isOpen}
bottomSheetRef={bottomSheetRef}
snapPoints={snapPoints}
handleSheetChange={handleSheetChange}
handleSheetDismiss={handleSheetDismiss}
handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor}
title={resolvedTitle}
@@ -1,91 +0,0 @@
import React from "react";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { TerminalViewportState } from "./headless-terminal-state";
import { TerminalGridView } from "./terminal-grid-view.native";
const fixtureStyle = { width: 1000 };
const output = "#".repeat(49);
const state: TerminalViewportState = {
rows: 1,
cols: 49,
firstRow: 0,
oldestRow: 0,
newestRow: 0,
grid: [[...output].map((char) => ({ char }))],
cursor: { row: 0, col: 48, hidden: true },
};
const styledRuns = ["a", "bc", "defg", "hijkl"];
const styledState: TerminalViewportState = {
...state,
cols: 12,
grid: [
styledRuns.flatMap((text, index) =>
[...text].map((char) => ({ char, fg: index + 1, fgMode: 1 })),
),
],
};
const measuredCellWidths: number[] = [];
function onCellMetricsChange(metrics: { cellWidth: number }) {
measuredCellWidths.push(metrics.cellWidth);
}
afterEach(() => {
cleanup();
measuredCellWidths.length = 0;
});
describe("native terminal full-width text", () => {
it.each([9, 11, 12, 14, 18])(
"keeps short colored runs complete despite native layout rounding at %s px",
async (fontSize) => {
const view = render(
<TerminalGridView state={styledState} fontSize={fontSize} style={fixtureStyle} />,
);
for (const runText of styledRuns) {
const text = view.getByText(runText);
const run = text.parentElement!;
await expect.poll(() => run.clientWidth).toBeGreaterThan(0);
// Native one-line layout rounds independently of the cell box. Leave text
// layout at least one physical pixel of slack, while the cell box clips paint.
await expect
.poll(() => text.getBoundingClientRect().width - run.getBoundingClientRect().width)
.toBeGreaterThanOrEqual(1 / window.devicePixelRatio);
expect(getComputedStyle(run).overflowX).toBe("hidden");
const range = document.createRange();
range.selectNodeContents(text);
expect(range.getBoundingClientRect().width).toBeLessThanOrEqual(
text.getBoundingClientRect().width,
);
}
expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe(
styledRuns.join(""),
);
},
);
it.each([9, 11, 12, 14, 18])(
"fits every character in a full-width ASCII row at %s px",
async (fontSize) => {
const view = render(
<TerminalGridView
state={state}
style={fixtureStyle}
fontSize={fontSize}
onCellMetricsChange={onCellMetricsChange}
/>,
);
await expect.poll(() => measuredCellWidths.at(-1) ?? 0).toBeGreaterThan(0);
const text = view.getByText(output);
await expect.poll(() => text.clientWidth).toBeGreaterThan(0);
// The native Text adapter truncates overflowing one-line content. Compare the actual
// font layout with the available width, rather than asserting a particular CSS policy.
await expect.poll(() => text.scrollWidth - text.clientWidth).toBeLessThanOrEqual(1);
const run = text.parentElement!;
expect(run.getBoundingClientRect().width).toBeCloseTo(49 * measuredCellWidths.at(-1)!, 1);
expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe(output);
},
);
});
@@ -44,7 +44,6 @@ const INITIAL_CELL_HEIGHT_RATIO = 1.35;
interface CellMetrics {
cellWidth: number;
cellHeight: number;
letterSpacing: number;
}
interface TerminalGridViewport {
@@ -58,7 +57,6 @@ interface TerminalGridRowProps {
cellHeight: number;
fontFamily?: string;
fontSize: number;
letterSpacing: number;
styleEpoch: string;
}
@@ -83,7 +81,6 @@ function estimateCellMetrics(fontSize: number): CellMetrics {
return {
cellWidth: snapPixel(fontSize * INITIAL_CELL_WIDTH_RATIO),
cellHeight: snapPixel(fontSize * INITIAL_CELL_HEIGHT_RATIO),
letterSpacing: 0,
};
}
@@ -115,15 +112,8 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
[cellHeight, cellWidth, run.cellCount, run.style.backgroundColor],
);
const runTextStyle = useMemo<StyleProp<TextStyle>>(
() => [
textStyle,
run.style,
styles.runText,
// Native text layout rounds independently of the cell box. One extra cell
// prevents rounding from truncating a run that otherwise fits its cells.
{ minWidth: (run.cellCount + 1) * cellWidth },
],
[cellWidth, run.cellCount, run.style, textStyle],
() => [textStyle, run.style],
[run.style, textStyle],
);
return (
@@ -131,7 +121,7 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid
{run.renderKind === "custom-glyph" ? (
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
) : (
<Text numberOfLines={1} ellipsizeMode="clip" style={runTextStyle}>
<Text numberOfLines={1} style={runTextStyle}>
{run.text}
</Text>
)}
@@ -245,7 +235,6 @@ function TerminalGridRow({
cellHeight,
fontFamily,
fontSize,
letterSpacing,
}: TerminalGridRowProps) {
const rowStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.row, { height: cellHeight }],
@@ -259,10 +248,9 @@ function TerminalGridRow({
lineHeight: cellHeight,
fontFamily,
fontSize,
letterSpacing,
},
],
[cellHeight, fontFamily, fontSize, letterSpacing],
[cellHeight, fontFamily, fontSize],
);
const accessibilityLabel = useMemo(
() =>
@@ -300,7 +288,6 @@ const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => {
previous.cellHeight === next.cellHeight &&
previous.fontFamily === next.fontFamily &&
previous.fontSize === next.fontSize &&
previous.letterSpacing === next.letterSpacing &&
previous.styleEpoch === next.styleEpoch
);
});
@@ -394,17 +381,6 @@ export function TerminalGridView({
measureTextLength: MEASURE_TEXT.length,
roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value),
});
// Grid cells are snapped to physical pixels. Match the font's natural advance to that
// cell width so a full row still fits instead of triggering native Text truncation.
const letterSpacing =
nextMetrics.cellWidth - event.nativeEvent.layout.width / MEASURE_TEXT.length;
setMetrics((current) =>
current.cellWidth === nextMetrics.cellWidth &&
current.cellHeight === nextMetrics.cellHeight &&
current.letterSpacing === letterSpacing
? current
: { ...nextMetrics, letterSpacing },
);
const changedMetrics = resolveTerminalGridMetricsMeasurement(
measuredMetricsRef.current,
nextMetrics,
@@ -413,6 +389,7 @@ export function TerminalGridView({
return;
}
measuredMetricsRef.current = changedMetrics;
setMetrics(changedMetrics);
onCellMetricsChange?.(changedMetrics);
},
[onCellMetricsChange],
@@ -442,7 +419,6 @@ export function TerminalGridView({
cellHeight={metrics.cellHeight}
fontFamily={resolvedFontFamily}
fontSize={fontSize}
letterSpacing={metrics.letterSpacing}
styleEpoch={resolver.themeKey}
/>
))}
@@ -465,12 +441,6 @@ const styles = StyleSheet.create({
run: {
overflow: "hidden",
},
runText: {
backgroundColor: "transparent",
position: "absolute",
left: 0,
top: 0,
},
customGlyphRun: {
flex: 1,
position: "relative",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.13.0",
"version": "0.13.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/server": "0.13.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.13.0",
"version": "0.13.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.13.0",
"version": "0.13.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.0",
"version": "0.13.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.13.0",
"version": "0.13.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.13.0",
"version": "0.13.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -72,8 +72,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -83,8 +83,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.13.0",
"version": "0.13.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.13.0",
"version": "0.13.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.13.0",
"version": "0.13.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/client": "0.13.1",
"@getpaseo/highlight": "0.13.1",
"@getpaseo/plugin": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@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 path from "node:path";
import pino from "pino";
@@ -7,9 +7,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.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.
@@ -32,18 +29,19 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string;
let records: LogRecord[];
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
async function resume(): Promise<void> {
const client = new ClaudeAgentClient({
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
queryFactory:
queryFactory ??
(() => {
throw new Error("history load must not start a query");
}),
resolveBinary: async () => process.execPath,
queryFactory: vi.fn(() => {
throw new Error("history load must not start a query");
}),
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 {
@@ -60,218 +58,12 @@ describe("ClaudeAgentSession persisted history load", () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
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 () => {
await (await resume()).close();
await resume();
expect(records).toContainEqual(
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.
mkdirSync(transcriptPath(), { recursive: true });
await (await resume()).close();
await resume();
expect(records).toContainEqual(
expect.objectContaining({
@@ -356,7 +148,7 @@ describe("ClaudeAgentSession persisted history load", () => {
chmodSync(path.join(configDir, "projects"), 0o000);
try {
await (await resume()).close();
await resume();
} finally {
chmodSync(path.join(configDir, "projects"), 0o755);
}
@@ -2326,7 +2326,6 @@ class ClaudeAgentSession implements AgentSession {
{ type: "provider_subagent" }
>[] = [];
private historyPending = false;
private replayingHistory = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
private cancelCurrentTurn: (() => void) | null = null;
@@ -5254,7 +5253,6 @@ class ClaudeAgentSession implements AgentSession {
private loadPersistedHistory(sessionId: string): void {
let historyPath: string | null = null;
this.replayingHistory = true;
try {
this.taskState.reset();
historyPath = this.resolveHistoryPath(sessionId);
@@ -5274,8 +5272,6 @@ class ClaudeAgentSession implements AgentSession {
{ err: error, sessionId, historyPath },
"Failed to load Claude history from transcript",
);
} finally {
this.replayingHistory = false;
}
}
@@ -6043,11 +6039,6 @@ class ClaudeAgentSession implements AgentSession {
}
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 {
return fs.existsSync(filePath) ? "update" : "add";
} 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 { messages } from "./history.js";
import { buildOpenCodeSubagentSubtitle } from "../subagent-presentation.js";
import type { V2Api } from "./api.js";
interface ChildrenOptions {
@@ -66,23 +65,11 @@ export class SessionChildren {
if (active[info.id]) status = "running";
else if (info.outcome === "failed") status = "failed";
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 = {
type: "upsert",
id: info.id,
parentSubagentId: info.parentID === this.options.id ? null : info.parentID,
title: info.title ?? null,
...(subtitle ? { subtitle } : {}),
status,
cwd: info.location.directory,
timestamp: new Date(info.time.updated).toISOString(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.13.0",
"version": "0.13.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.13.0",
"version": "0.13.1",
"private": true,
"type": "module",
"scripts": {