Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andlikaixuan cd845129e9 test(app): reproduce lost floating panel hosts after overlap
Preserve a failing reproduction with real portal hosts. Removing the newest host must leave an older mounted host measurable.

Co-authored-by: likaixuan <likaixuan@mucang.cn>
2026-11-06 16:00:30 +00:00
49 changed files with 193 additions and 905 deletions
-6
View File
@@ -1,11 +1,5 @@
# Changelog
## 0.15.3 - 2026-11-06
### Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### Added
-6
View File
@@ -1,11 +1,5 @@
# Changelog
## 0.15.3 - 2026-11-06
### 修复
- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### 新增
-1
View File
@@ -34,7 +34,6 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice.
- **A history row re-renders only when its item or layout item identity changes.** Every
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -1,4 +0,0 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.3",
"version": "0.15.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.3",
"version": "0.15.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.3",
"version": "0.15.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/server": "0.15.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.3",
"version": "0.15.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/highlight": "0.15.2",
"@getpaseo/plugin": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.3",
"version": "0.15.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.3",
"version": "0.15.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,200 +0,0 @@
import type { Page } from "@playwright/test";
import { expect, test as baseTest } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import { submitMessage } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving
// within a few milliseconds of each other. These tests hold all three, let the running
// turn paint, then release them together and sample every painted frame.
interface Box {
top: number;
height: number;
}
interface TurnEndFrame {
at: number;
running: boolean;
footer: Box | null;
userRow: Box | null;
replyVisible: boolean;
}
declare global {
interface Window {
__turnEndFrames?: { active: boolean; frames: TurnEndFrame[] };
}
}
const test = baseTest.extend<{ agent: MockAgentWorkspace }>({
agent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-end-",
title: "Turn end",
initialPrompt: "emit 4 tool agent stream updates",
});
await agent.client.waitForFinish(agent.agentId, 15_000);
await provide(agent);
await agent.cleanup();
},
});
async function recordTurnEndFrames(
page: Page,
input: { prompt: string; reply: string | null },
): Promise<void> {
await page.evaluate(({ prompt, reply }) => {
const state = { active: true, frames: [] as TurnEndFrame[] };
const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility());
const boxOf = (element: Element | null | undefined): Box | null => {
if (!element || !isVisible(element)) return null;
const rect = element.getBoundingClientRect();
return rect.height > 0 ? { top: rect.top, height: rect.height } : null;
};
const sample = () => {
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
.map(boxOf)
.filter((box): box is Box => box !== null)
.sort((left, right) => left.top - right.top);
const userRow = Array.from(
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
).find((candidate) => candidate.textContent?.includes(prompt));
const replyRow =
reply === null
? undefined
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
(candidate) => candidate.textContent?.includes(reply),
);
state.frames.push({
at: performance.now(),
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
footer: footers.at(-1) ?? null,
userRow: boxOf(userRow),
replyVisible: boxOf(replyRow) !== null,
});
if (state.active) requestAnimationFrame(() => setTimeout(sample, 0));
};
window.__turnEndFrames = state;
requestAnimationFrame(() => setTimeout(sample, 0));
}, input);
}
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
await expect
.poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0))
.toBeGreaterThanOrEqual(until);
}
async function stopRecording(page: Page): Promise<TurnEndFrame[]> {
return page.evaluate(() => {
const state = window.__turnEndFrames;
if (!state) throw new Error("Turn end frames were not recorded");
state.active = false;
return state.frames;
});
}
async function recordTurnEnd(
page: Page,
agent: MockAgentWorkspace,
input: { prompt: string; reply: string | null },
): Promise<TurnEndFrame[]> {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectAgentIdle(page);
if (input.reply === null) {
gate.setAgentStreamItemSuppressed("assistant_message", true);
} else {
gate.holdNextAgentStreamItem("assistant_message");
}
gate.holdNextAgentStreamEvent("turn_completed");
// The daemon sends more than one idle snapshot; hold the first and drop the rest until release.
gate.holdNextAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", true);
await recordTurnEndFrames(page, input);
await submitMessage(page, input.prompt);
await Promise.all([
input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"),
gate.waitForHeldAgentStreamEvent("turn_completed"),
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
]);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await recordPaintsFor(page, 200);
if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message");
gate.releaseHeldAgentStreamEvent("turn_completed");
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", false);
await expectAgentIdle(page);
await recordPaintsFor(page, 300);
return stopRecording(page);
}
function describeFrames(frames: TurnEndFrame[]): string {
return frames
.map(
(frame, index) =>
`${index}: ${frame.running ? "running " : "complete"} footer=${
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
)
.join("\n");
}
function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void {
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running);
const record = `frames:\n${describeFrames(frames)}`;
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
expect(firstComplete, record).toBeGreaterThan(firstRunning);
const transition = frames.slice(firstRunning);
const runningHeight = transition[0].footer?.height;
expect(
transition.map((frame) => frame.footer?.height ?? null),
`the footer must stay painted at one height\n${record}`,
).toEqual(transition.map(() => runningHeight));
if (input.reply !== null) {
expect(
frames.slice(firstComplete).every((frame) => frame.replyVisible),
`a completed turn must show its reply in the same frame\n${record}`,
).toBe(true);
}
// Tool rows land while the turn runs; only the turn end itself must be one step.
const ending = frames.slice(firstComplete - 1);
const promptMoves = ending
.slice(1)
.filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top);
expect(
promptMoves.length,
`the prompt must move at most once as the turn ends\n${record}`,
).toBeLessThanOrEqual(1);
}
test.describe("Turn end", () => {
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => {
const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => {
const input = { prompt: "emit 2 tool agent stream updates", reply: null };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
});
@@ -5,10 +5,8 @@ import {
holdDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
expectLayoutObservationToCatchPanelShift,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
shiftOutsideCommandCenter,
} from "../support/helpers/command-center-file-search";
import {
scrollCommandCenterAwayFromSelection,
@@ -86,9 +84,7 @@ test("workspace file search stays geometrically stable through delayed loading a
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await shiftOutsideCommandCenter(page);
await expectStableCommandCenterLayout(page);
await expectLayoutObservationToCatchPanelShift(page);
await row.click();
await expectFileTabOpen(page, FILE_PATH);
@@ -1,8 +1,6 @@
import { test } from "../support/fixtures";
import {
closeDiagramFullscreen,
expectInlineDiagramFitsViewport,
toggleDiagramSourceAndVerifyControls,
closeDiagramFullscreenFromOutside,
expectCompletedDiagram,
expectFullscreenDiagram,
@@ -23,36 +21,6 @@ const MERMAID_DIAGRAM = [
"```",
].join("\n");
test("bounds a tall inline diagram", async ({ page }) => {
test.setTimeout(120_000);
const diagram = [
"```mermaid",
"flowchart TD",
...Array.from({ length: 30 }, (_, i) => `N${i} --> N${i + 1}`),
"```",
].join("\n");
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-tall-",
title: "Tall Mermaid",
featureValues: {
mockStreamingAssistantResponse: diagram,
mockStreamingAssistantIntervalMs: 10,
},
});
try {
await openAgentRoute(page, agent);
await requestDiagram(agent);
await waitForDiagramTurnToComplete(agent);
await expectCompletedDiagram(page, ["N0", "N30"]);
await expectInlineDiagramFitsViewport(page);
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["N0", "N30"]);
await closeDiagramFullscreen(page);
} finally {
await agent.cleanup();
}
});
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
test.setTimeout(120_000);
@@ -72,10 +40,6 @@ test("opens a Mermaid diagram fullscreen and closes it again", async ({ page })
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("Source toggles preserve the diagram and exclude hidden controls", async () => {
await toggleDiagramSourceAndVerifyControls(page);
});
await test.step("The toolbar opens the diagram fullscreen", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
@@ -407,6 +407,7 @@ test("opening a turn keeps the received message above it clipped on every frame"
.getByTestId("user-message")
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
await bubble.scrollIntoViewIfNeeded();
await watchReceivedMessagePreview(page, "Sent by Reviewer");
await page
@@ -99,7 +99,6 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
const observedElements = new WeakSet<Element>();
const resizeObserver = new ResizeObserver(record);
const mutationObserver = new MutationObserver(discover);
let performanceObserver: PerformanceObserver | undefined;
function record(): void {
if (!observation.active) return;
@@ -133,7 +132,6 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (!observation.active) {
mutationObserver.disconnect();
resizeObserver.disconnect();
performanceObserver?.disconnect();
return;
}
record();
@@ -143,16 +141,9 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (typeof PerformanceObserver !== "undefined") {
try {
performanceObserver = new PerformanceObserver((list) => {
const panel = document.querySelector(selectors.panel);
const performanceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node && panel?.contains(source.node))) {
observation.layoutShift += shift.value;
}
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
}
});
performanceObserver.observe({ type: "layout-shift", buffered: false });
@@ -183,54 +174,7 @@ export async function expectStableCommandCenterLayout(page: Page): Promise<void>
).toBeLessThanOrEqual(1);
}
}
expect(
observation.layoutShift,
`command center layoutShift: ${JSON.stringify(observation)}`,
).toBeLessThanOrEqual(0.001);
}
/** Exercise an unrelated page shift while the real search panel is being observed. */
export async function shiftOutsideCommandCenter(page: Page): Promise<void> {
await shiftLayoutProbe(page.locator("body"));
}
export async function expectLayoutObservationToCatchPanelShift(page: Page): Promise<void> {
await startCommandCenterLayoutObservation(page);
await shiftLayoutProbe(page.getByTestId("command-center-panel"));
await expect(expectStableCommandCenterLayout(page)).rejects.toThrow("command center layoutShift");
}
async function shiftLayoutProbe(parent: Locator): Promise<void> {
const recordedShift = await parent.evaluate(async (container) => {
const sibling = document.createElement("div");
sibling.textContent = "Layout probe";
sibling.style.cssText =
"position:fixed;left:0;top:0;width:300px;height:100px;background:white;z-index:99999";
container.append(sibling);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const value = await new Promise<number>((resolve) => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node === sibling)) {
observer.disconnect();
resolve(shift.value);
}
}
});
observer.observe({ type: "layout-shift", buffered: false });
sibling.style.left = "400px";
});
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
sibling.remove();
return value;
});
expect(recordedShift, "the browser recorded a substantial probe shift").toBeGreaterThan(0.001);
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
}
export async function expectOneLineTruncatedFileResult(
@@ -148,59 +148,3 @@ export async function expectFullscreenDiagram(
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
}
/** Tall diagrams stay within the screen so their controls remain reachable. */
export async function expectInlineDiagramFitsViewport(page: Page): Promise<void> {
const viewport = page.getByTestId("mermaid-viewport").last();
const box = await viewport.boundingBox();
expect(box, "the inline diagram has measurable layout").not.toBeNull();
const screenHeight = page.viewportSize()!.height;
expect(box!.height, "the inline diagram fits within the screen").toBeLessThan(screenHeight);
const contentHeight = await diagramSvg(page, "mermaid-viewport").evaluate(
(svg) => (svg as SVGSVGElement).viewBox.baseVal.height,
);
expect(box!.height, "a tall diagram is bounded rather than expanding the chat").toBeLessThan(
contentHeight,
);
}
/** Exercise pointer toggles and the controls encountered along that same journey. */
export async function toggleDiagramSourceAndVerifyControls(page: Page): Promise<void> {
const viewport = page.getByTestId("mermaid-viewport").last();
const iframe = await viewport.locator("iframe").elementHandle();
for (let iteration = 0; iteration < 3; iteration++) {
await viewport.hover();
await page.getByRole("button", { name: "View source" }).last().click();
const viewDiagram = page.getByRole("button", { name: "View diagram" }).last();
await viewDiagram.click({ timeout: 10_000 });
await expect(viewDiagram).toHaveCount(0);
await expectFullscreenDiagramClosed(page);
expect(
await viewport.locator("iframe").evaluate((node, original) => node === original, iframe),
"switching views preserves the rendered diagram",
).toBe(true);
}
await viewport.hover();
await page.getByRole("button", { name: "View source" }).last().click();
const hiddenFullscreen = viewport.getByRole("button", {
name: "Fullscreen",
includeHidden: true,
});
await hiddenFullscreen.focus();
await expect(hiddenFullscreen).not.toBeFocused();
await page.getByRole("button", { name: "View diagram" }).last().click();
await viewport.hover();
const fullscreen = viewport.getByRole("button", { name: "Fullscreen", exact: true });
const box = await viewport.boundingBox();
const control = await fullscreen.boundingBox();
expect(box).not.toBeNull();
expect(control).not.toBeNull();
expect(control!.y, "the toolbar starts inside the preview").toBeGreaterThanOrEqual(box!.y);
expect(control!.y + control!.height, "the toolbar fits inside the preview").toBeLessThanOrEqual(
box!.y + box!.height,
);
await viewport.getByRole("button", { name: "Zoom in" }).click();
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeEnabled();
await viewport.getByRole("button", { name: "Reset view" }).click();
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeDisabled();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"main": "index.ts",
"scripts": {
+3 -11
View File
@@ -23,10 +23,6 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
// Every footer variant (running, completed with Copy, completed with only its duration) gets
// this content height, so a turn ending swaps the footer's controls without moving the rows
// above it.
const TURN_FOOTER_CONTENT_HEIGHT = 24;
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
@@ -212,11 +208,7 @@ function CompletedTurnFooter({
function TurnFooterRow({ children }: { children: ReactNode }) {
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
return (
<View style={rowStyle} testID="turn-footer">
{children}
</View>
);
return <View style={rowStyle}>{children}</View>;
}
const stylesheet = StyleSheet.create((theme) => ({
@@ -237,11 +229,11 @@ const stylesheet = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
minHeight: 24,
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
},
turnFooterContent: {
height: TURN_FOOTER_CONTENT_HEIGHT,
height: 24,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
@@ -72,7 +72,7 @@ function MermaidFenceHostImpl({
() => [
getDiagramBoxStyle(textStyle),
containerStyle,
{ height: Math.min(runtimeHeight, MAX_PREVIEW_HEIGHT), minHeight: MIN_DIAGRAM_BOX_HEIGHT },
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
],
[runtimeHeight, textStyle],
);
@@ -111,22 +111,20 @@ function MermaidFenceHostImpl({
) : null}
</View>
) : null}
<div inert={!diagramVisible} style={boundaryStyle}>
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={diagramSize}
style={viewportStyle}
testID="mermaid-viewport"
wheelActivation="modifier"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
</div>
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={diagramSize}
style={viewportStyle}
testID="mermaid-viewport"
wheelActivation="modifier"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
{isFullscreen && visible ? (
<MermaidFullscreenViewer
source={visible.source}
@@ -144,8 +142,6 @@ const MEASURING_SIZE = { width: 240, height: 240 };
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
*/
const MIN_DIAGRAM_BOX_HEIGHT = 56;
const MAX_PREVIEW_HEIGHT = 480;
const boundaryStyle: React.CSSProperties = { display: "contents" };
const sourceContainerStyle: ViewStyle = { position: "relative" };
/**
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createFloatingPanelHosts,
type FloatingPanelHostRect,
type MeasurableFloatingPanelHost,
} from "./floating-panel-hosts";
function createHost(rect: FloatingPanelHostRect): MeasurableFloatingPanelHost {
return {
measureInWindow: (callback) => callback(rect.x, rect.y, rect.width, rect.height),
};
}
const WORKSPACE_RECT: FloatingPanelHostRect = { x: 10, y: 20, width: 800, height: 600 };
const RETAINED_VIEWPORT: FloatingPanelHostRect = { x: 0, y: 0, width: 1200, height: 900 };
describe("floating panel hosts", () => {
it("measures the newest host registered under the name", async () => {
const hosts = createFloatingPanelHosts();
hosts.register("workspace", createHost(WORKSPACE_RECT));
hosts.register("workspace", createHost(RETAINED_VIEWPORT));
expect(await hosts.measure("workspace")).toEqual(RETAINED_VIEWPORT);
});
it("keeps measuring a survivor after the newest host unregisters", async () => {
const hosts = createFloatingPanelHosts();
const unregisterSurvivor = hosts.register("workspace", createHost(WORKSPACE_RECT));
const unregisterNewest = hosts.register("workspace", createHost(RETAINED_VIEWPORT));
unregisterNewest();
expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT);
unregisterSurvivor();
});
it("returns null once every host under the name is gone", async () => {
const hosts = createFloatingPanelHosts();
const unregisterFirst = hosts.register("workspace", createHost(WORKSPACE_RECT));
const unregisterSecond = hosts.register("workspace", createHost(RETAINED_VIEWPORT));
unregisterFirst();
unregisterSecond();
expect(await hosts.measure("workspace")).toBeNull();
});
it("skips hosts that are not laid out", async () => {
const hosts = createFloatingPanelHosts();
hosts.register("workspace", createHost(WORKSPACE_RECT));
hosts.register("workspace", createHost({ x: 0, y: 0, width: 0, height: 0 }));
expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT);
});
it("ignores a repeated unregister of the same host", async () => {
const hosts = createFloatingPanelHosts();
const unregister = hosts.register("workspace", createHost(WORKSPACE_RECT));
unregister();
unregister();
expect(await hosts.measure("workspace")).toBeNull();
});
});
@@ -1,57 +0,0 @@
export interface FloatingPanelHostRect {
x: number;
y: number;
width: number;
height: number;
}
export interface MeasurableFloatingPanelHost {
measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void;
}
export interface FloatingPanelHosts<T extends MeasurableFloatingPanelHost> {
register(name: string, host: T): () => void;
measure(name: string): Promise<FloatingPanelHostRect | null>;
}
function measureHost(host: MeasurableFloatingPanelHost): Promise<FloatingPanelHostRect> {
return new Promise((resolve) => {
host.measureInWindow((x, y, width, height) => resolve({ x, y, width, height }));
});
}
/**
* Hosts are addressed by name, and one name can have several live hosts: a workspace
* screen mounts its own host while retained copies of that screen stay mounted. Track
* every host so unmounting one leaves the others measurable.
*/
export function createFloatingPanelHosts<
T extends MeasurableFloatingPanelHost,
>(): FloatingPanelHosts<T> {
const hostsByName = new Map<string, T[]>();
function register(name: string, host: T): () => void {
const hosts = hostsByName.get(name) ?? [];
hostsByName.set(name, hosts);
hosts.push(host);
return () => {
const index = hosts.indexOf(host);
if (index === -1) return;
hosts.splice(index, 1);
if (hosts.length === 0 && hostsByName.get(name) === hosts) {
hostsByName.delete(name);
}
};
}
async function measure(name: string): Promise<FloatingPanelHostRect | null> {
const hosts = hostsByName.get(name);
if (!hosts || hosts.length === 0) return null;
const rects = await Promise.all(hosts.toReversed().map(measureHost));
return rects.find((rect) => rect.width > 0 && rect.height > 0) ?? rects[0];
}
return { register, measure };
}
@@ -23,15 +23,9 @@ describe("Floating panel host lifetime", () => {
}
try {
await act(async () => root.render(<Content overlap={true} />));
expect(await measureFloatingPanelPortalHost("workspace")).toMatchObject({
width: 800,
height: 600,
});
expect(await measureFloatingPanelPortalHost("workspace")).not.toBeNull();
await act(async () => root.render(<Content overlap={false} />));
expect(await measureFloatingPanelPortalHost("workspace")).toMatchObject({
width: 800,
height: 600,
});
expect(await measureFloatingPanelPortalHost("workspace")).not.toBeNull();
} finally {
await act(async () => root.unmount());
container.remove();
@@ -8,21 +8,32 @@ import {
} from "react";
import { StyleSheet, View } from "react-native";
import { PortalHost } from "@gorhom/portal";
import {
createFloatingPanelHosts,
type FloatingPanelHostRect,
} from "@/components/ui/floating-panel-hosts";
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
const portalHosts = createFloatingPanelHosts<View>();
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export function measureFloatingPanelPortalHost(
name: string,
): Promise<FloatingPanelHostRect | null> {
return portalHosts.measure(name);
const portalHostElements = new Map<string, View>();
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
export function measureFloatingPanelPortalHost(name: string): Promise<Rect | null> {
const element = portalHostElements.get(name);
if (!element) return Promise.resolve(null);
return measureElement(element);
}
export function FloatingPanelPortalHostNameProvider({
@@ -53,7 +64,12 @@ export function FloatingPanelPortalHost({
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
return portalHosts.register(name, host);
portalHostElements.set(name, host);
return () => {
if (portalHostElements.get(name) === host) {
portalHostElements.delete(name);
}
};
}, [name]);
return (
+15 -1
View File
@@ -12,6 +12,7 @@ import {
refreshAgentInitializationTimeout,
} from "@/hooks/use-agent-initialization";
import type { StreamItem } from "@/types/stream";
import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducers";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync";
@@ -359,6 +360,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const onStream = (message: SessionOutboundMessage) => {
if (message.type !== "agent_stream") return;
const { agentId, event, timestamp, seq, epoch } = message.payload;
const parsedTimestamp = new Date(timestamp);
const streamEvent = event;
if (
event.type === "turn_started" ||
event.type === "turn_completed" ||
@@ -367,7 +370,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
) {
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
}
sync.applyStreamEvent(agentId, { event, seq, epoch, timestamp: new Date(timestamp) });
const turnLiveness = deriveAgentStreamTurnLiveness([
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
]);
if (turnLiveness.length > 0) {
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
}
sync.enqueueStreamEvent(agentId, {
event: streamEvent,
seq,
epoch,
timestamp: parsedTimestamp,
});
// NOTE: We don't update lastActivityAt on every stream event to prevent
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
-2
View File
@@ -2852,8 +2852,6 @@ export class HostRuntimeStore {
replica,
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId),
applyTurnLiveness: (agentId, transitions) =>
directory.applyAgentTurnLiveness(agentId, transitions),
ports,
});
}
+6 -90
View File
@@ -11,7 +11,6 @@ import type { CachedTimeline } from "@/runtime/replica-cache";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import type { StreamItem } from "@/types/stream";
import type { TurnLivenessTransition } from "./turn-liveness";
import {
createTimelineReplica,
createViewedTimelineOwner,
@@ -41,13 +40,7 @@ function cachedTimeline(): CachedTimeline {
};
}
function createOwner(
storage: TimelineReplicaStorage,
applyTurnLiveness: (
agentId: string,
transitions: readonly TurnLivenessTransition[],
) => void = () => undefined,
): ViewedTimelineOwner {
function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
const replica = createTimelineReplica({
serverId: SERVER_ID,
storage,
@@ -58,7 +51,6 @@ function createOwner(
replica,
replaceDemandedAgentIds: () => undefined,
drainQueuedAgentMessage: () => undefined,
applyTurnLiveness,
ports: {
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
readCursor: () => undefined,
@@ -106,7 +98,7 @@ describe("viewed timeline persistence", () => {
.status,
)
.toBe("painted");
owner.applyStreamEvent(AGENT_ID, {
owner.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
@@ -155,7 +147,7 @@ describe("viewed timeline persistence", () => {
.map((entry) => entry.text);
};
expect.soft(replyTexts()).toEqual([complete]);
owner.applyStreamEvent(AGENT_ID, {
owner.enqueueStreamEvent(AGENT_ID, {
event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload,
seq: 7,
epoch: "epoch-1",
@@ -289,7 +281,7 @@ describe("viewed timeline persistence", () => {
)
.toMatchObject({ status: "painted" });
first.applyStreamEvent(AGENT_ID, {
first.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
@@ -468,7 +460,7 @@ describe("viewed timeline persistence", () => {
readTimeline: async () => undefined,
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
});
owner.applyStreamEvent(AGENT_ID, {
owner.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
@@ -533,7 +525,7 @@ describe("viewed timeline persistence", () => {
[AGENT_ID, 9],
["agent-2", 4],
] as const) {
owner.applyStreamEvent(agentId, {
owner.enqueueStreamEvent(agentId, {
event: {
type: "timeline",
provider: "codex",
@@ -551,81 +543,6 @@ describe("viewed timeline persistence", () => {
});
});
describe("turn boundaries", () => {
it("commits a turn's final reply before the turn stops running", () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
applySynced(AGENT_ID, 8);
const committedKinds = () => {
const session = useSessionStore.getState().sessions[SERVER_ID];
return [
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]
.filter((entry) => entry.id !== `network-${AGENT_ID}`)
.map((entry) => entry.kind);
};
const livenessChanges: { type: string; committed: string[] }[] = [];
const owner = createOwner(
{ readTimeline: async () => undefined, commitTimeline: () => undefined },
(_agentId, transitions) => {
for (const transition of transitions) {
livenessChanges.push({ type: transition.type, committed: committedKinds() });
}
},
);
const at = (second: number) => new Date(`2026-08-26T10:00:0${second}.000Z`);
// One frame's worth of live events: the frame flush that would commit them never runs.
owner.applyStreamEvent(AGENT_ID, {
event: { type: "turn_started", provider: "codex", turnId: "turn-1" },
seq: undefined,
epoch: undefined,
timestamp: at(1),
});
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
turnId: "turn-1",
item: {
type: "tool_call",
callId: "call-1",
name: "Bash",
status: "completed",
error: null,
detail: { type: "shell", command: "ls" },
},
} as AgentStreamEventPayload,
seq: 9,
epoch: "epoch-1",
timestamp: at(2),
});
owner.applyStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
turnId: "turn-1",
item: { type: "assistant_message", text: "Done.", messageId: "reply-1" },
} as AgentStreamEventPayload,
seq: 10,
epoch: "epoch-1",
timestamp: at(3),
});
owner.applyStreamEvent(AGENT_ID, {
event: { type: "turn_completed", provider: "codex", turnId: "turn-1" },
seq: undefined,
epoch: undefined,
timestamp: at(4),
});
expect(livenessChanges.at(-1)).toEqual({
type: "stream_close",
committed: ["tool_call", "assistant_message"],
});
owner.dispose();
});
});
describe("create handoff lifetime", () => {
const DRAFT_ID = "draft-1";
@@ -689,7 +606,6 @@ describe("create handoff lifetime", () => {
replica,
replaceDemandedAgentIds: () => undefined,
drainQueuedAgentMessage: () => undefined,
applyTurnLiveness: () => undefined,
ports: {
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
readCursor: () => undefined,
@@ -21,14 +21,12 @@ import {
} from "@/utils/agent-initialization";
import {
createSessionAgentStreamReducerQueue,
deriveAgentStreamTurnLiveness,
type AgentStreamReducerEvent,
type ProcessTimelineResponseOutput,
processTimelineResponse,
} from "./session-stream-reducers";
import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan";
import { replaceWithCanonicalStream } from "@/types/stream";
import type { TurnLivenessTransition } from "./turn-liveness";
export interface TimelineReplicaStorage {
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
@@ -363,12 +361,7 @@ export type ViewedTimelineOwnerPorts = Omit<
export interface ViewedTimelineOwner extends ViewedTimelineSync {
applyTimelineResponse(payload: TimelineResponsePayload): void;
/**
* Accepts one live stream event. Content is coalesced to the next frame; a turn boundary
* commits the turn's queued content before its liveness changes, so the view never shows
* a finished turn without its final reply.
*/
applyStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void;
flushStreamAgent(agentId: string): void;
}
@@ -377,7 +370,6 @@ export function createViewedTimelineOwner(input: {
replica: TimelineReplica;
replaceDemandedAgentIds: (agentIds: string[]) => void;
drainQueuedAgentMessage: (agentId: string) => void;
applyTurnLiveness: (agentId: string, transitions: readonly TurnLivenessTransition[]) => void;
ports: ViewedTimelineOwnerPorts;
}): ViewedTimelineOwner {
const forcedTailReplacements = new Set<string>();
@@ -417,12 +409,8 @@ export function createViewedTimelineOwner(input: {
});
if (accepted) input.replica.timelineUpdated(payload.agentId);
},
applyStreamEvent(agentId, event) {
enqueueStreamEvent(agentId, event) {
streamQueue.enqueue(agentId, event);
const turnLiveness = deriveAgentStreamTurnLiveness([event]);
if (turnLiveness.length === 0) return;
streamQueue.flushAgent(agentId);
input.applyTurnLiveness(agentId, turnLiveness);
},
flushStreamAgent(agentId) {
streamQueue.flushAgent(agentId);
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.15.3",
"version": "0.15.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"license": "Apache-2.0",
"bin": {
@@ -29,9 +29,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/server": "0.15.2",
"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.15.3",
"version": "0.15.2",
"description": "Paseo client SDK package",
"license": "Apache-2.0",
"files": [
@@ -48,8 +48,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.15.3",
"version": "0.15.2",
"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.15.3",
"version": "0.15.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.15.3",
"version": "0.15.2",
"license": "Apache-2.0",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.15.3",
"version": "0.15.2",
"description": "Paseo plugin SDK package",
"license": "Apache-2.0",
"files": [
@@ -78,8 +78,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -89,8 +89,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"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.15.3",
"version": "0.15.2",
"description": "Paseo shared protocol schemas and wire types",
"license": "Apache-2.0",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.15.3",
"version": "0.15.2",
"description": "Paseo relay for bridging daemon and client connections",
"license": "Apache-2.0",
"files": [
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.15.3",
"version": "0.15.2",
"description": "Paseo backend server",
"license": "Apache-2.0",
"files": [
@@ -108,11 +108,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/client": "0.15.2",
"@getpaseo/highlight": "0.15.2",
"@getpaseo/plugin": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -3286,78 +3286,6 @@ test.each([
},
);
test("createAgent and resume give the agent its workspace id", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-workspace-env-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
createLaunchContext: AgentLaunchContext | undefined;
resumeLaunchContext: AgentLaunchContext | undefined;
override async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.createLaunchContext = launchContext;
return new TestAgentSession(config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.resumeLaunchContext = launchContext;
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
return new TestAgentSession({ ...metadata, ...overrides, provider: "codex", cwd: workdir });
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000140",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "ws-agent-home",
});
expect(client.createLaunchContext?.env).toEqual({
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-agent-home",
});
// The stored record supplies the workspace when the caller doesn't.
const handle: AgentPersistenceHandle = {
provider: "codex",
sessionId: "workspace-env-session",
metadata: { provider: "codex", cwd: workdir },
};
const expectedEnv = {
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-agent-home",
};
await manager.closeAgent(snapshot.id);
const resumed = await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id);
expect(client.resumeLaunchContext?.env).toEqual(expectedEnv);
expect(resumed.workspaceId).toBe("ws-agent-home");
expect((await storage.get(snapshot.id))?.workspaceId).toBe("ws-agent-home");
// Each restart reads what the previous resume stored.
client.resumeLaunchContext = undefined;
await manager.closeAgent(snapshot.id);
await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id);
expect(client.resumeLaunchContext?.env).toEqual(expectedEnv);
});
test("createAgent passes persistSession to provider create options", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -4803,7 +4731,6 @@ test("importProviderSession imports the selected session without listing and pub
env: {
PASEO_AGENT_ID: imported.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-imported",
},
});
expect(imported.lifecycle).toBe("idle");
@@ -664,36 +664,6 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
return Object.assign(new Error(message), { name: "AbortError" });
}
// PASEO_WORKSPACE_ID lets an agent rename or archive its own workspace; several workspaces can
// share one cwd, so the agent can't work it out from PASEO_AGENT_CWD.
function agentIdentityEnv({
agentId,
cwd,
opening,
}: {
agentId: string;
cwd: string;
opening: { workspaceId?: string | null } | undefined;
}): Record<string, string> {
const workspaceId = opening?.workspaceId;
return {
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}),
};
}
// Launch env and the registered agent must agree, or the next snapshot erases the stored workspace.
function resolveResumeWorkspaceId({
options,
record,
}: {
options: { workspaceId?: string } | undefined;
record: Pick<StoredAgentRecord, "workspaceId"> | null;
}): string | undefined {
return options?.workspaceId ?? record?.workspaceId;
}
function validateAgentId(agentId: string, source: string): string {
const result = AgentIdSchema.safeParse(agentId);
if (!result.success) {
@@ -1601,7 +1571,6 @@ export class AgentManager {
const currentResumeOptions = { purpose } satisfies AgentResumeSessionOptions;
const launchEnv = record?.launchEnv;
const workspaceId = resolveResumeWorkspaceId({ options, record });
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
@@ -1624,7 +1593,7 @@ export class AgentManager {
{
reason: "resume",
purpose,
workspaceId,
workspaceId: options?.workspaceId ?? null,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
@@ -1640,7 +1609,6 @@ export class AgentManager {
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
workspaceId,
launchEnv,
persistence: handle,
restoring: true,
@@ -5806,7 +5774,8 @@ export class AgentManager {
mcpServerOwners,
env: {
...env,
...agentIdentityEnv({ agentId, cwd, opening }),
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
},
};
const useNativeTools =
@@ -349,11 +349,7 @@ type OpenCodeAgentConfig = Omit<AgentSessionConfig, "providerOptions"> & {
providerOptions: OpenCodeProviderOptions;
};
const OPENCODE_SESSION_ENV_KEYS = new Set([
"PASEO_AGENT_ID",
"PASEO_AGENT_CWD",
"PASEO_WORKSPACE_ID",
]);
const OPENCODE_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
function requiresDedicatedOpenCodeServer(
config: OpenCodeAgentConfig,
@@ -24,14 +24,7 @@ describe("OpenCode v2 helper isolation", () => {
expect(
requiresDedicatedV2Server(
{ provider: "opencode", cwd: "/tmp/project" },
{
agentId: "agent",
env: {
PASEO_AGENT_ID: "agent",
PASEO_AGENT_CWD: "/tmp/project",
PASEO_WORKSPACE_ID: "ws-1",
},
},
{ agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } },
),
).toBe(false);
});
@@ -72,7 +72,7 @@ export async function applyResumeOverrides(
// it must not force a dedicated server. Custom MCP is location-scoped, and any
// other env key belongs to the process, so both need isolation. Mirrors the v1
// adapter's `requiresDedicatedOpenCodeServer`.
const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD", "PASEO_WORKSPACE_ID"]);
const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
export function requiresDedicatedV2Server(
config: AgentSessionConfig,
@@ -18,7 +18,6 @@ import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-config
import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
import type {
AgentClient,
AgentLaunchContext,
AgentPersistenceHandle,
AgentRunResult,
AgentSession,
@@ -325,7 +324,6 @@ class RecordingMcpResumeClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities;
readonly resumeOverrides: Array<Partial<AgentSessionConfig> | undefined> = [];
readonly resumeLaunchContexts: Array<AgentLaunchContext | undefined> = [];
private readonly inner = createTestAgentClients({ supportsMcpServers: true }).codex;
constructor() {
@@ -343,10 +341,8 @@ class RecordingMcpResumeClient implements AgentClient {
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.resumeOverrides.push(overrides);
this.resumeLaunchContexts.push(launchContext);
return this.inner.resumeSession(handle, overrides);
}
@@ -406,7 +402,7 @@ class RejectingMcpResumeClient implements AgentClient {
}
}
test("createAgent preserves the error snapshot when the initial turn cannot start", async () => {
test("createAgent fails when the initial turn cannot start", async () => {
const testAgent = new StubAgentClient({
sessionId: "start-turn-failure-session",
supportsStreaming: false,
@@ -425,18 +421,16 @@ test("createAgent preserves the error snapshot when the initial turn cannot star
await client.connect();
await client.fetchAgents({ subscribe: {} });
const created = await client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Start failure agent",
modeId: "full-access",
model: "gpt-5.4-mini",
initialPrompt: "Run exactly: sleep 30",
});
expect(created.status).toBe("error");
expect(created.lastError).toBe("Initial turn failed to start");
const fetched = await client.fetchAgent({ agentId: created.id });
expect(fetched?.agent.lastError).toBe("Initial turn failed to start");
await expect(
client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Start failure agent",
modeId: "full-access",
model: "gpt-5.4-mini",
initialPrompt: "Run exactly: sleep 30",
}),
).rejects.toThrow("Initial turn failed to start");
} finally {
await client.close();
await daemon.close();
@@ -1040,38 +1034,6 @@ test("resume_agent rehydrates the newest private MCP config from a redacted pers
}
});
test("resume_agent keeps the workspace of the agent it matches", async () => {
const cwd = tmpCwd();
const provider = new RecordingMcpResumeClient();
const localCtx = await createDaemonTestContext({
agentClients: { codex: provider },
});
try {
const opened = await localCtx.client.openProject(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? "Workspace did not open");
}
const workspaceId = opened.workspace.id;
const created = await localCtx.client.createAgent({
config: { provider: "codex", cwd },
workspaceId,
});
if (!created.persistence) {
throw new Error("Expected a persistence handle");
}
await localCtx.client.archiveAgent(created.id);
const resumed = await localCtx.client.resumeAgent(created.persistence);
expect(provider.resumeLaunchContexts[0]?.env?.PASEO_WORKSPACE_ID).toBe(workspaceId);
expect(resumed.workspaceId).toBe(workspaceId);
} finally {
await localCtx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
});
test("resume_agent restores archive state when an MCP-capable provider returns an unsupported session", async () => {
const cwd = tmpCwd();
const provider = new RejectingMcpResumeClient();
@@ -1407,6 +1369,27 @@ test("creates agent and exercises lifecycle", async () => {
});
const createRequestId = `create-${Date.now()}`;
const createdStatusPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status?: string;
agentId?: string;
requestId?: string;
};
if (payload.status !== "agent_created") {
return;
}
if (payload.requestId !== createRequestId) {
return;
}
resolve(message);
});
return unsubscribe;
});
const agent = await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd,
@@ -1421,8 +1404,30 @@ test("creates agent and exercises lifecycle", async () => {
const agentUpdate = await agentUpdatePromise;
expect(agentUpdate.payload.agent.id).toBe(agent.id);
const createdStatus = await createdStatusPromise;
expect((createdStatus.payload as { agentId?: string }).agentId).toBe(agent.id);
const failRequestId = `fail-${Date.now()}`;
const failedStatusPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status?: string;
requestId?: string;
};
if (payload.status !== "agent_create_failed") {
return;
}
if (payload.requestId !== failRequestId) {
return;
}
resolve(message);
});
return unsubscribe;
});
await expect(
ctx.client.createAgent({
...getFullAccessConfig("codex"),
@@ -1431,6 +1436,7 @@ test("creates agent and exercises lifecycle", async () => {
requestId: failRequestId,
}),
).rejects.toThrow("Working directory does not exist");
await failedStatusPromise;
let sawRefresh = false;
const unsubscribe = ctx.client.subscribe((event) => {
@@ -1609,11 +1615,6 @@ test("creates agent and exercises lifecycle", async () => {
}, 300000);
test("handles permission flow", async () => {
const permissionEvents = ctx.client.observeEvents([
"agent_permission_request",
"agent_permission_resolved",
]);
await permissionEvents.ready;
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
@@ -1682,7 +1683,6 @@ test("handles permission flow", async () => {
// Prevent unhandled rejections if the test fails before promises resolve.
await permissionRequestPromise.catch(() => {});
await permissionResolvedPromise.catch(() => {});
await permissionEvents.release();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}
+1 -6
View File
@@ -4856,12 +4856,7 @@ export class Session {
: overrides;
let snapshot: ManagedAgent;
try {
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
effectiveOverrides,
undefined,
{ workspaceId: matched?.record.workspaceId },
);
snapshot = await this.agentManager.resumeAgentFromPersistence(handle, effectiveOverrides);
} catch (error) {
if (matched?.didUnarchive && matched.originalArchivedAt) {
await this.agentManager.archiveSnapshot(matched.record.id, matched.originalArchivedAt);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.15.3",
"version": "0.15.2",
"private": true,
"type": "module",
"scripts": {
-2
View File
@@ -211,8 +211,6 @@ paseo workspace rename <workspace-id> --reset # back to the branch or director
paseo workspace archive <workspace-id>
```
Inside an agent session, `PASEO_WORKSPACE_ID` holds the agent's own workspace ID, so an agent can run `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` or `paseo workspace archive "$PASEO_WORKSPACE_ID"` even when other workspaces share its directory.
Add `--forge <name>` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services.
## Terminals
-2
View File
@@ -202,8 +202,6 @@ paseo workspace rename <workspace-id> --reset # back to the branch or director
paseo workspace archive <workspace-id>
```
在 Agent 会话中,`PASEO_WORKSPACE_ID` 保存该 Agent 自己的工作区 ID。即使其他工作区共享同一个目录,Agent 也可以运行 `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` 或 `paseo workspace archive "$PASEO_WORKSPACE_ID"`。
当 Paseo 无法从源检出推断代码托管平台时,为 PR 检出添加 `--forge <name>`。setup 钩子和服务见 [Git worktree](/docs/worktrees)。
## 终端 {#terminals}
+1 -1
View File
@@ -598,7 +598,7 @@ Creation request
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration
```
+1 -1
View File
@@ -581,7 +581,7 @@ Creation request
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ open provider session and save agent configuration
```