From cd6c608c9e25a0a1c1332c70bb68209d4c612094 Mon Sep 17 00:00:00 2001 From: Ethan Greenfeld <39423472+ebg1223@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:29:42 -0700 Subject: [PATCH 01/20] Fix Pi slash commands hanging after local completion (#2066) * fix(pi): complete locally handled prompts Slash commands the Pi CLI handles without starting a model turn previously left the Paseo turn hanging until interrupt. Parse the optional agentInvoked correlation from prompt acknowledgements, surface command_output/notify output as timeline content, and complete the turn locally when no agent turn will start. Older Pi binaries without the correlation are detected via a getState compatibility probe. * review: guard command output without an active turn, simplify delegates - Drop late-arriving command_output when no turn is active instead of emitting an orphaned timeline item with turnId undefined - Remove redundant 'text' in event check (type is already narrowed) - Inline recordNoTurnNotification delegate * refactor(pi): consolidate local prompt state --------- Co-authored-by: Mohamed Boudra --- .../server/agent/providers/pi/agent.test.ts | 142 ++++++++++++++++ .../src/server/agent/providers/pi/agent.ts | 160 ++++++++++++++++-- .../server/agent/providers/pi/cli-runtime.ts | 16 +- .../server/agent/providers/pi/rpc-types.ts | 7 + .../src/server/agent/providers/pi/runtime.ts | 3 +- .../agent/providers/pi/test-utils/fake-pi.ts | 9 +- 6 files changed, 317 insertions(+), 20 deletions(-) diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 9ebbe00fe..467e68bc4 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; +import { setImmediate as waitForImmediate } from "node:timers/promises"; import { describe, expect, onTestFinished, test } from "vitest"; import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; @@ -54,6 +55,9 @@ function readUtf8File(pathname: string): string { closeSync(fd); } } +async function flushTurnScheduling(): Promise { + await waitForImmediate(); +} async function createSession(pi = new FakePi()): Promise<{ pi: FakePi; @@ -147,6 +151,13 @@ class SessionEvents { }); } + turnCompletedEvents() { + return this.events.filter( + (event): event is Extract => + event.type === "turn_completed", + ); + } + nextTurnCompletion(): Promise> { return this.nextEvent( (event): event is Extract => @@ -885,6 +896,137 @@ describe("PiRpcAgentSession", () => { error: "Pi exited", }); }); + test("completes locally handled slash commands when agentInvoked is false", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.promptAck = { agentInvoked: false }; + + const { turnId: usageTurnId } = await session.startTurn("/usage"); + fakeSession.emit({ + type: "command_output", + text: "\u001b[38;2;138;138;138mUsage 12%\u001b[39m", + }); + + await flushTurnScheduling(); + const usageCompletion = await events.nextTurnCompletion(); + expect(usageCompletion).toMatchObject({ type: "turn_completed", turnId: usageTurnId }); + expect(events.timelineAndCompletionEvents()).toEqual([ + { type: "timeline", item: { type: "user_message", text: "/usage" } }, + { type: "timeline", item: { type: "assistant_message", text: "Usage 12%" } }, + { type: "turn_completed" }, + ]); + + const { turnId: helloTurnId } = await session.startTurn("hello"); + fakeSession.finishTurn(); + await flushTurnScheduling(); + expect(events.turnCompletedEvents()).toHaveLength(2); + expect(events.turnCompletedEvents()[1]).toMatchObject({ + type: "turn_completed", + turnId: helloTurnId, + }); + }); + + test("does not synthesize completion when agentInvoked is true for slash prompts", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.promptAck = { agentInvoked: true }; + + const { turnId } = await session.startTurn("/usage"); + await flushTurnScheduling(); + expect(events.turnCompletedEvents()).toHaveLength(0); + + fakeSession.emit({ type: "agent_start" }); + fakeSession.finishTurn(); + await flushTurnScheduling(); + + const completion = await events.nextTurnCompletion(); + expect(completion).toMatchObject({ type: "turn_completed", turnId }); + expect(events.turnCompletedEvents()).toHaveLength(1); + }); + + test("probes slash prompts without agentInvoked and surfaces buffered notify output", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + const { turnId } = await session.startTurn("/plan on"); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-plan", + method: "notify", + message: "Plan mode enabled", + }); + + await flushTurnScheduling(); + const completion = await events.nextTurnCompletion(); + expect(completion).toMatchObject({ type: "turn_completed", turnId }); + expect(events.timelineAndCompletionEvents()).toEqual([ + { type: "timeline", item: { type: "user_message", text: "/plan on" } }, + { type: "timeline", item: { type: "assistant_message", text: "Plan mode enabled" } }, + { type: "turn_completed" }, + ]); + }); + + test("does not synthesize completion when lifecycle starts before the no-turn probe", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + const { turnId } = await session.startTurn("/custom-template-cmd"); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-buffered", + method: "notify", + message: "Should not appear after turn starts", + }); + fakeSession.emit({ type: "agent_start" }); + + await flushTurnScheduling(); + expect(events.turnCompletedEvents()).toHaveLength(0); + expect(events.timelineItems()).toEqual([]); + + fakeSession.finishTurn(); + await flushTurnScheduling(); + const completion = await events.nextTurnCompletion(); + expect(completion).toMatchObject({ type: "turn_completed", turnId }); + expect(events.turnCompletedEvents()).toHaveLength(1); + }); + + test("fails slash turns when the no-turn getState barrier errors", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.getStateError = new Error("get_state timed out"); + + const { turnId } = await session.startTurn("/local-command on"); + await flushTurnScheduling(); + + await expect(events.nextTurnFailure()).resolves.toMatchObject({ + turnId, + error: "get_state timed out", + }); + + fakeSession.getStateError = null; + const { turnId: recoveryTurnId } = await session.startTurn("hello"); + fakeSession.finishTurn(); + await flushTurnScheduling(); + await expect(events.nextTurnCompletion()).resolves.toMatchObject({ + type: "turn_completed", + turnId: recoveryTurnId, + }); + }); + + test("does not probe non-slash prompts when agentInvoked is missing", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + const { turnId } = await session.startTurn("hello"); + await flushTurnScheduling(); + expect(events.turnCompletedEvents()).toHaveLength(0); + + fakeSession.finishTurn(); + await flushTurnScheduling(); + const completion = await events.nextTurnCompletion(); + expect(completion).toMatchObject({ type: "turn_completed", turnId }); + expect(events.turnCompletedEvents()).toHaveLength(1); + }); }); describe("PiRpcAgentClient", () => { diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index e57126e7f..8cbdfa420 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { homedir, tmpdir } from "node:os"; import { join, resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; +import stripAnsi from "strip-ansi"; import { z } from "zod"; import { @@ -228,6 +229,12 @@ interface PendingPiUserMessage { turnId: string | undefined; } +interface PendingLocalPrompt { + turnId: string; + text: string; + outputs: string[]; +} + interface PendingExtensionResult { resolve: (value: unknown) => void; reject: (error: Error) => void; @@ -1064,6 +1071,7 @@ export class PiRpcAgentSession implements AgentSession { private activeAskUserDialog: ActiveAskUserDialog | null = null; private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null; private activeTurnId: string | null = null; + private pendingLocalPrompt: PendingLocalPrompt | null = null; private activeAssistantMessageId: string | null = null; private lastKnownThinkingOptionId: string | null; currentLeafOverrideId: string | null | undefined; @@ -1124,29 +1132,43 @@ export class PiRpcAgentSession implements AgentSession { const payload = convertPromptInput(prompt, { model: this.state.model }); const turnId = randomUUID(); this.activeTurnId = turnId; + this.pendingLocalPrompt = { turnId, text: payload.text, outputs: [] }; this.activeAssistantMessageId = null; + const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null; - void this.runtimeSession.prompt(payload.text, payload.images).catch((error) => { - if (this.activeTurnId !== turnId) { - return; - } - this.activeTurnId = null; - if (isPiRequestAbortError(error)) { + void (async () => { + try { + const ack = await this.runtimeSession.prompt(payload.text, payload.images); + if (ack.agentInvoked === false) { + await this.completeNoTurnPrompt(turnId); + return; + } + if (ack.agentInvoked === undefined && shouldProbeForNoTurnPrompt) { + await this.completePromptIfHandledWithoutTurn(turnId); + } + } catch (error) { + if (this.activeTurnId !== turnId) { + return; + } + this.activeTurnId = null; + this.pendingLocalPrompt = null; + if (isPiRequestAbortError(error)) { + this.emit({ + type: "turn_canceled", + provider: PI_PROVIDER, + turnId, + reason: toDiagnosticErrorMessage(error), + }); + return; + } this.emit({ - type: "turn_canceled", + type: "turn_failed", provider: PI_PROVIDER, turnId, - reason: toDiagnosticErrorMessage(error), + error: toDiagnosticErrorMessage(error), }); - return; } - this.emit({ - type: "turn_failed", - provider: PI_PROVIDER, - turnId, - error: toDiagnosticErrorMessage(error), - }); - }); + })(); return { turnId }; } @@ -1242,6 +1264,7 @@ export class PiRpcAgentSession implements AgentSession { await this.runtimeSession.abort(); if (turnId && this.activeTurnId === turnId) { this.activeTurnId = null; + this.pendingLocalPrompt = null; this.emit({ type: "turn_canceled", provider: PI_PROVIDER, reason: "interrupted", turnId }); } } @@ -1371,6 +1394,82 @@ export class PiRpcAgentSession implements AgentSession { return this.activeTurnId ?? undefined; } + private async completeNoTurnPrompt(turnId: string): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); + if (this.activeTurnId !== turnId || this.pendingLocalPrompt?.turnId !== turnId) { + return; + } + this.emitPendingLocalPrompt(); + this.completeTurn(turnId, []); + } + + private async completePromptIfHandledWithoutTurn(turnId: string): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); + + let runtimeState: PiSessionState; + try { + runtimeState = await this.runtimeSession.getState(); + } catch (error) { + if (this.activeTurnId === turnId && this.pendingLocalPrompt?.turnId === turnId) { + throw error; + } + return; + } + + if ( + this.activeTurnId !== turnId || + this.pendingLocalPrompt?.turnId !== turnId || + runtimeState.isStreaming + ) { + return; + } + this.state = runtimeState; + + this.emitPendingLocalPrompt(); + this.completeTurn(turnId, []); + } + + private emitPendingLocalPrompt(): void { + const prompt = this.pendingLocalPrompt; + this.pendingLocalPrompt = null; + if (!prompt) { + return; + } + if (prompt.text) { + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId: prompt.turnId, + item: { + type: "user_message", + text: prompt.text, + }, + }); + } + for (const output of prompt.outputs) { + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId: prompt.turnId, + item: { + type: "assistant_message", + text: output, + }, + }); + } + } + + private bufferLocalPromptOutput(message: string): void { + if (!this.pendingLocalPrompt) { + return; + } + this.pendingLocalPrompt.outputs.push(message); + } + private parseSlashCommandInput(text: string): PiSlashCommandInvocation | null { const trimmed = text.trim(); if (!trimmed.startsWith("/") || trimmed.length <= 1) { @@ -1613,6 +1712,7 @@ export class PiRpcAgentSession implements AgentSession { if (this.handleEntryCaptureMarker(message) || this.handleCommandResultMarker(message)) { return; } + this.bufferLocalPromptOutput(message); } if (this.respondToCombinedAskUserFollowUp(event)) { @@ -1667,6 +1767,26 @@ export class PiRpcAgentSession implements AgentSession { return false; } + private handleCommandOutput(textValue: unknown): void { + if (!this.activeTurnId) { + return; + } + const text = stripAnsi(optionalString(textValue) ?? "").trim(); + if (!text) { + return; + } + if (this.pendingLocalPrompt) { + this.bufferLocalPromptOutput(text); + return; + } + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId: this.currentTurnIdForEvent(), + item: { type: "assistant_message", text }, + }); + } + private handleRuntimeEvent(event: PiRuntimeEvent): void { if (event.type === "extension_ui_request") { this.handleExtensionUiRequest(event); @@ -1676,6 +1796,10 @@ export class PiRpcAgentSession implements AgentSession { this.handleProcessExit(event.error); return; } + if (event.type === "command_output") { + this.handleCommandOutput(event.text); + return; + } this.handleSessionEvent(event); } @@ -1686,6 +1810,7 @@ export class PiRpcAgentSession implements AgentSession { } const turnId = this.activeTurnId; this.activeTurnId = null; + this.pendingLocalPrompt = null; this.emit({ type: "turn_failed", provider: PI_PROVIDER, @@ -1699,6 +1824,7 @@ export class PiRpcAgentSession implements AgentSession { switch (event.type) { case "agent_start": + this.pendingLocalPrompt = null; this.emit({ type: "thread_started", provider: PI_PROVIDER, @@ -1706,6 +1832,7 @@ export class PiRpcAgentSession implements AgentSession { }); return; case "turn_start": + this.pendingLocalPrompt = null; this.emit({ type: "turn_started", provider: PI_PROVIDER, @@ -1922,6 +2049,7 @@ export class PiRpcAgentSession implements AgentSession { private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void { this.activeTurnId = null; + this.pendingLocalPrompt = null; this.activeAssistantMessageId = null; const errorMessage = latestPiErrorMessage(messages); if (typeof errorMessage === "string" && errorMessage.length > 0) { diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 001c6339c..4b7418695 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -15,6 +15,7 @@ import type { PiAgentMessage, PiCommandsRpcType, PiModel, + PiPromptAck, PiRpcCommand, PiRpcResponse, PiRpcSlashCommand, @@ -136,8 +137,19 @@ class PiCliRuntimeSession implements PiRuntimeSession { async prompt( message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>, - ): Promise { - await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) }); + ): Promise { + const data = await this.request({ + type: "prompt", + message, + ...(images?.length ? { images } : {}), + }); + if (typeof data === "object" && data !== null && !Array.isArray(data)) { + const { agentInvoked } = data as Record; + if (typeof agentInvoked === "boolean") { + return { agentInvoked }; + } + } + return {}; } async compact(customInstructions?: string): Promise { diff --git a/packages/server/src/server/agent/providers/pi/rpc-types.ts b/packages/server/src/server/agent/providers/pi/rpc-types.ts index a13fa9708..6b0cc6c76 100644 --- a/packages/server/src/server/agent/providers/pi/rpc-types.ts +++ b/packages/server/src/server/agent/providers/pi/rpc-types.ts @@ -5,6 +5,9 @@ export interface PiImageContent { data: string; mimeType: string; } +export interface PiPromptAck { + agentInvoked?: boolean; +} export interface PiTextContent { type: "text"; @@ -181,6 +184,10 @@ export type PiRuntimeEvent = method: string; [key: string]: unknown; } + | { + type: "command_output"; + text?: string; + } | { type: "process_exit"; error: string; diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index e3d4ca72b..a4e4be58a 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -1,6 +1,7 @@ import type { PiAgentMessage, PiModel, + PiPromptAck, PiRpcSlashCommand, PiRuntimeEvent, PiSessionState, @@ -38,7 +39,7 @@ export interface PiRuntimeSession { prompt( message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>, - ): Promise; + ): Promise; compact(customInstructions?: string): Promise; setAutoCompaction(enabled: boolean): Promise; abort(): Promise; diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 2fc4072e1..d27b44f66 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -7,6 +7,7 @@ import type { import type { PiAgentMessage, PiModel, + PiPromptAck, PiRpcSlashCommand, PiRuntimeEvent, PiSessionState, @@ -73,6 +74,8 @@ export class FakePiSession implements PiRuntimeSession { commands: PiRpcSlashCommand[] = []; compactError: Error | null = null; emitCompactEnd = true; + getStateError: Error | null = null; + promptAck: PiPromptAck = {}; state: PiSessionState; private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>(); @@ -104,7 +107,7 @@ export class FakePiSession implements PiRuntimeSession { async prompt( message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>, - ): Promise { + ): Promise { this.prompts.push({ message, imageCount: images?.length ?? 0 }); const heldPrompt = this.nextHeldPrompt; if (heldPrompt) { @@ -120,6 +123,7 @@ export class FakePiSession implements PiRuntimeSession { } this.handleTreeNavigationCommand(message); this.handleEntryCaptureCommand(message); + return this.promptAck; } holdNextPrompt(): void { @@ -166,6 +170,9 @@ export class FakePiSession implements PiRuntimeSession { } async getState(): Promise { + if (this.getStateError) { + throw this.getStateError; + } return this.state; } From d781b687115b65058d4a8ad2b9abdc9a4d0e9ef3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Jul 2026 21:06:25 +0200 Subject: [PATCH 02/20] Polish release notes and in-app help (#2070) * feat(ui): improve changelog and help navigation Make release notes easier to scan and reach from the app. Also keep the homepage marquee within the layout viewport so it cannot create horizontal scrolling. * fix(website): reject duplicate release anchors --- packages/app/e2e/sidebar-help.spec.ts | 9 +- .../components/sidebar/sidebar-help-menu.tsx | 80 ++++++-- .../app/src/components/ui/dropdown-menu.tsx | 8 +- packages/app/src/i18n/resources/ar.ts | 7 +- packages/app/src/i18n/resources/en.ts | 7 +- packages/app/src/i18n/resources/es.ts | 7 +- packages/app/src/i18n/resources/fr.ts | 7 +- packages/app/src/i18n/resources/ja.ts | 7 +- packages/app/src/i18n/resources/pt-BR.ts | 7 +- packages/app/src/i18n/resources/ru.ts | 7 +- packages/app/src/i18n/resources/zh-CN.ts | 7 +- .../website/src/components/landing-page.tsx | 2 +- packages/website/src/routes/changelog.tsx | 86 ++++++++- packages/website/src/styles.css | 179 +++++++++++++----- 14 files changed, 310 insertions(+), 110 deletions(-) diff --git a/packages/app/e2e/sidebar-help.spec.ts b/packages/app/e2e/sidebar-help.spec.ts index 9f95ed1ca..abf6919f1 100644 --- a/packages/app/e2e/sidebar-help.spec.ts +++ b/packages/app/e2e/sidebar-help.spec.ts @@ -6,6 +6,7 @@ const DISCORD_DESTINATION = /^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/; const GITHUB_ISSUE_DESTINATION = /^https:\/\/github\.com\/(?:getpaseo\/paseo\/issues\/new(?:\/choose)?(?:[/?#]|$)|login\?return_to=https%3A%2F%2Fgithub\.com%2Fgetpaseo%2Fpaseo%2Fissues%2Fnew$)/; +const CHANGELOG_DESTINATION = /^https:\/\/paseo\.sh\/changelog(?:[/?#]|$)/; const APP_VERSION = /^Paseo v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; async function openHelpMenu(page: Page): Promise { @@ -52,8 +53,9 @@ test("opens troubleshooting tools from the sidebar help menu", async ({ page }) return { y, height }; }); expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y); - await expect(page.getByText("Troubleshoot", { exact: true })).toBeVisible(); + await expect(page.getByText("Help", { exact: true })).toBeVisible(); await expect(page.getByText("Report an issue", { exact: true })).toBeVisible(); + await expect(page.getByText("What's new", { exact: true })).toBeVisible(); await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION); await page.getByTestId("sidebar-help-diagnostics").click(); @@ -66,7 +68,7 @@ test("opens troubleshooting tools from the sidebar help menu", async ({ page }) await closeSheet(page, "keyboard-shortcuts-dialog"); }); -test("opens the preferred issue-reporting destinations", async ({ page }) => { +test("opens support and release destinations", async ({ page }) => { await gotoAppShell(page); await openHelpMenu(page); @@ -74,6 +76,9 @@ test("opens the preferred issue-reporting destinations", async ({ page }) => { await openHelpMenu(page); await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION); + + await openHelpMenu(page); + await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION); }); test("keeps diagnostics available from Settings after globalizing the sheet", async ({ page }) => { diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index 315bc8f15..6168d5d39 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from "react"; import { Text, View } from "react-native"; -import { Activity, CircleHelp, Keyboard } from "lucide-react-native"; +import { Activity, CircleHelp, Gift, Keyboard } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { DiscordIcon } from "@/components/icons/discord-icon"; @@ -18,16 +18,21 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; import { useAppDiagnosticStore } from "@/diagnostics/store"; +import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useSessionStore } from "@/stores/session-store"; import { ICON_SIZE, type Theme } from "@/styles/theme"; +import type { HostProfile } from "@/types/host-connection"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { resolveAppVersion } from "@/utils/app-version"; import { openExternalUrl } from "@/utils/open-external-url"; const DISCORD_URL = "https://discord.gg/jz8T2uahpH"; const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new"; +const CHANGELOG_URL = "https://paseo.sh/changelog"; const ThemedActivity = withUnistyles(Activity); const ThemedCircleHelp = withUnistyles(CircleHelp); +const ThemedGift = withUnistyles(Gift); const ThemedKeyboard = withUnistyles(Keyboard); const ThemedDiscordIcon = withUnistyles(DiscordIcon); const ThemedGitHubIcon = withUnistyles(GitHubIcon); @@ -47,6 +52,29 @@ const discordLeadingIcon = ( const githubLeadingIcon = ( ); +const changelogLeadingIcon = ( + +); + +function HostVersionHint({ host }: { host: HostProfile }) { + const { t } = useTranslation(); + const isConnected = useHostRuntimeIsConnected(host.serverId); + const daemonVersion = useSessionStore( + (state) => state.sessions[host.serverId]?.serverInfo?.version ?? null, + ); + const version = isConnected + ? formatVersionWithPrefix(daemonVersion) + : t("settings.about.offline"); + + return ( + + {host.label} {version} + + ); +} export function SidebarHelpMenu() { const { t } = useTranslation(); @@ -56,6 +84,7 @@ export function SidebarHelpMenu() { const [open, setOpen] = useState(false); const showKeyboardShortcuts = !isNative && !isCompactLayout; const version = formatVersionWithPrefix(resolveAppVersion()); + const hosts = useHosts(); const openKeyboardShortcuts = useCallback(() => { setShortcutsDialogOpen(true); @@ -69,6 +98,10 @@ export function SidebarHelpMenu() { void openExternalUrl(GITHUB_ISSUE_URL); }, []); + const openChangelog = useCallback(() => { + void openExternalUrl(CHANGELOG_URL); + }, []); + return ( @@ -94,30 +127,34 @@ export function SidebarHelpMenu() { - {t("sidebar.help.troubleshoot")} - - {t("sidebar.help.diagnostics")} - + {t("sidebar.help.sectionHelp")} {showKeyboardShortcuts ? ( {t("sidebar.help.shortcuts")} ) : null} + + {t("sidebar.help.whatsNew")} + + + {t("sidebar.help.diagnostics")} + {t("sidebar.help.reportIssue")} @@ -125,16 +162,20 @@ export function SidebarHelpMenu() { {t("sidebar.help.github")} - - {t("sidebar.help.version", { version })} - + + + {t("sidebar.help.version", { version })} + + {hosts.map((host) => ( + + ))} + ); @@ -153,4 +194,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, + versionList: { + gap: theme.spacing[1], + paddingVertical: theme.spacing[2], + }, + versionHint: { + paddingVertical: 0, + }, })); diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 9af347498..8bceebc34 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -675,10 +675,12 @@ export function DropdownMenuSeparator({ export function DropdownMenuHint({ children, + style, testID, -}: PropsWithChildren<{ testID?: string }>): ReactElement { +}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement { + const hintContainerStyle = useMemo(() => [styles.hintContainer, style], [style]); return ( - + {children} ); @@ -908,7 +910,7 @@ const styles = StyleSheet.create((theme) => ({ }, hintContainer: { paddingHorizontal: theme.spacing[3], - paddingBottom: theme.spacing[2], + paddingVertical: theme.spacing[2], }, hintText: { fontSize: theme.fontSize.xs, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 0a39025b1..82e47079c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -792,16 +792,13 @@ export const ar: TranslationResources = { }, help: { trigger: "المساعدة والدعم", - troubleshoot: "استكشاف الأخطاء وإصلاحها", + sectionHelp: "المساعدة", diagnostics: "تشغيل التشخيص", - diagnosticsDescription: "جمع تفاصيل التطبيق والمضيفين المتصلين", shortcuts: "اختصارات لوحة المفاتيح", - shortcutsDescription: "عرض اختصارات لوحة المفاتيح المتاحة", reportIssue: "الإبلاغ عن مشكلة", discord: "Discord", - discordDescription: "الأفضل للمساعدة السريعة والنقاش", github: "إنشاء مشكلة على GitHub", - githubDescription: "الإبلاغ عن خطأ يمكن إعادة إنتاجه", + whatsNew: "ما الجديد", version: "Paseo {{version}}", }, sections: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 37eab1582..7f39945c1 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -799,16 +799,13 @@ export const en = { }, help: { trigger: "Help and support", - troubleshoot: "Troubleshoot", + sectionHelp: "Help", diagnostics: "Run diagnostics", - diagnosticsDescription: "Collect app and connected host details", shortcuts: "Keyboard shortcuts", - shortcutsDescription: "View available keyboard shortcuts", reportIssue: "Report an issue", discord: "Discord", - discordDescription: "Best for quick help and discussion", github: "Create GitHub issue", - githubDescription: "Report a reproducible bug", + whatsNew: "What's new", version: "Paseo {{version}}", }, sections: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index aba840de3..159fce658 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -819,16 +819,13 @@ export const es: TranslationResources = { }, help: { trigger: "Ayuda y soporte", - troubleshoot: "Solucionar problemas", + sectionHelp: "Ayuda", diagnostics: "Ejecutar diagnóstico", - diagnosticsDescription: "Recopila datos de la app y los hosts conectados", shortcuts: "Atajos de teclado", - shortcutsDescription: "Ver los atajos de teclado disponibles", reportIssue: "Informar de un problema", discord: "Discord", - discordDescription: "La mejor opción para ayuda rápida y conversación", github: "Crear incidencia en GitHub", - githubDescription: "Informar de un error reproducible", + whatsNew: "Novedades", version: "Paseo {{version}}", }, sections: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 82681b4a0..cbbbbe05d 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -818,16 +818,13 @@ export const fr: TranslationResources = { }, help: { trigger: "Aide et assistance", - troubleshoot: "Dépannage", + sectionHelp: "Aide", diagnostics: "Lancer le diagnostic", - diagnosticsDescription: "Collecter les détails de l’app et des hôtes connectés", shortcuts: "Raccourcis clavier", - shortcutsDescription: "Afficher les raccourcis clavier disponibles", reportIssue: "Signaler un problème", discord: "Discord", - discordDescription: "Idéal pour obtenir une aide rapide et échanger", github: "Créer un ticket GitHub", - githubDescription: "Signaler un bug reproductible", + whatsNew: "Nouveautés", version: "Paseo {{version}}", }, sections: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e43962423..823f7d0e8 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -804,16 +804,13 @@ export const ja: TranslationResources = { }, help: { trigger: "ヘルプとサポート", - troubleshoot: "トラブルシューティング", + sectionHelp: "ヘルプ", diagnostics: "診断を実行", - diagnosticsDescription: "アプリと接続中のホストの詳細を収集", shortcuts: "キーボードショートカット", - shortcutsDescription: "利用可能なキーボードショートカットを表示", reportIssue: "問題を報告", discord: "Discord", - discordDescription: "すばやいサポートや相談に最適", github: "GitHub Issueを作成", - githubDescription: "再現可能なバグを報告", + whatsNew: "新着情報", version: "Paseo {{version}}", }, sections: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 7369b1db8..995896fc7 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -810,16 +810,13 @@ export const ptBR: TranslationResources = { }, help: { trigger: "Ajuda e suporte", - troubleshoot: "Resolver problemas", + sectionHelp: "Ajuda", diagnostics: "Executar diagnóstico", - diagnosticsDescription: "Coletar detalhes do app e dos hosts conectados", shortcuts: "Atalhos de teclado", - shortcutsDescription: "Ver os atalhos de teclado disponíveis", reportIssue: "Relatar um problema", discord: "Discord", - discordDescription: "Ideal para ajuda rápida e conversa", github: "Criar issue no GitHub", - githubDescription: "Relatar um bug reproduzível", + whatsNew: "Novidades", version: "Paseo {{version}}", }, sections: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 208d4dbbc..fb6affc64 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -811,16 +811,13 @@ export const ru: TranslationResources = { }, help: { trigger: "Помощь и поддержка", - troubleshoot: "Устранение неполадок", + sectionHelp: "Помощь", diagnostics: "Запустить диагностику", - diagnosticsDescription: "Собрать данные приложения и подключённых хостов", shortcuts: "Сочетания клавиш", - shortcutsDescription: "Показать доступные сочетания клавиш", reportIssue: "Сообщить о проблеме", discord: "Discord", - discordDescription: "Для быстрой помощи и обсуждения", github: "Создать issue в GitHub", - githubDescription: "Сообщить о воспроизводимой ошибке", + whatsNew: "Что нового", version: "Paseo {{version}}", }, sections: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f97c00a35..6dd094490 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -787,16 +787,13 @@ export const zhCN: TranslationResources = { }, help: { trigger: "帮助与支持", - troubleshoot: "问题排查", + sectionHelp: "帮助", diagnostics: "运行诊断", - diagnosticsDescription: "收集应用和已连接 Host 的详细信息", shortcuts: "键盘快捷键", - shortcutsDescription: "查看可用的键盘快捷键", reportIssue: "报告问题", discord: "Discord", - discordDescription: "适合快速求助和讨论", github: "创建 GitHub Issue", - githubDescription: "报告可复现的 bug", + whatsNew: "新功能", version: "Paseo {{version}}", }, sections: { diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 09c9278d4..5c0ca9fa0 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -84,7 +84,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { {/* Content section */} -
+
diff --git a/packages/website/src/routes/changelog.tsx b/packages/website/src/routes/changelog.tsx index 05a5a4881..bbfb19a52 100644 --- a/packages/website/src/routes/changelog.tsx +++ b/packages/website/src/routes/changelog.tsx @@ -4,6 +4,14 @@ import changelogMarkdown from "../../../../CHANGELOG.md?raw"; import { SiteShell } from "~/components/site-shell"; import { pageMeta } from "~/meta"; +interface ChangelogRelease { + version: string; + date: string; + markdown: string; +} + +const releaseHeadingPattern = /^## (.+?) - (\d{4}-\d{2}-\d{2})$/; + export const Route = createFileRoute("/changelog")({ head: () => pageMeta( @@ -14,12 +22,84 @@ export const Route = createFileRoute("/changelog")({ component: Changelog, }); +function formatDate(date: string): string { + const [year, month, day] = date.split("-").map(Number); + return new Intl.DateTimeFormat("en", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }).format(new Date(Date.UTC(year, month - 1, day))); +} + +function parseChangelog(markdown: string): ChangelogRelease[] { + const releases: ChangelogRelease[] = []; + const versions = new Set(); + let currentRelease: ChangelogRelease | null = null; + + for (const line of markdown.split("\n")) { + const heading = line.match(releaseHeadingPattern); + if (heading) { + const version = heading[1]; + if (versions.has(version)) { + throw new Error(`Duplicate changelog version: ${version}`); + } + versions.add(version); + + if (currentRelease) releases.push(currentRelease); + currentRelease = { + version, + date: heading[2], + markdown: "", + }; + continue; + } + + if (currentRelease) currentRelease.markdown += `${line}\n`; + } + + if (currentRelease) releases.push(currentRelease); + return releases; +} + +const changelogReleases = parseChangelog(changelogMarkdown); + +function Release({ release }: { release: ChangelogRelease }) { + const anchor = `release-${release.version}`; + + return ( +
+ +
+ + + +

+ Paseo {release.version} +

+
+
+ {release.markdown} +
+
+ ); +} + function Changelog() { return ( -
- {changelogMarkdown} -
+
+

Changelog

+ {changelogReleases.map((release) => ( + + ))} +
); } diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index 9a2020200..2042f34ac 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -1,5 +1,31 @@ @import "tailwindcss"; +* { + scrollbar-color: var(--color-scrollbar-handle) transparent; + scrollbar-width: thin; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 9999px; + background: var(--color-scrollbar-handle) content-box; +} + +*::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} + @keyframes voice-bar { 0% { transform: scaleY(1); @@ -19,11 +45,15 @@ } .social-proof-marquee { - margin-inline: calc(50% - 50vw); + margin-inline: calc(50% - 50cqw); overflow-x: clip; position: relative; } +.landing-content { + container-type: inline-size; +} + .social-proof-marquee::before, .social-proof-marquee::after { content: ""; @@ -93,66 +123,124 @@ font-size: 0.875em; } -/* Markdown rendering styles for /changelog */ -.changelog-markdown { - color: rgba(250, 250, 250, 0.82); +/* Changelog */ +.changelog-release { + padding-bottom: 3rem; +} + +.changelog-release + .changelog-release { + border-top: 1px solid var(--color-border); + padding-top: 3rem; +} + +.changelog-release-date { + display: block; + margin-bottom: 0.625rem; + color: var(--color-muted-foreground); + font-size: 0.8125rem; +} + +.changelog-release-heading { + position: relative; + scroll-margin-top: 1.5rem; + margin-bottom: 1.75rem; +} + +.changelog-release-title { + color: var(--color-foreground); + font-size: 1.25rem; + font-weight: 500; + letter-spacing: -0.01em; + line-height: 1.4; +} + +.changelog-release-title span { + color: var(--color-muted-foreground); + font-weight: 400; +} + +.changelog-heading-anchor { + position: absolute; + top: 0; + right: 100%; + bottom: 0; + display: inline-flex; + align-items: center; + padding-right: 0.375rem; + color: color-mix(in srgb, var(--color-foreground) 40%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 400; + text-decoration: none; + opacity: 0; + transition: opacity 150ms ease; +} + +.changelog-release-heading:hover .changelog-heading-anchor, +.changelog-heading-anchor:focus { + opacity: 1; +} + +.changelog-heading-anchor:hover { + color: var(--color-foreground); +} + +.changelog-release-notes { + color: color-mix(in srgb, var(--color-foreground) 76%, transparent); + font-size: 0.9375rem; line-height: 1.7; } -.changelog-markdown h1 { - color: var(--color-foreground); - font-size: 2rem; - line-height: 1.2; - font-weight: 600; - margin-bottom: 1rem; -} - -.changelog-markdown h2 { - color: var(--color-foreground); - font-size: 1.5rem; - line-height: 1.3; - font-weight: 600; - margin-top: 2rem; - margin-bottom: 0.75rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--color-border); -} - -.changelog-markdown h3 { - color: var(--color-foreground); - font-size: 1.125rem; +.changelog-release-notes h3 { + margin-top: 1.75rem; + margin-bottom: 0.625rem; + color: var(--color-muted-foreground); + font-size: 0.875rem; + font-weight: 500; line-height: 1.4; - font-weight: 600; - margin-top: 1.5rem; - margin-bottom: 0.5rem; } -.changelog-markdown p { - margin-top: 0.5rem; - margin-bottom: 0.5rem; +.changelog-release-notes h3:first-child { + margin-top: 0; } -.changelog-markdown ul { - list-style: disc; - margin-left: 1.25rem; - margin-top: 0.75rem; - margin-bottom: 0.75rem; +.changelog-release-notes p { + margin-block: 0.75rem; +} + +.changelog-release-notes ul { display: grid; - gap: 0.5rem; + gap: 0.625rem; + margin-top: 0.75rem; + padding-left: 1.25rem; + list-style: disc; } -.changelog-markdown li { - padding-left: 0.125rem; +.changelog-release-notes li::marker { + color: color-mix(in srgb, var(--color-primary) 65%, var(--color-muted-foreground)); } -.changelog-markdown a { +.changelog-release-notes strong { color: var(--color-foreground); - text-decoration: underline; - text-underline-offset: 2px; + font-weight: 500; } -.changelog-markdown a:hover { - color: rgba(250, 250, 250, 0.8); +.changelog-release-notes a { + color: var(--color-foreground); + text-decoration-color: color-mix(in srgb, var(--color-primary) 65%, transparent); + text-decoration-line: underline; + text-underline-offset: 3px; +} + +.changelog-release-notes a:hover { + color: color-mix(in srgb, var(--color-foreground) 78%, var(--color-primary)); +} + +.changelog-release-notes code { + border-radius: 0.25rem; + background: var(--color-muted); + padding: 0.125rem 0.375rem; + color: var(--color-foreground); + font-size: 0.875em; } /* Docs page prose styles — mirrors the original docs/*.tsx components */ @@ -493,6 +581,7 @@ --color-muted-foreground: #a8adac; --color-card: #171d1c; --color-border: #252b2a; + --color-scrollbar-handle: #717574; --color-primary: #239956; --color-primary-foreground: #ffffff; --color-secondary: #252b2a; From 1b8af2e58ee872014f5940071a924b24e25c65f3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Jul 2026 21:16:29 +0200 Subject: [PATCH 03/20] Refine tool call summaries and scrolling (#2069) * feat(app): refine tool call summaries and scrolling Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism. * refactor(app): reuse tool call height constant * fix(app): preserve tool call summary state * fix(app): refresh web tool groups on expand * fix(app): preserve grouped tool errors --- .../app/src/agent-stream/strategy-native.tsx | 60 ++- .../src/agent-stream/strategy-web.test.tsx | 57 +- .../app/src/agent-stream/strategy-web.tsx | 83 ++- packages/app/src/agent-stream/strategy.ts | 8 + packages/app/src/agent-stream/view.tsx | 88 ++- packages/app/src/app/_layout.tsx | 3 + .../src/components/adaptive-modal-sheet.tsx | 19 +- .../app/src/components/diff-scroll.web.tsx | 7 +- packages/app/src/components/diff-viewer.tsx | 6 +- .../src/components/draggable-list.native.tsx | 1 - .../src/components/draggable-list.types.ts | 1 - .../app/src/components/draggable-list.web.tsx | 15 +- .../app/src/components/file-explorer-pane.tsx | 21 +- packages/app/src/components/file-pane.tsx | 33 +- packages/app/src/components/message.tsx | 65 ++- .../schedules/schedule-form-sheet.tsx | 1 - .../app/src/components/terminal-emulator.tsx | 356 ------------- .../app/src/components/tool-call-details.tsx | 47 +- .../app/src/components/tool-call-group.tsx | 341 ------------ .../app/src/components/ui/context-menu.tsx | 3 - .../app/src/components/ui/dropdown-menu.tsx | 6 +- .../app/src/components/use-web-scrollbar.tsx | 182 ------- .../components/web-desktop-scrollbar.math.ts | 73 --- .../components/web-desktop-scrollbar.test.ts | 82 --- .../src/components/web-desktop-scrollbar.tsx | 464 ---------------- packages/app/src/composer/input/input.tsx | 6 - packages/app/src/git/diff-pane.tsx | 47 +- .../src/hooks/use-settings/storage.test.ts | 6 +- .../app/src/hooks/use-settings/storage.ts | 23 +- .../src/hooks/use-web-scrollbar-style.d.ts | 1 - .../hooks/use-web-scrollbar-style.native.ts | 5 - .../src/hooks/use-web-scrollbar-style.web.ts | 21 - packages/app/src/i18n/resources/ar.ts | 13 +- packages/app/src/i18n/resources/en.ts | 13 +- packages/app/src/i18n/resources/es.ts | 11 +- packages/app/src/i18n/resources/fr.ts | 13 +- packages/app/src/i18n/resources/ja.ts | 13 +- packages/app/src/i18n/resources/pt-BR.ts | 13 +- packages/app/src/i18n/resources/ru.ts | 13 +- packages/app/src/i18n/resources/zh-CN.ts | 13 +- packages/app/src/screens/settings-screen.tsx | 12 +- .../appearance/appearance-section.tsx | 3 +- .../app/src/screens/startup-splash-screen.tsx | 14 +- .../styles/install-web-scrollbar-styles.ts | 3 + .../install-web-scrollbar-styles.web.ts | 43 ++ packages/app/src/styles/web-scrollbar.ts | 10 + .../src/tool-calls/detail-level/grouping.ts | 228 ++++++++ .../tool-calls/detail-level/overview/model.ts | 92 ++++ .../tool-calls/detail-level/overview/view.tsx | 173 ++++++ .../detail-level/projection.test.ts | 501 ++++++++++++++++++ .../src/tool-calls/detail-level/projection.ts | 60 +++ packages/app/src/tool-calls/grouping.test.ts | 264 --------- packages/app/src/tool-calls/grouping.ts | 325 ------------ .../app/src/types/react-native-flat-list.d.ts | 10 + packages/app/src/utils/to-xterm-theme.ts | 1 - 55 files changed, 1484 insertions(+), 2488 deletions(-) delete mode 100644 packages/app/src/components/tool-call-group.tsx delete mode 100644 packages/app/src/components/use-web-scrollbar.tsx delete mode 100644 packages/app/src/components/web-desktop-scrollbar.math.ts delete mode 100644 packages/app/src/components/web-desktop-scrollbar.test.ts delete mode 100644 packages/app/src/components/web-desktop-scrollbar.tsx delete mode 100644 packages/app/src/hooks/use-web-scrollbar-style.d.ts delete mode 100644 packages/app/src/hooks/use-web-scrollbar-style.native.ts delete mode 100644 packages/app/src/hooks/use-web-scrollbar-style.web.ts create mode 100644 packages/app/src/styles/install-web-scrollbar-styles.ts create mode 100644 packages/app/src/styles/install-web-scrollbar-styles.web.ts create mode 100644 packages/app/src/styles/web-scrollbar.ts create mode 100644 packages/app/src/tool-calls/detail-level/grouping.ts create mode 100644 packages/app/src/tool-calls/detail-level/overview/model.ts create mode 100644 packages/app/src/tool-calls/detail-level/overview/view.tsx create mode 100644 packages/app/src/tool-calls/detail-level/projection.test.ts create mode 100644 packages/app/src/tool-calls/detail-level/projection.ts delete mode 100644 packages/app/src/tool-calls/grouping.test.ts delete mode 100644 packages/app/src/tool-calls/grouping.ts create mode 100644 packages/app/src/types/react-native-flat-list.d.ts diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 5632c9129..0f9f04b8c 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -33,6 +33,24 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ }); const HISTORY_START_THRESHOLD_PX = 96; +interface HistoryRowDisplayVariants { + regular?: StreamItem; + compact?: StreamItem; +} + +const historyRowDisplayVariants = new WeakMap(); + +function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem { + let variants = historyRowDisplayVariants.get(item); + if (!variants) { + variants = {}; + historyRowDisplayVariants.set(item, variants); + } + const key = compact ? "compact" : "regular"; + variants[key] ??= { ...item }; + return variants[key]; +} + function keyExtractor(item: { id: string }): string { return item.id; } @@ -41,6 +59,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const { agentId, segments, + historyRowRevision, + liveHeadRowRevision, boundary, renderers, listEmptyComponent, @@ -73,12 +93,33 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); - const historyRows = useMemo(() => { + const historyItems = useMemo(() => { if (segments.historyVirtualized.length === 0) { return segments.historyMounted; } return [...segments.historyVirtualized, ...segments.historyMounted]; }, [segments.historyMounted, segments.historyVirtualized]); + // Keep unchanged item identities intact so live updates only rerender rows + // whose projected content or local display state actually changed. A rare + // breakpoint change intentionally refreshes the whole history window. + const globallyRevisedHistoryRows = useMemo(() => { + const globalDisplayState = historyRowRevision?.globalDisplayState ?? false; + return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState)); + }, [historyItems, historyRowRevision?.globalDisplayState]); + const displayStateHistoryRows = useMemo( + () => + globallyRevisedHistoryRows.map((item) => + historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item, + ), + [globallyRevisedHistoryRows, historyRowRevision?.displayStateById], + ); + const historyRows = useMemo( + () => + displayStateHistoryRows.map((item) => + historyRowRevision?.contentById.has(item.id) ? { ...item } : item, + ), + [displayStateHistoryRows, historyRowRevision?.contentById], + ); const clearNativeViewportSettling = useCallback(() => { if (nativeViewportSettlingFrameIdRef.current !== null) { @@ -307,12 +348,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const renderItem = useStableEvent( ({ item, index }: ListRenderItemInfo): ReactElement | null => { - const rendered = renderHistoryMountedRow(item, index, historyRows); + const rendered = renderHistoryMountedRow(item, index, historyItems); return (rendered ?? null) as ReactElement | null; }, ); const liveHeaderContent = useMemo(() => { + // Stable render events read the latest expansion state; this revision makes + // the memo invoke them again when that state changes. + void liveHeadRowRevision; const liveHeadRows = segments.liveHead.map((item, index) => ( {renderLiveHeadRow(item, index, segments.liveHead)} )); @@ -331,7 +375,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat {liveAuxiliary} ); - }, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]); + }, [ + boundary, + listEmptyComponent, + liveHeadRowRevision, + renderLiveAuxiliary, + renderLiveHeadRow, + segments.liveHead, + ]); const historyFooterContent = useMemo(() => { if (!isLoadingOlderHistory) { @@ -344,12 +395,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ); }, [isLoadingOlderHistory]); + // RN's FlatList strictMode keeps its internal renderItem wrapper stable when + // data or the live header changes, preserving the row identities above. return ( { @@ -25,8 +25,6 @@ vi.hoisted(() => { }); }); -vi.mock("@/components/use-web-scrollbar", () => ({ useWebElementScrollbar: () => null })); - function userMessage(index: number): StreamItem { return { kind: "user_message", @@ -148,6 +146,59 @@ describe("createWebStreamStrategy", () => { expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); }); + it("rerenders a stable live-head row when its revision changes", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); + const viewportRef = React.createRef(); + const liveHead = [userMessage(1)]; + let label = "collapsed"; + const renderLiveHeadRow = vi.fn(() =>
{label}
); + const renderInput: StreamRenderInput = { + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted: [], + liveHead, + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: false, + hasLiveHead: true, + }, + renderers: { + ...createRenderers(vi.fn()), + renderLiveHeadRow, + }, + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn(), + isLoadingOlderHistory: false, + hasOlderHistory: false, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 0 })); + }); + expect(container.textContent).toContain("collapsed"); + + label = "expanded"; + act(() => { + root?.render(strategy.render({ ...renderInput, liveHeadRowRevision: 1 })); + }); + + expect(container.textContent).toContain("expanded"); + expect(renderLiveHeadRow).toHaveBeenCalledTimes(2); + }); + it("fires near-history-start when the user scrolls near the top", async () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const viewportRef = React.createRef(); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 60a0ad961..bc5c11604 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -25,7 +25,6 @@ const USER_SCROLL_DELTA_EPSILON = 1; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; const HISTORY_START_THRESHOLD_PX = 96; -import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; const historyStartSlotStyle: CSSProperties = { display: "flex", @@ -95,6 +94,7 @@ function isScrollContainerOverscrolledPastBottom( function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { segments, + liveHeadRowRevision, boundary, renderers, listEmptyComponent, @@ -131,11 +131,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const pendingAutoScrollTimeoutRef = useRef(null); const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const historyStartReadyRef = useRef(false); - const showDesktopWebScrollbar = !isMobileBreakpoint; - const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, { - enabled: showDesktopWebScrollbar, - contentRef, - }); const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const { renderHistoryVirtualizedRow, @@ -540,10 +535,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool )); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { + void liveHeadRowRevision; return segments.liveHead.map((item, index) => ( {renderLiveHeadRow(item, index, segments.liveHead)} )); - }, [renderLiveHeadRow, segments.liveHead]); + }, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]); const liveAuxiliary = useMemo(() => { return renderLiveAuxiliary(); }, [renderLiveAuxiliary]); @@ -564,47 +560,40 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool !liveAuxiliary; return ( - <> -
-
- {historyStartSlot} - {shouldUseVirtualizer ? ( -
- {virtualRows.map((virtualRow) => { - const item = segments.historyVirtualized[virtualRow.index]; - if (!item) { - return null; - } - return ( -
- {renderHistoryVirtualizedRow( - item, - virtualRow.index, - segments.historyVirtualized, - )} -
- ); - })} -
- ) : null} - {mountedHistoryRows} - {liveHeadRows} - {liveAuxiliary} - {shouldRenderEmpty ? listEmptyComponent : null} -
+
+
+ {historyStartSlot} + {shouldUseVirtualizer ? ( +
+ {virtualRows.map((virtualRow) => { + const item = segments.historyVirtualized[virtualRow.index]; + if (!item) { + return null; + } + return ( +
+ {renderHistoryVirtualizedRow(item, virtualRow.index, segments.historyVirtualized)} +
+ ); + })} +
+ ) : null} + {mountedHistoryRows} + {liveHeadRows} + {liveAuxiliary} + {shouldRenderEmpty ? listEmptyComponent : null}
- {scrollbarOverlay} - +
); } diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index f8c272d28..dd9a798ed 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -51,9 +51,17 @@ export interface StreamSegmentRenderers { renderLiveAuxiliary: () => ReactNode; } +export interface StreamHistoryRowRevision { + contentById: { has(id: string): boolean }; + displayStateById: { has(id: string): boolean }; + globalDisplayState: boolean; +} + export interface StreamRenderInput { agentId: string; segments: StreamRenderSegments; + historyRowRevision?: StreamHistoryRowRevision; + liveHeadRowRevision?: unknown; boundary: StreamHistoryBoundary; renderers: StreamSegmentRenderers; listEmptyComponent: ReactNode; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 8250461f1..4736bd5c0 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -57,8 +57,11 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; -import { ToolCallGroup } from "@/components/tool-call-group"; -import { compactToolCallRuns } from "@/tool-calls/grouping"; +import { + prepareToolCallHistory, + projectToolCallDetailLevel, +} from "@/tool-calls/detail-level/projection"; +import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; @@ -259,6 +262,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ ]; const EMPTY_STREAM_HEAD: StreamItem[] = []; +const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200; function buildChatHistoryAttachment(input: { draftId: string; @@ -544,25 +548,38 @@ const AgentStreamViewComponent = forwardRef prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems), + [effectiveStreamItems, toolCallDetailLevel], + ); + const projectedToolCalls = useMemo( () => - compactToolCallRuns({ + projectToolCallDetailLevel({ + level: toolCallDetailLevel, tail: effectiveStreamItems, head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, - enabled: toolCallDetailLevel !== "detailed", + preparedHistory: preparedToolCallHistory, + isTurnActive: context.status === "running", }), - [effectiveStreamHead, effectiveStreamItems, toolCallDetailLevel], + [ + context.status, + effectiveStreamHead, + effectiveStreamItems, + preparedToolCallHistory, + toolCallDetailLevel, + ], ); const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ agentStatus: context.status, - tail: compactedToolCalls.tail, - head: compactedToolCalls.head, + tail: projectedToolCalls.tail, + head: projectedToolCalls.head, platform: isWeb ? "web" : "native", isMobileBreakpoint: isMobile, }); - }, [context.status, isMobile, compactedToolCalls.head, compactedToolCalls.tail]); + }, [context.status, isMobile, projectedToolCalls.head, projectedToolCalls.tail]); const streamLayout = useMemo( () => layoutStream({ @@ -691,7 +708,11 @@ const AgentStreamViewComponent = forwardRef, isLastInSequence: boolean) => { + ( + item: Extract, + isLastInSequence: boolean, + maxDetailHeight?: number, + ) => { const { payload } = item; if (payload.source === "agent") { @@ -720,6 +741,7 @@ const AgentStreamViewComponent = forwardRef ); } @@ -735,6 +757,7 @@ const AgentStreamViewComponent = forwardRef ); }, @@ -743,32 +766,43 @@ const AgentStreamViewComponent = forwardRef) => { - const group = compactedToolCalls.groupsByHostId.get(item.id); + const group = projectedToolCalls.groupsByHostId.get(item.id); if (!group) { return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); } - const expanded = expandedToolCallGroupIds.has(group.id); + const expanded = expandedToolCallGroupIds.has(group.run.id); return ( - - {group.calls.map((call, index) => ( - - {renderSingleToolCallItem(call, index === group.calls.length - 1)} - - ))} - + {expanded + ? group.run.calls.map((call, index) => ( + + {renderSingleToolCallItem( + call, + index === group.run.calls.length - 1, + GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, + )} + + )) + : null} + ); }, [ - compactedToolCalls.groupsByHostId, + projectedToolCalls.groupsByHostId, + context.cwd, expandedToolCallGroupIds, + handleToolCallOpenFile, + isMobile, renderSingleToolCallItem, setToolCallGroupExpanded, - toolCallDetailLevel, ], ); @@ -960,6 +994,14 @@ const AgentStreamViewComponent = forwardRef ({ + contentById: projectedToolCalls.historyGroupUpdatesByHostId, + displayStateById: expandedToolCallGroupIds, + globalDisplayState: isMobile, + }), + [expandedToolCallGroupIds, isMobile, projectedToolCalls.historyGroupUpdatesByHostId], + ); return ( @@ -968,6 +1010,8 @@ const AgentStreamViewComponent = forwardRef installWebScrollbarStyles(), []); + return ( diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 57e424cba..68814e579 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -22,7 +22,6 @@ import { import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; import { createControlGeometry } from "@/components/ui/control-geometry"; import { isNative, isWeb } from "@/constants/platform"; -import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useSafeAreaInsets } from "react-native-safe-area-context"; // Horizontal indent token shared by the sheet header (title, back arrow, @@ -460,11 +459,6 @@ export interface AdaptiveModalSheetProps { desktopMaxWidth?: number; scrollable?: boolean; presentation?: "push" | "replace"; - /** - * Render the themed desktop-web scrollbar over the scroll area instead of the - * native browser scrollbar. No-op on native and on the mobile bottom sheet. - */ - webScrollbar?: boolean; } export function AdaptiveModalSheet({ @@ -479,16 +473,11 @@ export function AdaptiveModalSheet({ desktopMaxWidth, scrollable = true, presentation, - webScrollbar = false, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); - const desktopScrollRef = useRef(null); - const desktopScrollbar = useWebScrollViewScrollbar(desktopScrollRef, { - enabled: webScrollbar && !isMobile, - }); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const compactSafeAreaPadding = useMemo( () => @@ -651,19 +640,13 @@ export function AdaptiveModalSheet({ {scrollable ? ( {children} - {desktopScrollbar.overlay} ) : ( {children} diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx index 8af11ee0e..4caa3ac40 100644 --- a/packages/app/src/components/diff-scroll.web.tsx +++ b/packages/app/src/components/diff-scroll.web.tsx @@ -1,6 +1,5 @@ -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; interface DiffScrollProps { children: React.ReactNode; @@ -16,8 +15,6 @@ export function DiffScroll({ style, contentContainerStyle, }: DiffScrollProps) { - const webScrollbarStyle = useWebScrollbarStyle(); - const combinedStyle = useMemo(() => [style, webScrollbarStyle], [style, webScrollbarStyle]); const handleLayout = useCallback( (e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width), [onScrollViewWidthChange], @@ -28,7 +25,7 @@ export function DiffScroll({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={combinedStyle} + style={style} contentContainerStyle={contentContainerStyle} onLayout={handleLayout} > diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 26f22c6b8..2362363b9 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -6,7 +6,6 @@ import { StyleSheet } from "react-native-unistyles"; import type { DiffLine } from "@/utils/tool-call-parsers"; import { diffLinePrefix } from "@/utils/diff-highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { getCodeInsets } from "./code-insets"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; @@ -127,7 +126,6 @@ export function DiffViewer({ const { t } = useTranslation(); const [scrollViewWidth, setScrollViewWidth] = React.useState(0); const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty"); - const webScrollbarStyle = useWebScrollbarStyle(); const handleInnerLayout = React.useCallback( (e: { nativeEvent: { layout: { width: number } } }) => setScrollViewWidth(e.nativeEvent.layout.width), @@ -139,9 +137,8 @@ export function DiffViewer({ styles.verticalScroll, maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }), fillAvailableHeight && styles.fillHeight, - webScrollbarStyle, ], - [maxHeight, fillAvailableHeight, webScrollbarStyle], + [maxHeight, fillAvailableHeight], ); const linesContainerStyle = React.useMemo( () => [ @@ -180,7 +177,6 @@ export function DiffViewer({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={webScrollbarStyle} contentContainerStyle={styles.horizontalContent} onLayout={handleInnerLayout} > diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index 2de511ca8..554dcafa3 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -24,7 +24,6 @@ export function DraggableList({ ListHeaderComponent, ListEmptyComponent, showsVerticalScrollIndicator = true, - enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false, scrollEnabled = true, useDragHandle: _useDragHandle = false, refreshing, diff --git a/packages/app/src/components/draggable-list.types.ts b/packages/app/src/components/draggable-list.types.ts index 01b15c640..74b365ac6 100644 --- a/packages/app/src/components/draggable-list.types.ts +++ b/packages/app/src/components/draggable-list.types.ts @@ -34,7 +34,6 @@ export interface DraggableListProps { ListHeaderComponent?: ReactElement | null; ListEmptyComponent?: ReactElement | null; showsVerticalScrollIndicator?: boolean; - enableDesktopWebScrollbar?: boolean; /** When false, disables internal scrolling (use outer list to scroll). */ scrollEnabled?: boolean; /** diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 971575255..3f16fc072 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -17,7 +17,6 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types"; -import { useWebScrollViewScrollbar } from "./use-web-scrollbar"; import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder"; export type { DraggableListProps, DraggableRenderItemInfo }; @@ -133,7 +132,6 @@ export function DraggableList({ ListHeaderComponent, ListEmptyComponent, showsVerticalScrollIndicator = true, - enableDesktopWebScrollbar = false, scrollEnabled = true, extraData: _extraData, useDragHandle = false, @@ -147,11 +145,6 @@ export function DraggableList({ onDragEnd, onDragBegin, }); - const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled; - const scrollViewRef = useRef(null); - const scrollbar = useWebScrollViewScrollbar(scrollViewRef, { - enabled: showCustomScrollbar, - }); const pointerActivationConstraint = getPointerActivationConstraint( useDragHandle, POINTER_ACTIVATION_CONFIG, @@ -183,15 +176,10 @@ export function DraggableList({ {scrollEnabled ? ( {ListHeaderComponent} {items.length === 0 && ListEmptyComponent} @@ -254,7 +242,6 @@ export function DraggableList({ {ListFooterComponent} )} - {scrollbar.overlay} ); } diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index aee2cbf07..6707b417c 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -13,7 +13,6 @@ import { type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import * as Clipboard from "expo-clipboard"; import { SvgXml } from "react-native-svg"; @@ -46,8 +45,6 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility"; -import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; -import { isWeb } from "@/constants/platform"; const SORT_OPTIONS: { value: SortOption }[] = [ { value: "name" }, @@ -224,8 +221,6 @@ export function FileExplorerPane({ onOpenFile, }: FileExplorerPaneProps) { const { t } = useTranslation(); - const isMobile = useIsCompactFormFactor(); - const showDesktopWebScrollbar = isWeb && !isMobile; const daemons = useHosts(); const daemonProfile = useMemo( @@ -283,9 +278,6 @@ export function FileExplorerPane({ ); const treeListRef = useRef>(null); - const scrollbar = useWebScrollViewScrollbar(treeListRef, { - enabled: showDesktopWebScrollbar, - }); const hasInitializedRef = useRef(false); @@ -482,9 +474,7 @@ export function FileExplorerPane({ treeRows={treeRows} currentSortLabel={currentSortLabel} isRefreshFetching={isRefreshFetching} - showDesktopWebScrollbar={showDesktopWebScrollbar} treeListRef={treeListRef} - scrollbar={scrollbar} renderTreeRow={renderTreeRow} handleSortCycle={handleSortCycle} handleToggleHiddenFiles={handleToggleHiddenFiles} @@ -505,9 +495,7 @@ interface FileExplorerPaneContentProps { treeRows: TreeRow[]; currentSortLabel: string; isRefreshFetching: boolean; - showDesktopWebScrollbar: boolean; treeListRef: RefObject | null>; - scrollbar: ReturnType; renderTreeRow: (info: ListRenderItemInfo) => ReactElement; handleSortCycle: () => void; handleToggleHiddenFiles: () => void; @@ -528,9 +516,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { treeRows, currentSortLabel, isRefreshFetching, - showDesktopWebScrollbar, treeListRef, - scrollbar, renderTreeRow, handleSortCycle, handleToggleHiddenFiles, @@ -645,17 +631,12 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { keyExtractor={treeRowKeyExtractor} testID="file-explorer-tree-scroll" contentContainerStyle={styles.entriesContent} - onLayout={scrollbar.onLayout} - onScroll={scrollbar.onScroll} - onContentSizeChange={scrollbar.onContentSizeChange} - scrollEventThrottle={16} - showsVerticalScrollIndicator={!showDesktopWebScrollbar} + showsVerticalScrollIndicator initialNumToRender={24} maxToRenderPerBatch={40} windowSize={12} /> )} - {treeRows.length > 0 ? scrollbar.overlay : null} ); } diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 8163b8c03..e8cde4141 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -13,15 +13,12 @@ import { useTranslation } from "react-i18next"; import { MarkdownRenderer } from "@/components/markdown/renderer"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; -import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; -import { isWeb } from "@/constants/platform"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { persistAttachmentFromBytes } from "@/attachments/service"; @@ -43,7 +40,6 @@ interface CodeLineProps { interface FilePreviewBodyProps { preview: ExplorerFile | null; isLoading: boolean; - showDesktopWebScrollbar: boolean; isMobile: boolean; location: WorkspaceFileLocation; imagePreviewUri: string | null; @@ -192,7 +188,6 @@ const codeLineStyles = StyleSheet.create((theme) => ({ function FilePreviewBody({ preview, isLoading, - showDesktopWebScrollbar, isMobile, location, imagePreviewUri, @@ -204,10 +199,6 @@ function FilePreviewBody({ preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart; const previewScrollRef = useRef(null); - const webScrollbarStyle = useWebScrollbarStyle(); - const scrollbar = useWebScrollViewScrollbar(previewScrollRef, { - enabled: showDesktopWebScrollbar, - }); const highlightedLines = useMemo(() => { if (!preview || preview.kind !== "text" || isMarkdownFile) { @@ -276,15 +267,10 @@ function FilePreviewBody({ ref={previewScrollRef} style={styles.previewContent} contentContainerStyle={styles.previewMarkdownScrollContent} - onLayout={scrollbar.onLayout} - onScroll={scrollbar.onScroll} - onContentSizeChange={scrollbar.onContentSizeChange} - scrollEventThrottle={16} - showsVerticalScrollIndicator={!showDesktopWebScrollbar} + showsVerticalScrollIndicator > - {scrollbar.overlay} ); } @@ -318,11 +304,7 @@ function FilePreviewBody({ {isMobile ? ( {codeLines} @@ -331,14 +313,12 @@ function FilePreviewBody({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={webScrollbarStyle} contentContainerStyle={styles.previewCodeScrollContent} > {codeLines} )} - {scrollbar.overlay} ); } @@ -359,11 +339,7 @@ function FilePreviewBody({ ref={previewScrollRef} style={styles.previewContent} contentContainerStyle={styles.previewImageScrollContent} - onLayout={scrollbar.onLayout} - onScroll={scrollbar.onScroll} - onContentSizeChange={scrollbar.onContentSizeChange} - scrollEventThrottle={16} - showsVerticalScrollIndicator={!showDesktopWebScrollbar} + showsVerticalScrollIndicator > - {scrollbar.overlay} ); } @@ -395,7 +370,6 @@ export function FilePane({ }) { const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); - const showDesktopWebScrollbar = isWeb && !isMobile; const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); @@ -463,7 +437,6 @@ export function FilePane({ ({ }, chevron: { flexShrink: 0, - transform: [{ scale: 1.3 }], }, openFileButton: { marginLeft: theme.spacing[1], @@ -1285,9 +1286,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ width: 14, height: 14, }, - chevronExpanded: { - transform: [{ scale: 1.3 }, { rotate: "90deg" }], - }, detailWrapper: { borderBottomLeftRadius: theme.borderRadius.lg, borderBottomRightRadius: theme.borderRadius.lg, @@ -1302,11 +1300,16 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ ...(isWeb ? { cursor: "auto" as const, userSelect: "text" as const } : {}), }, pressableExpanded: { - borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, + }, + pressableExpandedAttached: { + borderColor: theme.colors.border, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, + detailWrapperBorderless: { + borderWidth: 0, + }, shimmerOverlay: { position: "absolute", top: 0, @@ -1358,9 +1361,14 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer( durationSeconds, gradientId, }: NativeExpandableBadgeShimmerProps) { + const isPanelActive = useRetainedPanelActive(); const shimmerTranslateX = useSharedValue(0); useEffect(() => { + if (!isPanelActive) { + cancelAnimation(shimmerTranslateX); + return; + } const startPosition = -peakWidth; const endPosition = rowWidth + peakWidth; shimmerTranslateX.value = startPosition; @@ -1375,7 +1383,7 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer( return () => { cancelAnimation(shimmerTranslateX); }; - }, [durationSeconds, peakWidth, rowWidth, shimmerTranslateX]); + }, [durationSeconds, isPanelActive, peakWidth, rowWidth, shimmerTranslateX]); const nativeShimmerPeakStyle = useAnimatedStyle(() => ({ transform: [{ translateX: shimmerTranslateX.value }], @@ -2366,6 +2374,7 @@ interface ExpandableBadgeProps { isError?: boolean; isLastInSequence?: boolean; disableOuterSpacing?: boolean; + borderlessWhenExpanded?: boolean; testID?: string; } @@ -2604,7 +2613,9 @@ function renderExpandableBadgeIconSlot({ }): ReactNode { if (showChevron) { return ( - + + + ); } return iconNode; @@ -2695,7 +2706,7 @@ function buildShimmerTextStyle(input: { offsetX: number; }): object | null { if (!input.isWebShimmer) return null; - return { + return inlineUnistylesStyle({ opacity: 1, color: "transparent", backgroundImage: SHIMMER_GRADIENT, @@ -2707,10 +2718,10 @@ function buildShimmerTextStyle(input: { animation: `${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} ${input.shimmerDuration}s linear infinite`, "--paseo-shimmer-start": `${input.webShimmerTrackStart - input.offsetX}px`, "--paseo-shimmer-end": `${input.webShimmerTrackEnd - input.offsetX}px`, - }; + }); } -const ExpandableBadge = memo(function ExpandableBadge({ +export const ExpandableBadge = memo(function ExpandableBadge({ label, style, secondaryLabel, @@ -2724,6 +2735,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ isError = false, isLastInSequence = false, disableOuterSpacing, + borderlessWhenExpanded = false, testID, }: ExpandableBadgeProps) { const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); @@ -2894,8 +2906,17 @@ const ExpandableBadge = memo(function ExpandableBadge({ expandableBadgeStylesheet.pressable, isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null, isExpanded && expandableBadgeStylesheet.pressableExpanded, + isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached, ], - [isExpanded, isInteractive, isPressed], + [borderlessWhenExpanded, isExpanded, isInteractive, isPressed], + ); + + const detailWrapperStyle = useMemo( + () => [ + expandableBadgeStylesheet.detailWrapper, + borderlessWhenExpanded && expandableBadgeStylesheet.detailWrapperBorderless, + ], + [borderlessWhenExpanded], ); const accessibilityState = useMemo( @@ -2944,8 +2965,10 @@ const ExpandableBadge = memo(function ExpandableBadge({ const chevronStyle = useMemo( () => [ expandableBadgeStylesheet.chevron, - isExpanded && expandableBadgeStylesheet.chevronExpanded, LUCIDE_CHEVRON_NUDGE_LEFT, + inlineUnistylesStyle({ + transform: isExpanded ? [{ scale: 1.3 }, { rotate: "90deg" }] : [{ scale: 1.3 }], + }), ], [isExpanded], ); @@ -2953,7 +2976,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]); const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon }); const iconSlotNode = renderExpandableBadgeIconSlot({ - showChevron: isInteractive && isHovered, + showChevron: isInteractive && (isHovered || isExpanded), chevronStyle, iconNode, }); @@ -3012,7 +3035,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ {detailContent ? ( @@ -3033,6 +3056,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa if (previous.isError !== next.isError) return false; if (previous.isLastInSequence !== next.isLastInSequence) return false; if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false; + if (previous.borderlessWhenExpanded !== next.borderlessWhenExpanded) return false; if (previous.testID !== next.testID) return false; if (previous.onToggle !== next.onToggle) return false; if (previous.onOpenFile !== next.onOpenFile) return false; @@ -3057,6 +3081,7 @@ interface ToolCallProps { onOpenFilePath?: (filePath: string) => void; defaultExpanded?: boolean; forceInline?: boolean; + maxDetailHeight?: number; } export const ToolCall = memo(function ToolCall({ @@ -3075,6 +3100,7 @@ export const ToolCall = memo(function ToolCall({ onOpenFilePath, defaultExpanded, forceInline = false, + maxDetailHeight = 400, }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false); @@ -3175,11 +3201,17 @@ export const ToolCall = memo(function ToolCall({ ); - }, [shouldRenderInline, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]); + }, [ + shouldRenderInline, + effectiveDetail, + presentation.errorText, + presentation.isLoadingDetails, + maxDetailHeight, + ]); if (presentation.isPlan && effectiveDetail?.type === "plan") { return ( @@ -3224,5 +3256,6 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) { if (previous.onOpenFilePath !== next.onOpenFilePath) return false; if (previous.defaultExpanded !== next.defaultExpanded) return false; if (previous.forceInline !== next.forceInline) return false; + if (previous.maxDetailHeight !== next.maxDetailHeight) return false; return true; } diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 1a7b1295d..721ceb225 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -432,7 +432,6 @@ function OpenScheduleFormSheet({ onClose={onClose} onDismiss={onDismiss} footer={footer} - webScrollbar testID="schedule-form-sheet" > void; } -const SCROLLBAR_HANDLE_WIDTH_IDLE = 6; -const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9; -const SCROLLBAR_HANDLE_GRAB_WIDTH = 18; -const SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING = 8; -const SCROLLBAR_HANDLE_OPACITY_VISIBLE = 0.62; -const SCROLLBAR_HANDLE_OPACITY_HOVERED = 0.78; -const SCROLLBAR_HANDLE_OPACITY_DRAGGING = 0.9; -const SCROLLBAR_HANDLE_FADE_DURATION_MS = 220; -const SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS = 240; -const SCROLLBAR_HANDLE_TRAVEL_DURATION_MS = 90; -const SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS = 1_200; -const SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS = 110; -const WEBKIT_SCROLLBAR_STYLE_ID = "terminal-emulator-webkit-scrollbar-style"; - const HOST_DIV_STYLE: CSSProperties = { flex: 1, minHeight: 0, @@ -79,25 +60,6 @@ const HOST_DIV_STYLE: CSSProperties = { paddingRight: 0, }; -const SCROLLBAR_CONTAINER_STYLE: CSSProperties = { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - width: 12, - display: "flex", - alignItems: "center", - justifyContent: "flex-start", - zIndex: 10, - pointerEvents: "none", -}; - -interface ViewportMetrics { - offset: number; - viewportSize: number; - contentSize: number; -} - function buildXtermThemeKey(theme: ITheme): string { const values: Array = [ theme.background, @@ -169,10 +131,6 @@ declare global { interface Window {} } -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - function isTerminalState(value: unknown): value is TerminalState { return ( typeof value === "object" && @@ -183,30 +141,6 @@ function isTerminalState(value: unknown): value is TerminalState { ); } -function ensureTerminalScrollbarStyle(): void { - if (typeof document === "undefined") { - return; - } - if (document.getElementById(WEBKIT_SCROLLBAR_STYLE_ID)) { - return; - } - - const styleElement = document.createElement("style"); - styleElement.id = WEBKIT_SCROLLBAR_STYLE_ID; - styleElement.textContent = ` - [data-terminal-scrollbar-root="true"] .xterm-viewport { - scrollbar-width: none; - -ms-overflow-style: none; - } - - [data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar { - width: 0; - height: 0; - } - `; - document.head.appendChild(styleElement); -} - export default function TerminalEmulator({ ref, streamKey, @@ -246,13 +180,6 @@ export default function TerminalEmulator({ scrollbackLinesRef.current = scrollbackLines; fontFamilyRef.current = fontFamily; fontSizeRef.current = fontSize; - const viewportRef = useRef(null); - const dragStartOffsetRef = useRef(0); - const dragStartClientYRef = useRef(0); - const scrollVisibilityTimeoutRef = useRef | null>(null); - const scrollActiveTimeoutRef = useRef | null>(null); - const lastObservedOffsetRef = useRef(null); - const lastMetricsRef = useRef({ offset: 0, viewportSize: 0, contentSize: 0 }); const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); const xtermThemeRef = useRef(xtermTheme); xtermThemeRef.current = xtermTheme; @@ -280,30 +207,8 @@ export default function TerminalEmulator({ initialSnapshotRef.current = initialSnapshot; const pendingModifiersRef = useRef(pendingModifiers); pendingModifiersRef.current = pendingModifiers; - const [viewportMetrics, setViewportMetrics] = useState({ - offset: 0, - viewportSize: 0, - contentSize: 0, - }); - const [isHandleHovered, setIsHandleHovered] = useState(false); - const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false); - const [isScrollVisible, setIsScrollVisible] = useState(false); - const [isScrollActive, setIsScrollActive] = useState(false); const [isDropActive, setIsDropActive] = useState(false); const dropActiveTimeoutRef = useRef | null>(null); - const updateViewportMetricsState = useCallback((metrics: ViewportMetrics) => { - const lastMetrics = lastMetricsRef.current; - if ( - metrics.offset === lastMetrics.offset && - metrics.viewportSize === lastMetrics.viewportSize && - metrics.contentSize === lastMetrics.contentSize - ) { - return; - } - - lastMetricsRef.current = metrics; - setViewportMetrics(metrics); - }, []); const domBridgeRef = useRef(null); useDOMImperativeHandle( @@ -366,10 +271,6 @@ export default function TerminalEmulator({ runtimeRef.current?.setScrollback({ lines: scrollbackLines }); }, [scrollbackLines]); - useEffect(() => { - ensureTerminalScrollbarStyle(); - }, []); - useEffect(() => { const root = rootRef.current; if (!root || !swipeGesturesEnabled) { @@ -580,188 +481,6 @@ export default function TerminalEmulator({ runtimeRef.current?.resize({ force: true, shouldClaim: true }); }, [resizeRequestToken]); - useEffect(() => { - const host = hostRef.current; - if (!host) { - return () => {}; - } - - const viewportElement = host.querySelector(".xterm-viewport"); - if (!viewportElement) { - viewportRef.current = null; - updateViewportMetricsState({ offset: 0, viewportSize: 0, contentSize: 0 }); - return () => {}; - } - - viewportRef.current = viewportElement; - - const updateViewportMetrics = () => { - const offset = Math.max(0, viewportElement.scrollTop); - const viewportSize = Math.max(0, viewportElement.clientHeight); - const contentSize = Math.max(0, viewportElement.scrollHeight); - updateViewportMetricsState({ offset, viewportSize, contentSize }); - }; - - updateViewportMetrics(); - - let scrollRafId: number | null = null; - const handleViewportScroll = () => { - if (scrollRafId !== null) { - return; - } - scrollRafId = requestAnimationFrame(() => { - scrollRafId = null; - updateViewportMetrics(); - }); - }; - - const resizeObserver = new ResizeObserver(() => { - updateViewportMetrics(); - }); - resizeObserver.observe(viewportElement); - const scrollAreaElement = host.querySelector(".xterm-scroll-area"); - if (scrollAreaElement) { - resizeObserver.observe(scrollAreaElement); - } - - viewportElement.addEventListener("scroll", handleViewportScroll, { passive: true }); - - return () => { - if (scrollRafId !== null) { - cancelAnimationFrame(scrollRafId); - scrollRafId = null; - } - viewportElement.removeEventListener("scroll", handleViewportScroll); - resizeObserver.disconnect(); - if (viewportRef.current === viewportElement) { - viewportRef.current = null; - } - }; - }, [streamKey, updateViewportMetricsState]); - - useEffect(() => { - const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize); - const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset); - if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) { - setIsScrollVisible(false); - setIsScrollActive(false); - lastObservedOffsetRef.current = null; - return; - } - - const previousOffset = lastObservedOffsetRef.current; - lastObservedOffsetRef.current = normalizedOffset; - if (previousOffset === null || Math.abs(previousOffset - normalizedOffset) <= 0.5) { - return; - } - - setIsScrollVisible(true); - if (scrollVisibilityTimeoutRef.current !== null) { - clearTimeout(scrollVisibilityTimeoutRef.current); - } - scrollVisibilityTimeoutRef.current = setTimeout(() => { - setIsScrollVisible(false); - scrollVisibilityTimeoutRef.current = null; - }, SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS); - - setIsScrollActive(true); - if (scrollActiveTimeoutRef.current !== null) { - clearTimeout(scrollActiveTimeoutRef.current); - } - scrollActiveTimeoutRef.current = setTimeout(() => { - setIsScrollActive(false); - scrollActiveTimeoutRef.current = null; - }, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS); - }, [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]); - - useEffect(() => { - return () => { - if (scrollVisibilityTimeoutRef.current !== null) { - clearTimeout(scrollVisibilityTimeoutRef.current); - } - if (scrollActiveTimeoutRef.current !== null) { - clearTimeout(scrollActiveTimeoutRef.current); - } - }; - }, []); - - const scrollbarGeometry = useMemo( - () => - computeVerticalScrollbarGeometry({ - viewportSize: viewportMetrics.viewportSize, - contentSize: viewportMetrics.contentSize, - offset: viewportMetrics.offset, - }), - [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize], - ); - - useEffect(() => { - if (!isDraggingScrollbar) { - return () => {}; - } - - const handlePointerMove = (event: PointerEvent) => { - const dragDelta = event.clientY - dragStartClientYRef.current; - const nextOffset = computeScrollOffsetFromDragDelta({ - startOffset: dragStartOffsetRef.current, - dragDelta, - maxScrollOffset: scrollbarGeometry.maxScrollOffset, - maxHandleOffset: scrollbarGeometry.maxHandleOffset, - }); - const viewportElement = viewportRef.current; - if (!viewportElement) { - return; - } - viewportElement.scrollTop = nextOffset; - updateViewportMetricsState({ - offset: nextOffset, - viewportSize: Math.max(0, viewportElement.clientHeight), - contentSize: Math.max(0, viewportElement.scrollHeight), - }); - }; - - const stopDragging = () => { - setIsDraggingScrollbar(false); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", stopDragging); - window.addEventListener("pointercancel", stopDragging); - - return () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", stopDragging); - window.removeEventListener("pointercancel", stopDragging); - }; - }, [ - isDraggingScrollbar, - scrollbarGeometry.maxHandleOffset, - scrollbarGeometry.maxScrollOffset, - updateViewportMetricsState, - ]); - - const handleVisible = - scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered); - let handleOpacity: number; - if (isDraggingScrollbar) handleOpacity = SCROLLBAR_HANDLE_OPACITY_DRAGGING; - else if (isHandleHovered) handleOpacity = SCROLLBAR_HANDLE_OPACITY_HOVERED; - else if (isScrollVisible) handleOpacity = SCROLLBAR_HANDLE_OPACITY_VISIBLE; - else handleOpacity = 0; - const handleWidth = - isDraggingScrollbar || isHandleHovered - ? SCROLLBAR_HANDLE_WIDTH_ACTIVE - : SCROLLBAR_HANDLE_WIDTH_IDLE; - const thumbRegionOffset = Math.max( - 0, - scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING, - ); - const thumbRegionHeight = Math.min( - viewportMetrics.viewportSize - thumbRegionOffset, - scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2, - ); - const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2); - const handleTravelDurationMs = - isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS; const showTerminalContextMenu = useCallback(() => { const showContextMenu = window.paseoDesktop?.menu?.showContextMenu; if (typeof showContextMenu !== "function") { @@ -788,29 +507,6 @@ export default function TerminalEmulator({ [showTerminalContextMenu], ); - const scrollbarMaxOffset = scrollbarGeometry.maxScrollOffset; - const handleScrollbarPointerDown = useCallback( - (event: ReactPointerEvent) => { - event.preventDefault(); - event.stopPropagation(); - dragStartOffsetRef.current = clamp(viewportMetrics.offset, 0, scrollbarMaxOffset); - dragStartClientYRef.current = event.clientY; - setIsDraggingScrollbar(true); - }, - [scrollbarMaxOffset, viewportMetrics.offset], - ); - - const handleScrollbarPointerEnter = useCallback(() => { - if (!isScrollVisible && !isDraggingScrollbar) { - return; - } - setIsHandleHovered(true); - }, [isScrollVisible, isDraggingScrollbar]); - - const handleScrollbarPointerLeave = useCallback(() => { - setIsHandleHovered(false); - }, []); - const clearDropActiveTimeout = useCallback(() => { if (dropActiveTimeoutRef.current === null) { return; @@ -944,46 +640,6 @@ export default function TerminalEmulator({ }), [isDropActive], ); - const handleContainerStyle = useMemo( - () => ({ - position: "absolute", - top: 0, - right: -3, - width: SCROLLBAR_HANDLE_GRAB_WIDTH, - height: thumbRegionHeight, - transform: `translateY(${thumbRegionOffset}px)`, - cursor: isDraggingScrollbar ? "grabbing" : "grab", - touchAction: "none", - userSelect: "none", - transitionProperty: "transform", - transitionDuration: `${handleTravelDurationMs}ms`, - transitionTimingFunction: "linear", - pointerEvents: handleVisible ? "auto" : "none", - }), - [ - thumbRegionHeight, - thumbRegionOffset, - isDraggingScrollbar, - handleTravelDurationMs, - handleVisible, - ], - ); - const handleInnerStyle = useMemo( - () => ({ - marginTop: handleInsetTop, - height: scrollbarGeometry.handleSize, - width: handleWidth, - borderRadius: 999, - alignSelf: "center", - backgroundColor: "rgba(113, 113, 122, 1)", - opacity: handleOpacity, - transitionProperty: "opacity, width, background-color", - transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`, - transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", - }), - [handleInsetTop, scrollbarGeometry.handleSize, handleWidth, handleOpacity], - ); - return (
- {scrollbarGeometry.isVisible ? ( -
-
-
-
-
- ) : null}
); } diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index a77156478..b3e6cd463 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -15,7 +15,6 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { highlightDiffLines } from "@/utils/diff-highlight"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache"; @@ -46,7 +45,6 @@ interface DetailStyles { jsonScrollErrorCombined: StyleProp; fullBleedContainerStyle: StyleProp; loadingContainerStyle: StyleProp; - webScrollbarStyle: StyleProp; resolvedMaxHeight: number | undefined; shouldFill: boolean; isFullBleed: boolean; @@ -70,7 +68,6 @@ function useDetailStyles( resolvedMaxHeight: number | undefined, fillAvailableHeight: boolean, ): DetailStyles { - const webScrollbarStyle = useWebScrollbarStyle(); const isFullBleed = resolveIsFullBleed(detail); const shouldFill = resolveShouldFill(detail, fillAvailableHeight); const codeBlockStyle = isFullBleed ? styles.fullBleedBlock : styles.diffContainer; @@ -88,35 +85,26 @@ function useDetailStyles( styles.codeVerticalScroll, resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), shouldFill && styles.fillHeight, - webScrollbarStyle, ], - [resolvedMaxHeight, shouldFill, webScrollbarStyle], + [resolvedMaxHeight, shouldFill], ); const scrollAreaFillStyle = useMemo( () => [ styles.scrollArea, resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), shouldFill && styles.fillHeight, - webScrollbarStyle, ], - [resolvedMaxHeight, shouldFill, webScrollbarStyle], + [resolvedMaxHeight, shouldFill], ); const scrollAreaStyle = useMemo( () => [ styles.scrollArea, resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), - webScrollbarStyle, ], - [resolvedMaxHeight, webScrollbarStyle], - ); - const jsonScrollCombined = useMemo( - () => [styles.jsonScroll, webScrollbarStyle], - [webScrollbarStyle], - ); - const jsonScrollErrorCombined = useMemo( - () => [styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle], - [webScrollbarStyle], + [resolvedMaxHeight], ); + const jsonScrollCombined = styles.jsonScroll; + const jsonScrollErrorCombined = [styles.jsonScroll, styles.jsonScrollError]; const fullBleedContainerStyle = useMemo( () => [ isFullBleed ? styles.fullBleedContainer : styles.paddedContainer, @@ -139,7 +127,6 @@ function useDetailStyles( jsonScrollErrorCombined, fullBleedContainerStyle, loadingContainerStyle, - webScrollbarStyle, resolvedMaxHeight, shouldFill, isFullBleed, @@ -179,7 +166,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) { horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -224,7 +210,6 @@ function WorktreeSetupDetailSection({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -389,7 +374,6 @@ function SubAgentDetailSection({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - style={ds.webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -466,12 +450,7 @@ function ScrollableTextSection({ nestedScrollEnabled showsVerticalScrollIndicator={true} > - + {keyedLines ? ( ) : ( @@ -501,12 +480,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) { nestedScrollEnabled showsVerticalScrollIndicator > - + {result ? `${url}\n\n${result}` : url} @@ -545,12 +519,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[ nestedScrollEnabled showsVerticalScrollIndicator > - + {detail.content} diff --git a/packages/app/src/components/tool-call-group.tsx b/packages/app/src/components/tool-call-group.tsx deleted file mode 100644 index ef44e3f7a..000000000 --- a/packages/app/src/components/tool-call-group.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import { memo, useCallback, useMemo, type ReactNode } from "react"; -import { - ActivityIndicator, - Pressable, - Text, - View, - type PressableStateCallbackType, -} from "react-native"; -import { useTranslation } from "react-i18next"; -import { ChevronRight, TriangleAlert, Wrench } from "lucide-react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import type { - CompactToolCallGroup as CompactToolCallGroupModel, - ToolCallCategorySummary, -} from "@/tool-calls/grouping"; -import { componentForToolCallIcon } from "@/utils/tool-call-icon"; - -interface ToolCallGroupProps { - group: CompactToolCallGroupModel; - presentation: "overview" | "concise"; - expanded: boolean; - onExpandedChange: (groupId: string, expanded: boolean) => void; - children: ReactNode; -} - -function CategoryStatus({ category }: { category: ToolCallCategorySummary }) { - const { t } = useTranslation(); - if (category.failedCount > 0) { - return ( - - - - {t("toolCallGroup.failed", { count: category.failedCount })} - - - ); - } - if (category.runningCount > 0) { - return ; - } - return null; -} - -function CategoryRow({ - category, - resourceLimit, -}: { - category: ToolCallCategorySummary; - resourceLimit: number; -}) { - const Icon = componentForToolCallIcon(category.iconName); - const visibleResources = category.resources.slice(0, resourceLimit); - const hiddenResourceCount = category.resources.length - visibleResources.length; - const resourceText = [ - ...visibleResources, - ...(hiddenResourceCount > 0 ? [`+${hiddenResourceCount}`] : []), - ].join(", "); - - return ( - - - - - ×{category.callCount} - {category.label} - - {resourceText ? ( - - {resourceText} - - ) : null} - - ); -} - -function GroupHeaderIcon({ - group, - compact, -}: { - group: CompactToolCallGroupModel; - compact: boolean; -}) { - const size = compact ? 11 : 12; - if (group.failedCount > 0) { - return ; - } - if (group.isRunning) { - return ; - } - return ; -} - -function joinSummaryParts(parts: string[], conjunction: string): string { - if (parts.length === 0) { - return ""; - } - let joined: string; - if (parts.length === 1) { - joined = parts[0] ?? ""; - } else if (parts.length === 2) { - joined = `${parts[0]} ${conjunction} ${parts[1]}`; - } else { - joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; - } - const firstCharacter = joined[0]; - return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; -} - -export const ToolCallGroup = memo(function ToolCallGroup({ - group, - presentation, - expanded, - onExpandedChange, - children, -}: ToolCallGroupProps) { - const { t } = useTranslation(); - const isCompact = useIsCompactFormFactor(); - const isOverview = presentation === "overview"; - const resourceLimit = isCompact ? 2 : 3; - const handlePress = useCallback( - () => onExpandedChange(group.id, !expanded), - [expanded, group.id, onExpandedChange], - ); - const accessibilityState = useMemo(() => ({ expanded }), [expanded]); - const headerStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.header, - !isOverview && styles.headerConcise, - (pressed || hovered || expanded) && styles.headerActive, - ], - [expanded, isOverview], - ); - const summary = useMemo(() => { - const parts: string[] = []; - if (group.editedFileCount > 0) { - parts.push( - t( - group.editedFileCount === 1 - ? "toolCallGroup.editedFiles.one" - : "toolCallGroup.editedFiles.other", - { count: group.editedFileCount }, - ), - ); - } - if (group.commandCount > 0) { - parts.push( - t( - group.commandCount === 1 ? "toolCallGroup.commands.one" : "toolCallGroup.commands.other", - { count: group.commandCount }, - ), - ); - } - if (group.readFileCount > 0) { - parts.push( - t( - group.readFileCount === 1 - ? "toolCallGroup.readFiles.one" - : "toolCallGroup.readFiles.other", - { count: group.readFileCount }, - ), - ); - } - if (group.searchCount > 0) { - parts.push( - t(group.searchCount === 1 ? "toolCallGroup.searches.one" : "toolCallGroup.searches.other", { - count: group.searchCount, - }), - ); - } - if (group.otherToolCount > 0) { - parts.push( - t( - group.otherToolCount === 1 - ? "toolCallGroup.otherTools.one" - : "toolCallGroup.otherTools.other", - { count: group.otherToolCount }, - ), - ); - } - if (group.paseoCallCount > 0) { - parts.push( - t( - group.paseoCallCount === 1 - ? "toolCallGroup.paseoCalls.one" - : "toolCallGroup.paseoCalls.other", - { count: group.paseoCallCount }, - ), - ); - } - return joinSummaryParts(parts, t("toolCallGroup.and")); - }, [group, t]); - const accessibilityLabel = isOverview - ? summary - : t("toolCallGroup.accessibilityLabel", { count: group.callCount }); - - return ( - - - - - - {isOverview ? ( - - {summary} - - ) : ( - <> - {t("toolCallGroup.title")} - ×{group.callCount} - - )} - {group.failedCount > 0 ? ( - - {t("toolCallGroup.failed", { count: group.failedCount })} - - ) : null} - - - - {expanded ? {children} : null} - {!expanded && !isOverview ? ( - - {group.categories.map((category) => ( - - ))} - - ) : null} - - ); -}); - -const styles = StyleSheet.create((theme) => ({ - container: { - marginHorizontal: -theme.spacing[3], - }, - header: { - minHeight: 26, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.lg, - }, - headerActive: { - backgroundColor: theme.colors.surface1, - }, - headerConcise: { - minHeight: 30, - }, - headerIcon: { - width: 18, - height: 18, - alignItems: "center", - justifyContent: "center", - }, - summary: { - flex: 1, - minWidth: 0, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, - conciseTitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, - conciseCallCount: { - flex: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - categories: { - gap: theme.spacing[1], - paddingLeft: theme.spacing[8], - paddingRight: theme.spacing[2], - paddingTop: theme.spacing[1], - }, - categoryRow: { - minHeight: 20, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - categoryIcon: { - width: 14, - alignItems: "center", - }, - categoryLabel: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - minWidth: 64, - }, - categoryCount: { - width: theme.spacing[6], - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - textAlign: "right", - }, - categoryStatus: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - resources: { - flex: 1, - minWidth: 0, - color: theme.colors.foregroundMuted, - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - }, - expandedCalls: { - paddingTop: theme.spacing[1], - marginHorizontal: theme.spacing[3], - }, - chevronExpanded: { - transform: [{ rotate: "90deg" }], - }, - foreground: { - color: theme.colors.foreground, - }, - muted: { - color: theme.colors.foregroundMuted, - }, - error: { - color: theme.colors.destructive, - fontSize: theme.fontSize.xs, - }, -})); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index ea6ba574f..1869d8568 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -40,7 +40,6 @@ import { } from "@/components/ui/isolated-bottom-sheet-modal"; import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { isWeb, isNative } from "@/constants/platform"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; @@ -389,7 +388,6 @@ export function ContextMenuContent({ const { t } = useTranslation(); const context = useContextMenuContext("ContextMenuContent"); const { theme } = useUnistyles(); - const webScrollbarStyle = useWebScrollbarStyle(); const isMobile = useIsCompactFormFactor(); const useMobileSheet = isMobile && mobileMode === "sheet"; const { open, setOpen, triggerRef, anchorRect } = context; @@ -579,7 +577,6 @@ export function ContextMenuContent({ {children} diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 8bceebc34..cccafc03c 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -31,7 +31,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { isWeb } from "@/constants/platform"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; @@ -448,7 +447,6 @@ export function DropdownMenuContent({ useDropdownMenuContext("DropdownMenuContent"); const [modalVisible, setModalVisible] = useState(false); const surfaceNativeID = useId(); - const webScrollbarStyle = useWebScrollbarStyle(); const [closing, setClosing] = useState(false); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState(null); @@ -603,8 +601,8 @@ export function DropdownMenuContent({ align, ]); const scrollViewportStyle = useMemo( - () => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null], - [visibleContentSize, webScrollbarStyle], + () => [visibleContentSize ? { height: visibleContentSize.height } : null], + [visibleContentSize], ); if (!modalVisible) return null; diff --git a/packages/app/src/components/use-web-scrollbar.tsx b/packages/app/src/components/use-web-scrollbar.tsx deleted file mode 100644 index ab6e13e6e..000000000 --- a/packages/app/src/components/use-web-scrollbar.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useCallback, useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; -import { - type FlatList, - type LayoutChangeEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, - type ScrollView, -} from "react-native"; -import { - WebDesktopScrollbarOverlay, - useWebDesktopScrollbarMetrics, - type ScrollbarMetrics, -} from "./web-desktop-scrollbar"; -import { isWeb as platformIsWeb } from "@/constants/platform"; - -const METRICS_EPSILON = 0.5; -const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar"; - -function ensureHideScrollbarStyle(): void { - if (typeof document === "undefined") return; - if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return; - const style = document.createElement("style"); - style.id = HIDE_SCROLLBAR_STYLE_ID; - style.textContent = ` - [data-hide-scrollbar] { - scrollbar-width: none; - -ms-overflow-style: none; - scrollbar-gutter: auto; - } - - [data-hide-scrollbar]::-webkit-scrollbar { - display: none; - width: 0; - height: 0; - } - - [data-hide-scrollbar]::-webkit-scrollbar-button { - display: none; - width: 0; - height: 0; - } - `; - document.head.appendChild(style); -} - -function metricsChanged(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean { - return ( - Math.abs(a.offset - b.offset) > METRICS_EPSILON || - Math.abs(a.viewportSize - b.viewportSize) > METRICS_EPSILON || - Math.abs(a.contentSize - b.contentSize) > METRICS_EPSILON - ); -} - -// ── DOM element scrollbar ──────────────────────────────────────────── -// Fully automatic: listens to scroll/input/resize events on the element, -// hides the native scrollbar, and returns a themed overlay or null. - -export function useWebElementScrollbar( - elementRef: RefObject, - options?: { - enabled?: boolean; - contentRef?: RefObject; - }, -): ReactNode { - const enabled = (options?.enabled ?? true) && platformIsWeb; - const contentRef = options?.contentRef; - - const [metrics, setMetrics] = useState({ - offset: 0, - viewportSize: 0, - contentSize: 0, - }); - - useLayoutEffect(() => { - if (!enabled) return; - const element = elementRef.current; - if (!element) return; - - type ScrollbarStyle = CSSStyleDeclaration & { - scrollbarWidth: string; - msOverflowStyle: string; - scrollbarGutter: string; - }; - const style = element.style as ScrollbarStyle; - const previousScrollbarWidth = style.scrollbarWidth; - const previousMsOverflowStyle = style.msOverflowStyle; - const previousScrollbarGutter = style.scrollbarGutter; - - element.setAttribute("data-hide-scrollbar", ""); - style.scrollbarWidth = "none"; - style.msOverflowStyle = "none"; - style.scrollbarGutter = "auto"; - ensureHideScrollbarStyle(); - - function update() { - const el = elementRef.current; - if (!el) return; - const next: ScrollbarMetrics = { - offset: el.scrollTop, - viewportSize: el.clientHeight, - contentSize: el.scrollHeight, - }; - setMetrics((prev) => (metricsChanged(prev, next) ? next : prev)); - } - - element.addEventListener("scroll", update, { passive: true }); - - const resizeObserver = new ResizeObserver(update); - resizeObserver.observe(element); - const contentElement = contentRef?.current; - if (contentElement) { - resizeObserver.observe(contentElement); - } - - update(); - - return () => { - element.removeEventListener("scroll", update); - resizeObserver.disconnect(); - element.removeAttribute("data-hide-scrollbar"); - style.scrollbarWidth = previousScrollbarWidth; - style.msOverflowStyle = previousMsOverflowStyle; - style.scrollbarGutter = previousScrollbarGutter; - }; - }, [contentRef, elementRef, enabled]); - - const onScrollToOffset = useCallback( - (offset: number) => { - elementRef.current?.scrollTo({ top: offset, behavior: "auto" }); - }, - [elementRef], - ); - - if (!enabled) return null; - - return ( - - ); -} - -// ── RN ScrollView / FlatList scrollbar ─────────────────────────────── -// Returns event handlers to wire onto your ScrollView/FlatList plus -// a renderable overlay. The overlay is null when disabled. - -interface WebScrollViewScrollbar { - onScroll: (event: NativeSyntheticEvent) => void; - onLayout: (event: LayoutChangeEvent) => void; - onContentSizeChange: (width: number, height: number) => void; - overlay: ReactNode; -} - -export function useWebScrollViewScrollbar( - scrollableRef: RefObject, - options?: { enabled?: boolean }, -): WebScrollViewScrollbar { - const enabled = (options?.enabled ?? true) && platformIsWeb; - const metricsHook = useWebDesktopScrollbarMetrics(); - - const onScrollToOffset = useCallback( - (offset: number) => { - const scrollable = scrollableRef.current; - if (!scrollable) return; - if ("scrollToOffset" in scrollable) { - scrollable.scrollToOffset({ offset, animated: false }); - } else { - scrollable.scrollTo({ y: offset, animated: false }); - } - }, - [scrollableRef], - ); - - const overlay: ReactNode = enabled ? ( - - ) : null; - - return { - onScroll: metricsHook.onScroll, - onLayout: metricsHook.onLayout, - onContentSizeChange: metricsHook.onContentSizeChange, - overlay, - }; -} diff --git a/packages/app/src/components/web-desktop-scrollbar.math.ts b/packages/app/src/components/web-desktop-scrollbar.math.ts deleted file mode 100644 index cdf155d7a..000000000 --- a/packages/app/src/components/web-desktop-scrollbar.math.ts +++ /dev/null @@ -1,73 +0,0 @@ -const DEFAULT_MIN_HANDLE_SIZE = 36; - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -export interface VerticalScrollbarGeometryInput { - viewportSize: number; - contentSize: number; - offset: number; - minHandleSize?: number; -} - -export interface VerticalScrollbarGeometry { - isVisible: boolean; - maxScrollOffset: number; - handleSize: number; - handleOffset: number; - maxHandleOffset: number; -} - -export function computeVerticalScrollbarGeometry( - input: VerticalScrollbarGeometryInput, -): VerticalScrollbarGeometry { - const viewportSize = Number.isFinite(input.viewportSize) ? Math.max(0, input.viewportSize) : 0; - const contentSize = Number.isFinite(input.contentSize) ? Math.max(0, input.contentSize) : 0; - const minHandleSize = Number.isFinite(input.minHandleSize) - ? Math.max(0, input.minHandleSize ?? DEFAULT_MIN_HANDLE_SIZE) - : DEFAULT_MIN_HANDLE_SIZE; - - const maxScrollOffset = Math.max(0, contentSize - viewportSize); - if (maxScrollOffset <= 0 || viewportSize <= 0 || contentSize <= 0) { - return { - isVisible: false, - maxScrollOffset: 0, - handleSize: 0, - handleOffset: 0, - maxHandleOffset: 0, - }; - } - - const rawHandleSize = (viewportSize * viewportSize) / contentSize; - const handleSize = clamp(rawHandleSize, minHandleSize, viewportSize); - const maxHandleOffset = Math.max(0, viewportSize - handleSize); - const clampedOffset = clamp(input.offset, 0, maxScrollOffset); - const handleOffset = - maxScrollOffset > 0 ? (clampedOffset / maxScrollOffset) * maxHandleOffset : 0; - - return { - isVisible: true, - maxScrollOffset, - handleSize, - handleOffset, - maxHandleOffset, - }; -} - -export interface ScrollOffsetFromDragDeltaInput { - startOffset: number; - dragDelta: number; - maxScrollOffset: number; - maxHandleOffset: number; -} - -export function computeScrollOffsetFromDragDelta(input: ScrollOffsetFromDragDeltaInput): number { - if (input.maxScrollOffset <= 0 || input.maxHandleOffset <= 0) { - return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset)); - } - - const scrollPerPixel = input.maxScrollOffset / input.maxHandleOffset; - const nextOffset = input.startOffset + input.dragDelta * scrollPerPixel; - return clamp(nextOffset, 0, input.maxScrollOffset); -} diff --git a/packages/app/src/components/web-desktop-scrollbar.test.ts b/packages/app/src/components/web-desktop-scrollbar.test.ts deleted file mode 100644 index 91bcafbab..000000000 --- a/packages/app/src/components/web-desktop-scrollbar.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - computeScrollOffsetFromDragDelta, - computeVerticalScrollbarGeometry, -} from "./web-desktop-scrollbar.math"; - -describe("computeVerticalScrollbarGeometry", () => { - it("returns hidden geometry when content does not overflow", () => { - const geometry = computeVerticalScrollbarGeometry({ - viewportSize: 500, - contentSize: 500, - offset: 0, - minHandleSize: 36, - }); - - expect(geometry).toEqual({ - isVisible: false, - maxScrollOffset: 0, - handleSize: 0, - handleOffset: 0, - maxHandleOffset: 0, - }); - }); - - it("computes visible geometry when content overflows", () => { - const geometry = computeVerticalScrollbarGeometry({ - viewportSize: 500, - contentSize: 2000, - offset: 375, - minHandleSize: 36, - }); - - expect(geometry).toEqual({ - isVisible: true, - maxScrollOffset: 1500, - handleSize: 125, - handleOffset: 93.75, - maxHandleOffset: 375, - }); - }); - - it("clamps handle size to min and offset to bounds", () => { - const geometry = computeVerticalScrollbarGeometry({ - viewportSize: 100, - contentSize: 10000, - offset: 99999, - minHandleSize: 24, - }); - - expect(geometry).toEqual({ - isVisible: true, - maxScrollOffset: 9900, - handleSize: 24, - handleOffset: 76, - maxHandleOffset: 76, - }); - }); -}); - -describe("computeScrollOffsetFromDragDelta", () => { - it("maps drag distance proportionally to scroll offset", () => { - const nextOffset = computeScrollOffsetFromDragDelta({ - startOffset: 250, - dragDelta: 50, - maxScrollOffset: 1000, - maxHandleOffset: 200, - }); - - expect(nextOffset).toBe(500); - }); - - it("clamps to scroll bounds", () => { - const nextOffset = computeScrollOffsetFromDragDelta({ - startOffset: 900, - dragDelta: 1000, - maxScrollOffset: 1000, - maxHandleOffset: 200, - }); - - expect(nextOffset).toBe(1000); - }); -}); diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx deleted file mode 100644 index caff521bc..000000000 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ /dev/null @@ -1,464 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - PanResponder, - type GestureResponderEvent, - type LayoutChangeEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, - type ViewStyle, - View, -} from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { isWeb as platformIsWeb } from "@/constants/platform"; -import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; -import { - computeScrollOffsetFromDragDelta, - computeVerticalScrollbarGeometry, -} from "./web-desktop-scrollbar.math"; - -const METRICS_EPSILON = 0.5; -const HANDLE_WIDTH_IDLE = 6; -const HANDLE_WIDTH_ACTIVE = 9; -const HANDLE_GRAB_WIDTH = 18; -const HANDLE_GRAB_VERTICAL_PADDING = 8; -const HANDLE_OPACITY_VISIBLE = 0.62; -const HANDLE_OPACITY_HOVERED = 0.78; -const HANDLE_OPACITY_DRAGGING = 0.9; -const HANDLE_TRAVEL_TRANSITION_DURATION_MS = 90; -const HANDLE_FADE_DURATION_MS = 220; -const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240; -const HANDLE_SCROLL_VISIBILITY_MS = 1200; -const HANDLE_SCROLL_ACTIVE_MS = 110; - -interface WebPointerStyle { - cursor?: "grab" | "grabbing"; - touchAction?: "none"; - userSelect?: "none"; - transitionProperty?: string; - transitionDuration?: string; - transitionTimingFunction?: string; -} - -interface PointerLikeEvent { - clientY?: number; - pageY?: number; - nativeEvent?: { clientY?: number; pageY?: number; preventDefault?: () => void }; - preventDefault?: () => void; - stopPropagation?: () => void; -} - -function readClientY(event: PointerLikeEvent): number | null { - const value = - event?.nativeEvent?.clientY ?? event?.clientY ?? event?.nativeEvent?.pageY ?? event?.pageY; - return typeof value === "number" ? value : null; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -export interface ScrollbarMetrics { - offset: number; - viewportSize: number; - contentSize: number; -} - -function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean { - return ( - Math.abs(a.offset - b.offset) <= METRICS_EPSILON && - Math.abs(a.viewportSize - b.viewportSize) <= METRICS_EPSILON && - Math.abs(a.contentSize - b.contentSize) <= METRICS_EPSILON - ); -} - -interface WebDesktopScrollbarOverlayProps { - enabled: boolean; - metrics: ScrollbarMetrics; - onScrollToOffset: (offset: number) => void; - inverted?: boolean; -} - -export function useWebDesktopScrollbarMetrics() { - const [metrics, setMetrics] = useState({ - offset: 0, - viewportSize: 0, - contentSize: 0, - }); - - const setMetricsIfChanged = useCallback((next: ScrollbarMetrics) => { - setMetrics((previous) => (areMetricsEqual(previous, next) ? previous : next)); - }, []); - - const onScroll = useCallback( - (event: NativeSyntheticEvent) => { - const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent; - setMetricsIfChanged({ - offset: Math.max(0, contentOffset.y), - viewportSize: Math.max(0, layoutMeasurement.height), - contentSize: Math.max(0, contentSize.height), - }); - }, - [setMetricsIfChanged], - ); - - const onLayout = useCallback((event: LayoutChangeEvent) => { - const viewportSize = Math.max(0, event.nativeEvent.layout.height); - setMetrics((previous) => { - const next = { ...previous, viewportSize }; - return areMetricsEqual(previous, next) ? previous : next; - }); - }, []); - - const onContentSizeChange = useCallback((_width: number, height: number) => { - const contentSize = Math.max(0, height); - setMetrics((previous) => { - const next = { ...previous, contentSize }; - return areMetricsEqual(previous, next) ? previous : next; - }); - }, []); - - const setOffset = useCallback((offset: number) => { - const clampedOffset = Math.max(0, offset); - setMetrics((previous) => { - const next = { ...previous, offset: clampedOffset }; - return areMetricsEqual(previous, next) ? previous : next; - }); - }, []); - - return { - ...metrics, - onScroll, - onLayout, - onContentSizeChange, - setOffset, - }; -} - -export function WebDesktopScrollbarOverlay({ - enabled, - metrics, - onScrollToOffset, - inverted = false, -}: WebDesktopScrollbarOverlayProps) { - const { theme } = useUnistyles(); - const [isHandleHovered, setIsHandleHovered] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [isScrollVisible, setIsScrollVisible] = useState(false); - const [isScrollActive, setIsScrollActive] = useState(false); - const dragStartOffsetRef = useRef(0); - const dragStartClientYRef = useRef(0); - const scrollVisibilityTimeoutRef = useRef | null>(null); - const scrollActiveTimeoutRef = useRef | null>(null); - const lastObservedOffsetRef = useRef(null); - const geometryRef = useRef({ - maxHandleOffset: 0, - maxScrollOffset: 0, - }); - const onScrollToOffsetRef = useRef(onScrollToOffset); - - const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize); - const normalizedOffset = inverted - ? Math.max(0, maxScrollOffset - clamp(metrics.offset, 0, maxScrollOffset)) - : clamp(metrics.offset, 0, maxScrollOffset); - const normalizedOffsetRef = useRef(normalizedOffset); - - const geometry = useMemo( - () => - computeVerticalScrollbarGeometry({ - viewportSize: metrics.viewportSize, - contentSize: metrics.contentSize, - offset: normalizedOffset, - }), - [metrics.contentSize, metrics.viewportSize, normalizedOffset], - ); - - useEffect(() => { - geometryRef.current = { - maxHandleOffset: geometry.maxHandleOffset, - maxScrollOffset: geometry.maxScrollOffset, - }; - }, [geometry.maxHandleOffset, geometry.maxScrollOffset]); - - useEffect(() => { - onScrollToOffsetRef.current = onScrollToOffset; - }, [onScrollToOffset]); - - useEffect(() => { - normalizedOffsetRef.current = normalizedOffset; - }, [normalizedOffset]); - - const clearScrollVisibilityTimeout = useCallback(() => { - if (scrollVisibilityTimeoutRef.current === null) { - return; - } - clearTimeout(scrollVisibilityTimeoutRef.current); - scrollVisibilityTimeoutRef.current = null; - }, []); - - const clearScrollActiveTimeout = useCallback(() => { - if (scrollActiveTimeoutRef.current === null) { - return; - } - clearTimeout(scrollActiveTimeoutRef.current); - scrollActiveTimeoutRef.current = null; - }, []); - - const revealScrollbarFromScroll = useCallback(() => { - setIsScrollVisible(true); - clearScrollVisibilityTimeout(); - scrollVisibilityTimeoutRef.current = setTimeout(() => { - setIsScrollVisible(false); - scrollVisibilityTimeoutRef.current = null; - }, HANDLE_SCROLL_VISIBILITY_MS); - }, [clearScrollVisibilityTimeout]); - - const markScrollActivity = useCallback(() => { - setIsScrollActive(true); - clearScrollActiveTimeout(); - scrollActiveTimeoutRef.current = setTimeout(() => { - setIsScrollActive(false); - scrollActiveTimeoutRef.current = null; - }, HANDLE_SCROLL_ACTIVE_MS); - }, [clearScrollActiveTimeout]); - - useEffect(() => { - if (!enabled || !geometry.isVisible) { - setIsScrollVisible(false); - setIsScrollActive(false); - clearScrollVisibilityTimeout(); - clearScrollActiveTimeout(); - lastObservedOffsetRef.current = null; - return; - } - - const previousOffset = lastObservedOffsetRef.current; - lastObservedOffsetRef.current = normalizedOffset; - if (previousOffset === null) { - return; - } - if (Math.abs(normalizedOffset - previousOffset) <= METRICS_EPSILON) { - return; - } - revealScrollbarFromScroll(); - markScrollActivity(); - }, [ - clearScrollActiveTimeout, - clearScrollVisibilityTimeout, - enabled, - geometry.isVisible, - markScrollActivity, - normalizedOffset, - revealScrollbarFromScroll, - ]); - - useEffect( - () => () => { - clearScrollActiveTimeout(); - clearScrollVisibilityTimeout(); - }, - [clearScrollActiveTimeout, clearScrollVisibilityTimeout], - ); - - const applyDragDelta = useCallback( - (dragDelta: number) => { - const currentGeometry = geometryRef.current; - const nextNormalizedOffset = computeScrollOffsetFromDragDelta({ - startOffset: dragStartOffsetRef.current, - dragDelta, - maxScrollOffset: currentGeometry.maxScrollOffset, - maxHandleOffset: currentGeometry.maxHandleOffset, - }); - const nextOffset = inverted - ? currentGeometry.maxScrollOffset - nextNormalizedOffset - : nextNormalizedOffset; - onScrollToOffsetRef.current(nextOffset); - }, - [inverted], - ); - - const panResponder = useMemo(() => { - if (platformIsWeb) { - return null; - } - - return PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onMoveShouldSetPanResponder: () => true, - onPanResponderTerminationRequest: () => false, - onPanResponderGrant: (event: GestureResponderEvent) => { - const clientY = readClientY(event); - dragStartOffsetRef.current = normalizedOffsetRef.current; - if (clientY !== null) { - dragStartClientYRef.current = clientY; - } - setIsDragging(true); - }, - onPanResponderMove: (_event, gestureState) => { - applyDragDelta(gestureState.dy); - }, - onPanResponderRelease: () => { - setIsDragging(false); - }, - onPanResponderTerminate: () => { - setIsDragging(false); - }, - }); - }, [applyDragDelta]); - - const startWebDrag = useCallback((event: PointerLikeEvent) => { - if (!platformIsWeb) { - return; - } - const clientY = readClientY(event); - if (clientY === null) { - return; - } - event?.preventDefault?.(); - event?.stopPropagation?.(); - event?.nativeEvent?.preventDefault?.(); - dragStartOffsetRef.current = normalizedOffsetRef.current; - dragStartClientYRef.current = clientY; - setIsDragging(true); - }, []); - - const handleGrabHoverIn = useCallback(() => { - if (!isScrollVisible && !isDragging) { - return; - } - setIsHandleHovered(true); - }, [isDragging, isScrollVisible]); - - const handleGrabHoverOut = useCallback(() => { - setIsHandleHovered(false); - }, []); - - useEffect(() => { - if (!platformIsWeb || !isDragging) { - return; - } - - const handlePointerMove = (event: PointerEvent) => { - const dragDelta = event.clientY - dragStartClientYRef.current; - applyDragDelta(dragDelta); - }; - - const stopDragging = () => { - setIsDragging(false); - }; - - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", stopDragging); - window.addEventListener("pointercancel", stopDragging); - - return () => { - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", stopDragging); - window.removeEventListener("pointercancel", stopDragging); - }; - }, [applyDragDelta, isDragging]); - - const handleVisible = isDragging || isScrollVisible || isHandleHovered; - let handleOpacity: number; - if (isDragging) handleOpacity = HANDLE_OPACITY_DRAGGING; - else if (isHandleHovered) handleOpacity = HANDLE_OPACITY_HOVERED; - else if (isScrollVisible) handleOpacity = HANDLE_OPACITY_VISIBLE; - else handleOpacity = 0; - const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE; - const handleColor = theme.colors.scrollbarHandle; - const handleCursor = isDragging ? "grabbing" : "grab"; - const handleTravelDurationMs = - isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS; - const thumbRegionOffset = Math.max(0, geometry.handleOffset - HANDLE_GRAB_VERTICAL_PADDING); - const thumbRegionHeight = Math.min( - metrics.viewportSize - thumbRegionOffset, - geometry.handleSize + HANDLE_GRAB_VERTICAL_PADDING * 2, - ); - const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2); - - const thumbRegionStyle = useMemo( - () => [ - styles.thumbRegion, - inlineUnistylesStyle({ - height: thumbRegionHeight, - transform: [{ translateY: thumbRegionOffset }], - }), - platformIsWeb && - inlineUnistylesStyle({ - cursor: handleCursor, - touchAction: "none", - userSelect: "none", - transitionProperty: "transform", - transitionDuration: `${handleTravelDurationMs}ms`, - transitionTimingFunction: "linear", - } satisfies WebPointerStyle as unknown as ViewStyle), - ], - [thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs], - ); - - const handleStyle = useMemo( - () => [ - styles.handle, - inlineUnistylesStyle({ - marginTop: handleInsetTop, - height: geometry.handleSize, - width: handleWidth, - backgroundColor: handleColor, - opacity: handleOpacity, - }), - platformIsWeb && - inlineUnistylesStyle({ - transitionProperty: "opacity, width, background-color", - transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, - transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", - } satisfies WebPointerStyle as unknown as ViewStyle), - ], - [handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity], - ); - - if (!enabled || !geometry.isVisible) { - return null; - } - - return ( - - - - - - ); -} - -const styles = StyleSheet.create(() => ({ - overlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - width: 12, - alignItems: "center", - justifyContent: "flex-start", - zIndex: 10, - }, - handle: { - width: HANDLE_WIDTH_IDLE, - borderRadius: 999, - alignSelf: "center", - }, - thumbRegion: { - position: "absolute", - right: -3, - width: HANDLE_GRAB_WIDTH, - top: 0, - }, -})); diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 73dd7b1a9..4c68a2c40 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -51,7 +51,6 @@ import { } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; -import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; @@ -1616,10 +1615,6 @@ export const MessageInput = forwardRef( } }, [getWebTextArea]); - const inputScrollbar = useWebElementScrollbar(webTextareaRef, { - enabled: isWeb, - }); - usePasteImagesEffect({ getWebTextArea, isConnected, @@ -1842,7 +1837,6 @@ export const MessageInput = forwardRef( onSelectionChange={handleSelectionChange} autoFocus={isWeb && autoFocus} /> - {inputScrollbar} | null>; handleDiffListLayout: (event: LayoutChangeEvent) => void; handleDiffListScroll: (event: NativeSyntheticEvent) => void; - onContentSizeChange: (width: number, height: number) => void; - showDesktopWebScrollbar: boolean; checkingRepositoryLabel: string; notRepositoryLabel: string; } @@ -1559,8 +1556,6 @@ function DiffBodyContent({ diffListRef, handleDiffListLayout, handleDiffListScroll, - onContentSizeChange, - showDesktopWebScrollbar, checkingRepositoryLabel, notRepositoryLabel, }: DiffBodyContentProps) { @@ -1621,9 +1616,8 @@ function DiffBodyContent({ testID="git-diff-scroll" onLayout={handleDiffListLayout} onScroll={handleDiffListScroll} - onContentSizeChange={onContentSizeChange} scrollEventThrottle={16} - showsVerticalScrollIndicator={!showDesktopWebScrollbar} + showsVerticalScrollIndicator // Mixed-height rows (header + potentially very large body) are prone to clipping artifacts. // Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll. removeClippedSubviews={false} @@ -1737,7 +1731,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane const { settings: appSettings } = useAppSettings(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); - const showDesktopWebScrollbar = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile; const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = useChangesPreferences(); @@ -1963,9 +1956,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane } void updateChangesPreferences({ viewMode: nextViewMode }); }, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]); - const scrollbar = useWebScrollViewScrollbar(diffListRef, { - enabled: showDesktopWebScrollbar, - }); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); @@ -2089,25 +2079,17 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane [getBodyHeightKey], ); - const handleDiffListScroll = useCallback( - (event: NativeSyntheticEvent) => { - diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; - scrollbar.onScroll(event); - }, - [scrollbar], - ); + const handleDiffListScroll = useCallback((event: NativeSyntheticEvent) => { + diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, []); - const handleDiffListLayout = useCallback( - (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height; - if (!Number.isFinite(height) || height <= 0) { - return; - } - diffListViewportHeightRef.current = height; - scrollbar.onLayout(event); - }, - [scrollbar], - ); + const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height; + if (!Number.isFinite(height) || height <= 0) { + return; + } + diffListViewportHeightRef.current = height; + }, []); // Offset of the first item matching `predicate`, walking the SAME flatItems // list getFlatItemLayout uses so folder rows are counted (single source of @@ -2379,8 +2361,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane diffListRef={diffListRef} handleDiffListLayout={handleDiffListLayout} handleDiffListScroll={handleDiffListScroll} - onContentSizeChange={scrollbar.onContentSizeChange} - showDesktopWebScrollbar={showDesktopWebScrollbar} checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")} notRepositoryLabel={t("workspace.git.diff.notRepository")} /> @@ -2479,10 +2459,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane {prErrorMessage ? {prErrorMessage} : null} - - {bodyContent} - {hasChanges ? scrollbar.overlay : null} - + {bodyContent} ); } diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 38b3f84da..a864afc76 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -341,14 +341,14 @@ describe("appearance settings", () => { expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); }); - it("loads an explicit tool call detail level", async () => { + it("maps an unrecognized tool call detail level to overview", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ - [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "concise" }), + [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("concise"); + expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); }); it("clamps the UI font size into range and rejects non-numeric values", async () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index dc982aef7..808665f3e 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -12,16 +12,12 @@ export type SendBehavior = "interrupt" | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; export type WorkspaceTitleSource = "title" | "branch"; -export type ToolCallDetailLevel = "overview" | "concise" | "detailed"; +export type ToolCallDetailLevel = "overview" | "detailed"; const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set(["title", "branch"]); -const VALID_TOOL_CALL_DETAIL_LEVELS = new Set([ - "overview", - "concise", - "detailed", -]); +const VALID_TOOL_CALL_DETAIL_LEVELS = new Set(["overview", "detailed"]); export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; export const MIN_TERMINAL_SCROLLBACK_LINES = 0; export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; @@ -172,11 +168,16 @@ export function normalizeAppSettings(value: unknown): AppSettings { } function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null { - if ( - typeof stored.toolCallDetailLevel === "string" && - VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel) - ) { - return stored.toolCallDetailLevel; + if (stored.toolCallDetailLevel !== undefined) { + if ( + typeof stored.toolCallDetailLevel === "string" && + VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel) + ) { + return stored.toolCallDetailLevel; + } + // COMPAT(toolCallDetailLevelConcise): removed in v0.1.107; legacy "concise" values + // deliberately follow the unknown-value fallback. Remove after 2027-01-14. + return "overview"; } if (typeof stored.compactToolCalls === "boolean") { // COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12. diff --git a/packages/app/src/hooks/use-web-scrollbar-style.d.ts b/packages/app/src/hooks/use-web-scrollbar-style.d.ts deleted file mode 100644 index afafdb944..000000000 --- a/packages/app/src/hooks/use-web-scrollbar-style.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-web-scrollbar-style.web"; diff --git a/packages/app/src/hooks/use-web-scrollbar-style.native.ts b/packages/app/src/hooks/use-web-scrollbar-style.native.ts deleted file mode 100644 index d2f336ed5..000000000 --- a/packages/app/src/hooks/use-web-scrollbar-style.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { ViewStyle } from "react-native"; - -export function useWebScrollbarStyle(): ViewStyle | undefined { - return undefined; -} diff --git a/packages/app/src/hooks/use-web-scrollbar-style.web.ts b/packages/app/src/hooks/use-web-scrollbar-style.web.ts deleted file mode 100644 index 38d22c910..000000000 --- a/packages/app/src/hooks/use-web-scrollbar-style.web.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMemo } from "react"; -import type { ViewStyle } from "react-native"; -import { useUnistyles } from "react-native-unistyles"; - -// CSS scrollbar properties are supported by React Native Web at runtime -// but are not included in React Native's ViewStyle type definition. -interface WebScrollbarStyle extends ViewStyle { - scrollbarColor: string; - scrollbarWidth: string; -} - -export function useWebScrollbarStyle(): WebScrollbarStyle { - const { theme } = useUnistyles(); - return useMemo( - (): WebScrollbarStyle => ({ - scrollbarColor: `${theme.colors.scrollbarHandle} transparent`, - scrollbarWidth: "thin", - }), - [theme.colors.scrollbarHandle], - ); -} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 82e47079c..52920c2bc 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1393,8 +1393,6 @@ export const ar: TranslationResources = { output: "الإخراج", }, toolCallGroup: { - title: "الأدوات", - accessibilityLabel: "الأدوات، {{count}} استدعاءات", editedFiles: { one: "حرّر {{count}} ملفًا", other: "حرّر {{count}} ملفات", @@ -1520,13 +1518,12 @@ export const ar: TranslationResources = { description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي", }, toolCallDetail: { - label: "تفاصيل استدعاءات الأدوات", - description: "كيفية ظهور نشاط الأدوات في الخط الزمني للوكيل", - accessibilityLabel: "حدد مستوى تفاصيل الأدوات ({{value}})", + label: "عرض استدعاءات الأدوات", + description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني", + accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})", options: { - overview: "نظرة عامة", - concise: "موجز", - detailed: "مفصل", + overview: "ملخص", + detailed: "التفاصيل الكاملة", }, }, language: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 7f39945c1..cfe4612cb 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1401,8 +1401,6 @@ export const en = { output: "Output", }, toolCallGroup: { - title: "Tools", - accessibilityLabel: "Tools, {{count}} calls", editedFiles: { one: "edited {{count}} file", other: "edited {{count}} files", @@ -1527,13 +1525,12 @@ export const en = { description: "Show agent thinking and chain-of-thought blocks fully expanded by default", }, toolCallDetail: { - label: "Tool call detail", - description: "How tool activity appears in agent timelines", - accessibilityLabel: "Select tool call detail ({{value}})", + label: "Tool call display", + description: "How tool calls appear in the timeline", + accessibilityLabel: "Select tool call display ({{value}})", options: { - overview: "Overview", - concise: "Concise", - detailed: "Detailed", + overview: "Summary", + detailed: "Full detail", }, }, language: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 159fce658..1172b96ca 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1432,8 +1432,6 @@ export const es: TranslationResources = { output: "Producción", }, toolCallGroup: { - title: "Herramientas", - accessibilityLabel: "Herramientas, {{count}} llamadas", editedFiles: { one: "editó {{count}} archivo", other: "editó {{count}} archivos", @@ -1561,13 +1559,12 @@ export const es: TranslationResources = { "Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada", }, toolCallDetail: { - label: "Detalle de llamadas a herramientas", - description: "Cómo aparece la actividad de herramientas en las cronologías del agente", - accessibilityLabel: "Seleccionar detalle de herramientas ({{value}})", + label: "Visualización de llamadas a herramientas", + description: "Cómo aparecen las llamadas a herramientas en la cronología", + accessibilityLabel: "Seleccionar visualización de llamadas a herramientas ({{value}})", options: { overview: "Resumen", - concise: "Conciso", - detailed: "Detallado", + detailed: "Detalle completo", }, }, language: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index cbbbbe05d..9a8b32c4e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1435,8 +1435,6 @@ export const fr: TranslationResources = { output: "Sortir", }, toolCallGroup: { - title: "Outils", - accessibilityLabel: "Outils, {{count}} appels", editedFiles: { one: "a modifié {{count}} fichier", other: "a modifié {{count}} fichiers", @@ -1563,13 +1561,12 @@ export const fr: TranslationResources = { description: "Afficher le raisonnement de l'agent entièrement développé par défaut", }, toolCallDetail: { - label: "Détail des appels d’outils", - description: "Affichage de l’activité des outils dans la chronologie de l’agent", - accessibilityLabel: "Sélectionner le détail des outils ({{value}})", + label: "Affichage des appels d’outils", + description: "Comment les appels d’outils apparaissent dans la chronologie", + accessibilityLabel: "Sélectionner l’affichage des appels d’outils ({{value}})", options: { - overview: "Vue d’ensemble", - concise: "Concis", - detailed: "Détaillé", + overview: "Résumé", + detailed: "Détails complets", }, }, language: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 823f7d0e8..035970583 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1410,8 +1410,6 @@ export const ja: TranslationResources = { output: "出力", }, toolCallGroup: { - title: "ツール", - accessibilityLabel: "ツール、{{count}}件の呼び出し", editedFiles: { one: "{{count}}個のファイルを編集", other: "{{count}}個のファイルを編集", @@ -1536,13 +1534,12 @@ export const ja: TranslationResources = { description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します", }, toolCallDetail: { - label: "ツール呼び出しの詳細", - description: "エージェントのタイムラインでのツール活動の表示方法", - accessibilityLabel: "ツール詳細を選択({{value}})", + label: "ツール呼び出しの表示", + description: "タイムラインでのツール呼び出しの表示方法", + accessibilityLabel: "ツール呼び出しの表示を選択({{value}})", options: { - overview: "概要", - concise: "簡潔", - detailed: "詳細", + overview: "要約", + detailed: "すべての詳細", }, }, language: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 995896fc7..8966fe0cc 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1418,8 +1418,6 @@ export const ptBR: TranslationResources = { output: "Saída", }, toolCallGroup: { - title: "Ferramentas", - accessibilityLabel: "Ferramentas, {{count}} chamadas", editedFiles: { one: "editou {{count}} arquivo", other: "editou {{count}} arquivos", @@ -1546,13 +1544,12 @@ export const ptBR: TranslationResources = { "Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão", }, toolCallDetail: { - label: "Detalhe das chamadas de ferramentas", - description: "Como a atividade das ferramentas aparece na linha do tempo do agente", - accessibilityLabel: "Selecionar detalhe das ferramentas ({{value}})", + label: "Exibição de chamadas de ferramentas", + description: "Como as chamadas de ferramentas aparecem na linha do tempo", + accessibilityLabel: "Selecionar exibição de chamadas de ferramentas ({{value}})", options: { - overview: "Visão geral", - concise: "Conciso", - detailed: "Detalhado", + overview: "Resumo", + detailed: "Detalhes completos", }, }, language: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index fb6affc64..bd1ea0172 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1424,8 +1424,6 @@ export const ru: TranslationResources = { output: "Выход", }, toolCallGroup: { - title: "Инструменты", - accessibilityLabel: "Инструменты, вызовов: {{count}}", editedFiles: { one: "изменён {{count}} файл", other: "изменено {{count}} файлов", @@ -1551,13 +1549,12 @@ export const ru: TranslationResources = { "По умолчанию показывать блоки размышлений и логики агента полностью развернутыми", }, toolCallDetail: { - label: "Детализация вызовов инструментов", - description: "Отображение активности инструментов в хронологии агента", - accessibilityLabel: "Выбрать детализацию инструментов ({{value}})", + label: "Отображение вызовов инструментов", + description: "Как вызовы инструментов отображаются на временной шкале", + accessibilityLabel: "Выбрать отображение вызовов инструментов ({{value}})", options: { - overview: "Обзор", - concise: "Кратко", - detailed: "Подробно", + overview: "Сводка", + detailed: "Полная детализация", }, }, language: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6dd094490..067b10304 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1377,8 +1377,6 @@ export const zhCN: TranslationResources = { output: "输出", }, toolCallGroup: { - title: "工具", - accessibilityLabel: "工具,{{count}} 次调用", editedFiles: { one: "编辑了 {{count}} 个文件", other: "编辑了 {{count}} 个文件", @@ -1503,13 +1501,12 @@ export const zhCN: TranslationResources = { description: "默认情况下完全展开 AI 的思考和推理过程", }, toolCallDetail: { - label: "工具调用详情", - description: "工具活动在智能体时间线中的显示方式", - accessibilityLabel: "选择工具调用详情({{value}})", + label: "工具调用显示", + description: "工具调用在时间线中的显示方式", + accessibilityLabel: "选择工具调用显示方式({{value}})", options: { - overview: "概览", - concise: "简洁", - detailed: "详细", + overview: "摘要", + detailed: "完整详情", }, }, language: { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 33347130c..b5e2bf16e 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -103,7 +103,6 @@ import { type EnableBuiltInDaemonOption, useEnableBuiltInDaemonOption, } from "@/desktop/hooks/use-enable-built-in-daemon-option"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { buildOpenProjectRoute, buildProjectsSettingsRoute, @@ -1134,11 +1133,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const isCompactLayout = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); const insetBottomStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]); - const webScrollbarStyle = useWebScrollbarStyle(); - const scrollViewStyle = useMemo( - () => [styles.scrollView, webScrollbarStyle], - [webScrollbarStyle], - ); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); @@ -1469,7 +1463,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti return ( - + - + {content} {addHostModals} @@ -1547,7 +1541,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti } leftStyle={desktopStyles.detailLeft} /> - + {content} diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 9a0162406..4ae7b2886 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -214,9 +214,8 @@ function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps const TOOL_CALL_DETAIL_ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ - "overview", - "concise", "detailed", + "overview", ]; function getToolCallDetailLevelLabel( diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index fdb5d6ce9..abf73797f 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -20,7 +20,6 @@ import { Button } from "@/components/ui/button"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { isNative, isWeb } from "@/constants/platform"; -import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; interface StartupSplashScreenProps { @@ -300,15 +299,6 @@ const styles = StyleSheet.create((theme) => ({ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); - const webScrollbarStyle = useWebScrollbarStyle(); - const errorScrollViewStyle = useMemo( - () => [styles.errorScrollView, webScrollbarStyle], - [webScrollbarStyle], - ); - const logsScrollStyle = useMemo( - () => [styles.logsScroll, webScrollbarStyle], - [webScrollbarStyle], - ); const [daemonLogs, setDaemonLogs] = useState(null); const [logsError, setLogsError] = useState(null); const [isLoadingLogs, setIsLoadingLogs] = useState(false); @@ -404,7 +394,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps @@ -424,7 +414,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps diff --git a/packages/app/src/styles/install-web-scrollbar-styles.ts b/packages/app/src/styles/install-web-scrollbar-styles.ts new file mode 100644 index 000000000..fe7cf9289 --- /dev/null +++ b/packages/app/src/styles/install-web-scrollbar-styles.ts @@ -0,0 +1,3 @@ +export function installWebScrollbarStyles(): () => void { + return () => {}; +} diff --git a/packages/app/src/styles/install-web-scrollbar-styles.web.ts b/packages/app/src/styles/install-web-scrollbar-styles.web.ts new file mode 100644 index 000000000..18652c7f4 --- /dev/null +++ b/packages/app/src/styles/install-web-scrollbar-styles.web.ts @@ -0,0 +1,43 @@ +import { + WEB_SCROLLBAR_SIZE_PX, + webScrollbarColor, + webScrollbarThumbColor, + WEB_SCROLLBAR_WIDTH, +} from "@/styles/web-scrollbar"; + +const STYLE_ID = "paseo-web-scrollbar-styles"; + +export function installWebScrollbarStyles(): () => void { + const existingStyle = document.getElementById(STYLE_ID); + if (existingStyle) return () => {}; + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +* { + scrollbar-color: ${webScrollbarColor("var(--colors-scrollbar-handle)")}; + scrollbar-width: ${WEB_SCROLLBAR_WIDTH}; +} + +*::-webkit-scrollbar { + width: ${WEB_SCROLLBAR_SIZE_PX}px; + height: ${WEB_SCROLLBAR_SIZE_PX}px; + background: transparent; +} + +*::-webkit-scrollbar-track, +*::-webkit-scrollbar-corner { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background: ${webScrollbarThumbColor("var(--colors-scrollbar-handle)")}; + background-clip: content-box; +} +`; + document.head.append(style); + + return () => style.remove(); +} diff --git a/packages/app/src/styles/web-scrollbar.ts b/packages/app/src/styles/web-scrollbar.ts new file mode 100644 index 000000000..1fd4fbd80 --- /dev/null +++ b/packages/app/src/styles/web-scrollbar.ts @@ -0,0 +1,10 @@ +export const WEB_SCROLLBAR_WIDTH = "thin"; +export const WEB_SCROLLBAR_SIZE_PX = 8; + +export function webScrollbarThumbColor(handleColor: string): string { + return `color-mix(in srgb, ${handleColor} 62%, transparent)`; +} + +export function webScrollbarColor(handleColor: string): string { + return `${webScrollbarThumbColor(handleColor)} transparent`; +} diff --git a/packages/app/src/tool-calls/detail-level/grouping.ts b/packages/app/src/tool-calls/detail-level/grouping.ts new file mode 100644 index 000000000..079158b2d --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/grouping.ts @@ -0,0 +1,228 @@ +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; +import type { StreamItem, ToolCallItem } from "@/types/stream"; + +export interface ToolCallDescriptor { + detail: ToolCallDetail; + name: string; + status: "executing" | "running" | "completed" | "failed" | "canceled"; + error: unknown; + metadata?: Record; +} + +export interface ToolCallRun { + id: string; + calls: readonly ToolCallItem[]; + latest: ToolCallItem; + isSealed: boolean; +} + +export interface GroupedHistory { + tail: StreamItem[]; + groupsByHostId: Map; + pendingCalls: readonly ToolCallItem[]; +} + +export interface GroupedToolCalls { + tail: StreamItem[]; + head: StreamItem[]; + groupsByHostId: ToolCallGroupLookup; + historyGroupUpdatesByHostId: ToolCallGroupLookup; +} + +export interface ToolCallGroupLookup { + readonly size: number; + get(id: string): TGroup | undefined; + has(id: string): boolean; +} + +const EMPTY_GROUPS = new Map(); + +export function describeToolCall(item: ToolCallItem): ToolCallDescriptor { + if (item.payload.source === "agent") { + const { data } = item.payload; + return { + detail: data.detail, + name: data.name, + status: data.status, + error: data.error, + metadata: data.metadata, + }; + } + + const { data } = item.payload; + return { + detail: { + type: "unknown", + input: data.arguments ?? null, + output: data.result ?? null, + }, + name: data.toolName, + status: data.status, + error: data.error, + }; +} + +export function isGroupableToolCall(item: StreamItem): item is ToolCallItem { + if (item.kind !== "tool_call") { + return false; + } + const descriptor = describeToolCall(item); + return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; +} + +function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun { + const first = calls[0]; + const latest = calls.at(-1); + if (!first || !latest) { + throw new Error("Cannot group an empty tool call run"); + } + return { id: first.id, calls, latest, isSealed }; +} + +function createHost(run: ToolCallRun): ToolCallItem { + if (run.calls.length === 1) { + return run.latest; + } + return { ...run.latest, id: run.id }; +} + +function isRunning(call: ToolCallItem): boolean { + const status = describeToolCall(call).status; + return status === "running" || status === "executing"; +} + +function appendRun(input: { + calls: readonly ToolCallItem[]; + isSealed: boolean; + output: StreamItem[]; + groups: Map; + buildGroup: (run: ToolCallRun) => TGroup; +}): void { + if (input.calls.length === 0) { + return; + } + const run = createRun(input.calls, input.isSealed); + const host = createHost(run); + input.output.push(host); + input.groups.set(host.id, input.buildGroup(run)); +} + +export function prepareGroupedHistory(input: { + tail: StreamItem[]; + buildGroup: (run: ToolCallRun) => TGroup; +}): GroupedHistory { + const output: StreamItem[] = []; + const groups = new Map(); + let pending: ToolCallItem[] = []; + + for (const item of input.tail) { + if (isGroupableToolCall(item)) { + pending.push(item); + continue; + } + appendRun({ + calls: pending, + isSealed: true, + output, + groups, + buildGroup: input.buildGroup, + }); + pending = []; + output.push(item); + } + + appendRun({ + calls: pending, + isSealed: true, + output, + groups, + buildGroup: input.buildGroup, + }); + + return { + tail: groups.size > 0 ? output : input.tail, + groupsByHostId: groups, + pendingCalls: pending, + }; +} + +export function groupLiveToolCalls(input: { + history: GroupedHistory; + head: StreamItem[]; + isTurnActive: boolean; + buildGroup: (run: ToolCallRun) => TGroup; +}): GroupedToolCalls { + const head: StreamItem[] = []; + const liveGroups = new Map(); + let pending = [...input.history.pendingCalls]; + let hostPlacement: "history" | "head" | null = pending.length > 0 ? "history" : null; + let pendingIncludesHead = false; + + const flush = (isSealed: boolean) => { + if (pending.length === 0) { + return; + } + const run = createRun(pending, isSealed); + if (hostPlacement === "head") { + head.push(createHost(run)); + } + if (hostPlacement === "head" || pendingIncludesHead || !isSealed) { + liveGroups.set(run.id, input.buildGroup(run)); + } + pending = []; + hostPlacement = null; + pendingIncludesHead = false; + }; + + for (const item of input.head) { + if (isGroupableToolCall(item)) { + if (pending.length === 0) { + hostPlacement = "head"; + } + pending.push(item); + pendingIncludesHead = true; + continue; + } + flush(true); + head.push(item); + } + // Tool calls live in retained tail rather than the streaming head. The agent + // lifecycle snapshot can still be idle while a newly received tool call is + // already running, so its direct timeline status is the authoritative start + // signal. The lifecycle state continues to keep completed calls live between + // sequential tool updates. + const trailingRunIsActive = input.isTurnActive || pending.some(isRunning); + flush(!trailingRunIsActive); + + if (liveGroups.size === 0) { + return { + tail: input.history.tail, + head: input.head, + groupsByHostId: input.history.groupsByHostId, + historyGroupUpdatesByHostId: EMPTY_GROUPS, + }; + } + if (input.history.groupsByHostId.size === 0) { + return { + tail: input.history.tail, + head, + groupsByHostId: liveGroups, + historyGroupUpdatesByHostId: EMPTY_GROUPS, + }; + } + const groupsByHostId = new Map(input.history.groupsByHostId); + let historyGroupUpdatesByHostId: Map | null = null; + for (const [id, group] of liveGroups) { + groupsByHostId.set(id, group); + if (input.history.groupsByHostId.has(id)) { + historyGroupUpdatesByHostId ??= new Map(); + historyGroupUpdatesByHostId.set(id, group); + } + } + return { + tail: input.history.tail, + head, + groupsByHostId, + historyGroupUpdatesByHostId: historyGroupUpdatesByHostId ?? EMPTY_GROUPS, + }; +} diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts new file mode 100644 index 000000000..31faa23f2 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -0,0 +1,92 @@ +import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; +import { describeToolCall, type ToolCallRun } from "../grouping"; + +const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; +const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; + +export interface OverviewSummary { + editedFileCount: number; + commandCount: number; + readFileCount: number; + searchCount: number; + otherToolCount: number; + paseoCallCount: number; +} + +export interface OverviewToolCallGroup { + mode: "overview"; + run: ToolCallRun; + summary: OverviewSummary; + failedCount: number; + isLoading: boolean; +} + +export type OverviewHeader = { kind: "latest"; call: ToolCallRun["latest"] } | { kind: "summary" }; + +export function resolveOverviewHeader( + group: OverviewToolCallGroup, + expanded: boolean, +): OverviewHeader { + if (group.run.calls.length === 1) { + return { kind: "latest", call: group.run.latest }; + } + if (expanded || group.run.isSealed) { + return { kind: "summary" }; + } + return { kind: "latest", call: group.run.latest }; +} + +function isPaseoCall(name: string, normalizedName: string): boolean { + return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX); +} + +function isSearchCall(name: string): boolean { + return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); +} + +export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { + const editedFiles = new Set(); + const readFiles = new Set(); + let failedCount = 0; + let isLoading = false; + let commandCount = 0; + let searchCount = 0; + let otherToolCount = 0; + let paseoCallCount = 0; + + for (const call of run.calls) { + const descriptor = describeToolCall(call); + const normalizedName = descriptor.name.trim().toLowerCase(); + failedCount += descriptor.status === "failed" ? 1 : 0; + isLoading ||= descriptor.status === "running" || descriptor.status === "executing"; + if (isPaseoCall(descriptor.name, normalizedName)) { + paseoCallCount += 1; + } else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { + editedFiles.add(descriptor.detail.filePath); + } else if (descriptor.detail.type === "shell") { + commandCount += 1; + } else if (descriptor.detail.type === "read") { + readFiles.add(descriptor.detail.filePath); + } else if (descriptor.detail.type === "search" || isSearchCall(normalizedName)) { + searchCount += 1; + } else { + otherToolCount += 1; + } + } + + const summary = { + editedFileCount: editedFiles.size, + commandCount, + readFileCount: readFiles.size, + searchCount, + otherToolCount, + paseoCallCount, + }; + return { + mode: "overview", + run, + failedCount, + isLoading, + summary, + }; +} diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx new file mode 100644 index 000000000..c2cc68fc7 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -0,0 +1,173 @@ +import { memo, useCallback, useMemo, useRef, type ReactNode } from "react"; +import { ScrollView } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Wrench } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ExpandableBadge } from "@/components/message"; +import { ToolCallDetailsContent } from "@/components/tool-call-details"; +import { useToolCallSheet } from "@/components/tool-call-sheet"; +import { buildToolCallPresentation } from "@/tool-calls/presentation"; +import { resolveToolCallIcon } from "@/utils/tool-call-icon"; +import { describeToolCall } from "../grouping"; +import { resolveOverviewHeader, type OverviewSummary, type OverviewToolCallGroup } from "./model"; + +interface OverviewGroupProps { + group: OverviewToolCallGroup; + expanded: boolean; + isCompact: boolean; + isLastInSequence: boolean; + onExpandedChange: (groupId: string, expanded: boolean) => void; + cwd?: string; + onOpenFilePath?: (filePath: string) => void; + children: ReactNode; +} + +const TOOL_CALL_GROUP_MAX_HEIGHT = 400; + +function joinSummaryParts(parts: string[], conjunction: string): string { + if (parts.length === 0) { + return ""; + } + let joined = parts[0] ?? ""; + if (parts.length === 2) { + joined = `${parts[0]} ${conjunction} ${parts[1]}`; + } else if (parts.length > 2) { + joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; + } + const firstCharacter = joined[0]; + return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; +} + +function useOverviewSummary(summary: OverviewSummary): string { + const { t } = useTranslation(); + return useMemo(() => { + const parts: string[] = []; + const entries = [ + [summary.editedFileCount, "toolCallGroup.editedFiles"], + [summary.commandCount, "toolCallGroup.commands"], + [summary.readFileCount, "toolCallGroup.readFiles"], + [summary.searchCount, "toolCallGroup.searches"], + [summary.otherToolCount, "toolCallGroup.otherTools"], + [summary.paseoCallCount, "toolCallGroup.paseoCalls"], + ] as const; + for (const [count, key] of entries) { + if (count > 0) { + parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); + } + } + return joinSummaryParts(parts, t("toolCallGroup.and")); + }, [summary, t]); +} + +export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({ + group, + expanded, + isCompact, + isLastInSequence, + onExpandedChange, + cwd, + onOpenFilePath, + children, +}: OverviewGroupProps) { + const { t } = useTranslation(); + const { openToolCall } = useToolCallSheet(); + const scrollRef = useRef(null); + const aggregateSummary = useOverviewSummary(group.summary); + const header = resolveOverviewHeader(group, expanded); + const latestCall = header.kind === "latest" ? header.call : group.run.latest; + const latest = useMemo(() => { + const descriptor = describeToolCall(latestCall); + return { + detail: descriptor.detail, + presentation: buildToolCallPresentation({ + toolName: descriptor.name, + status: descriptor.status, + error: descriptor.error, + detail: descriptor.detail, + metadata: descriptor.metadata, + cwd, + resolveIcon: resolveToolCallIcon, + }), + }; + }, [cwd, latestCall]); + const showsLatest = header.kind === "latest"; + const opensSingleCallSheet = isCompact && group.run.calls.length === 1; + const openLatestFile = useMemo(() => { + const path = latest.presentation.openFilePath; + if (!showsLatest || !path || !onOpenFilePath) { + return undefined; + } + return () => onOpenFilePath(path); + }, [showsLatest, latest.presentation.openFilePath, onOpenFilePath]); + const failedSummary = + group.failedCount > 0 ? t("toolCallGroup.failed", { count: group.failedCount }) : undefined; + const scrollToLatest = useCallback(() => { + scrollRef.current?.scrollToEnd({ animated: false }); + }, []); + const toggle = useCallback(() => { + if (opensSingleCallSheet) { + openToolCall({ + displayName: latest.presentation.displayName, + summary: latest.presentation.summary, + detail: latest.detail, + errorText: latest.presentation.errorText, + icon: latest.presentation.icon, + showLoadingSkeleton: latest.presentation.isLoadingDetails, + }); + return; + } + onExpandedChange(group.run.id, !expanded); + }, [expanded, group.run.id, latest, onExpandedChange, openToolCall, opensSingleCallSheet]); + const renderDetails = useCallback(() => { + if (group.run.calls.length === 1) { + return ( + + ); + } + return ( + + {children} + + ); + }, [children, group.run.calls.length, latest, scrollToLatest]); + const canExpand = group.run.calls.length > 1 || latest.presentation.canOpenDetails; + + return ( + 0} + isExpanded={opensSingleCallSheet ? false : expanded} + isLastInSequence={isLastInSequence} + onToggle={canExpand ? toggle : undefined} + onOpenFile={openLatestFile} + renderDetails={canExpand && !opensSingleCallSheet ? renderDetails : undefined} + borderlessWhenExpanded + /> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + scroll: { + maxHeight: TOOL_CALL_GROUP_MAX_HEIGHT, + }, + content: { + paddingTop: theme.spacing[1], + paddingHorizontal: 13, + }, +})); diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts new file mode 100644 index 000000000..d8b56d46b --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -0,0 +1,501 @@ +import { describe, expect, it } from "vitest"; +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; +import type { StreamItem, ToolCallItem } from "@/types/stream"; +import { + prepareToolCallHistory, + projectToolCallDetailLevel, + type PreparedToolCallHistory, + type ToolCallDetailLevel, +} from "./projection"; +import { resolveOverviewHeader } from "./overview/model"; + +type AssistantMessageItem = Extract; + +function toolCall( + id: string, + detail: ToolCallDetail, + options: { + name?: string; + status?: "running" | "completed" | "failed" | "canceled"; + } = {}, +): ToolCallItem { + return { + kind: "tool_call", + id, + timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`), + payload: { + source: "agent", + data: { + provider: "claude", + callId: id, + name: options.name ?? detail.type, + status: options.status ?? "completed", + error: options.status === "failed" ? "boom" : null, + detail, + }, + }, + }; +} + +function assistant(id: string): AssistantMessageItem { + return { + kind: "assistant_message", + id, + text: id, + timestamp: new Date("2026-01-01T00:01:00.000Z"), + }; +} + +function project(input: { + level: ToolCallDetailLevel; + tail?: StreamItem[]; + head?: StreamItem[]; + isTurnActive?: boolean; + preparedHistory?: PreparedToolCallHistory | null; +}) { + const tail = input.tail ?? []; + return projectToolCallDetailLevel({ + level: input.level, + tail, + head: input.head ?? [], + preparedHistory: input.preparedHistory ?? prepareToolCallHistory(input.level, tail), + isTurnActive: input.isTurnActive ?? false, + }); +} + +describe("tool call detail-level projection", () => { + it("passes detailed timelines through without grouping work", () => { + const tail = [toolCall("1", { type: "shell", command: "one" })]; + const head = [toolCall("2", { type: "shell", command: "two" })]; + + const prepared = prepareToolCallHistory("detailed", tail); + const result = project({ level: "detailed", tail, head, preparedHistory: prepared }); + + expect(prepared).toBeNull(); + expect(result.tail).toBe(tail); + expect(result.head).toBe(head); + expect(result.groupsByHostId.size).toBe(0); + }); + + it("keeps one stable overview host as a run grows", () => { + const firstCall = toolCall("1", { type: "shell", command: "one" }); + const secondCall = toolCall("2", { type: "read", filePath: "/repo/a.ts" }); + const prepared = prepareToolCallHistory("overview", []); + + const single = project({ + level: "overview", + head: [firstCall], + isTurnActive: true, + preparedHistory: prepared, + }); + expect(single.head).toEqual([firstCall]); + expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({ + calls: [firstCall], + latest: firstCall, + isSealed: false, + }); + + const grouped = project({ + level: "overview", + head: [firstCall, secondCall], + isTurnActive: true, + preparedHistory: prepared, + }); + expect(grouped.head).toEqual([ + expect.objectContaining({ id: firstCall.id, timestamp: secondCall.timestamp }), + ]); + expect(grouped.groupsByHostId.get(firstCall.id)?.run).toMatchObject({ + calls: [firstCall, secondCall], + latest: secondCall, + isSealed: false, + }); + }); + + it("shows the latest call while collapsed and the summary while expanded", () => { + const calls = [ + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "read", filePath: "/repo/a.ts" }, { status: "running" }), + ]; + const result = project({ + level: "overview", + head: calls, + isTurnActive: true, + }); + const group = result.groupsByHostId.get("1"); + if (!group) { + throw new Error("Expected an overview group"); + } + + expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call: calls[1] }); + expect(resolveOverviewHeader(group, true)).toEqual({ kind: "summary" }); + }); + + it("keeps a parallel group loading while any call is still running", () => { + const calls = [ + toolCall("1", { type: "shell", command: "slow" }, { status: "running" }), + toolCall("2", { type: "shell", command: "done" }), + ]; + const result = project({ level: "overview", head: calls, isTurnActive: true }); + + expect(result.groupsByHostId.get("1")?.isLoading).toBe(true); + }); + + it("keeps a one-call run presented as its tool call", () => { + const call = toolCall("1", { type: "shell", command: "one" }); + const result = project({ level: "overview", head: [call] }); + const group = result.groupsByHostId.get(call.id); + if (!group) { + throw new Error("Expected an overview group"); + } + + expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call }); + expect(resolveOverviewHeader(group, true)).toEqual({ kind: "latest", call }); + }); + + it("keeps an active overview group on its latest call until a visible boundary arrives", () => { + const calls = [ + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "read", filePath: "/repo/a.ts" }), + toolCall("3", { type: "read", filePath: "/repo/b.ts" }), + toolCall("4", { type: "edit", filePath: "/repo/a.ts" }), + ]; + const prepared = prepareToolCallHistory("overview", []); + const active = project({ + level: "overview", + head: calls, + isTurnActive: true, + preparedHistory: prepared, + }); + const activeGroup = active.groupsByHostId.get("1"); + + expect(activeGroup).toMatchObject({ + mode: "overview", + run: { id: "1", latest: calls[3], isSealed: false }, + }); + expect(resolveOverviewHeader(activeGroup!, false)).toEqual({ + kind: "latest", + call: calls[3], + }); + + const boundary = assistant("answer"); + const sealed = project({ + level: "overview", + head: [...calls, boundary], + isTurnActive: true, + preparedHistory: prepared, + }); + expect(sealed.groupsByHostId.get("1")).toMatchObject({ + mode: "overview", + run: { latest: calls[3], isSealed: true }, + summary: { editedFileCount: 1, readFileCount: 2, commandCount: 1 }, + }); + expect(resolveOverviewHeader(sealed.groupsByHostId.get("1")!, false)).toEqual({ + kind: "summary", + }); + }); + + it("keeps a running overview header live before the agent lifecycle catches up", () => { + const calls = ["1", "2", "3", "4"].map((id) => + toolCall(id, { type: "shell", command: id }, { status: "running" }), + ); + + const result = project({ + level: "overview", + tail: calls, + isTurnActive: false, + }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + run: { latest: calls[3], isSealed: false }, + }); + expect(resolveOverviewHeader(result.groupsByHostId.get("1")!, false)).toEqual({ + kind: "latest", + call: calls[3], + }); + }); + + it("seals the trailing overview group only when the turn ends", () => { + const calls = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id })); + const prepared = prepareToolCallHistory("overview", []); + + const betweenCalls = project({ + level: "overview", + head: calls, + isTurnActive: true, + preparedHistory: prepared, + }); + const nextCall = toolCall("5", { type: "read", filePath: "/repo/a.ts" }); + const continued = project({ + level: "overview", + head: [...calls, nextCall], + isTurnActive: true, + preparedHistory: prepared, + }); + const ended = project({ + level: "overview", + head: [...calls, nextCall], + isTurnActive: false, + preparedHistory: prepared, + }); + + expect(betweenCalls.groupsByHostId.get("1")?.run.isSealed).toBe(false); + expect(resolveOverviewHeader(betweenCalls.groupsByHostId.get("1")!, false)).toEqual({ + kind: "latest", + call: calls[3], + }); + expect(continued.groupsByHostId.get("1")?.run).toMatchObject({ + latest: nextCall, + isSealed: false, + }); + expect(resolveOverviewHeader(continued.groupsByHostId.get("1")!, false)).toEqual({ + kind: "latest", + call: nextCall, + }); + expect(ended.groupsByHostId.get("1")?.run.isSealed).toBe(true); + expect(resolveOverviewHeader(ended.groupsByHostId.get("1")!, false)).toEqual({ + kind: "summary", + }); + }); + + it("builds overview summaries without category-specific presentation data", () => { + const calls = [ + toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }), + toolCall("2", { type: "read", filePath: "/repo/src/b.ts" }), + toolCall("3", { type: "shell", command: "npm test" }), + toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }, { status: "failed" }), + ]; + + const overview = project({ level: "overview", head: calls }); + + expect(overview.groupsByHostId.get("1")).toEqual({ + mode: "overview", + run: expect.any(Object), + failedCount: 1, + isLoading: false, + summary: { + editedFileCount: 1, + commandCount: 1, + readFileCount: 2, + searchCount: 0, + otherToolCount: 0, + paseoCallCount: 0, + }, + }); + }); + + it("distinguishes reads, searches, and other tools in overview", () => { + const calls = [ + toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }), + toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }), + toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }), + toolCall( + "4", + { type: "search", query: "paseo", toolName: "web_search" }, + { status: "failed" }, + ), + toolCall("5", { type: "fetch", url: "not a url" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + failedCount: 1, + summary: { + editedFileCount: 0, + commandCount: 0, + readFileCount: 2, + searchCount: 1, + otherToolCount: 2, + }, + }); + }); + + it("counts unique edited files and every shell command in overview", () => { + const calls = [ + toolCall("1", { type: "edit", filePath: "/repo/a.ts" }), + toolCall("2", { type: "edit", filePath: "/repo/a.ts" }), + toolCall("3", { type: "write", filePath: "/repo/b.ts" }), + toolCall("4", { type: "shell", command: "npm test" }), + toolCall("5", { type: "shell", command: "npm run lint" }), + toolCall("6", { type: "read", filePath: "/repo/c.ts" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + summary: { + editedFileCount: 2, + commandCount: 2, + readFileCount: 1, + otherToolCount: 0, + }, + }); + }); + + it("counts Paseo calls separately from other tools", () => { + const calls = [ + toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }), + toolCall( + "2", + { type: "unknown", input: null, output: null }, + { name: "mcp__paseo__list_worktrees" }, + ), + toolCall("3", { type: "fetch", url: "https://paseo.sh" }), + toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + summary: { otherToolCount: 2, paseoCallCount: 2 }, + }); + }); + + it("classifies direct Brave search and Paseo runtime tool names", () => { + const unknownDetail = { type: "unknown" as const, input: null, output: null }; + const calls = [ + toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }), + toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }), + toolCall("3", unknownDetail, { name: "paseo_list_providers" }), + toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }), + toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }), + toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }), + ]; + + const result = project({ level: "overview", head: calls }); + + expect(result.groupsByHostId.get("1")).toMatchObject({ + summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 }, + }); + }); + + it("reuses prepared history and sealed group models across live-head updates", () => { + const historicalCalls = ["1", "2", "3", "4"].map((id) => + toolCall(id, { type: "shell", command: id }), + ); + const tail = [...historicalCalls, assistant("boundary")]; + const prepared = prepareToolCallHistory("overview", tail); + if (!prepared) { + throw new Error("Overview history must be prepared"); + } + expect(prepared.grouped.tail).toEqual([ + expect.objectContaining({ id: "1", timestamp: historicalCalls[3]?.timestamp }), + tail[4], + ]); + const first = project({ + level: "overview", + tail, + head: [toolCall("5", { type: "read", filePath: "/repo/a.ts" })], + isTurnActive: true, + preparedHistory: prepared, + }); + const second = project({ + level: "overview", + tail, + head: [ + toolCall("5", { type: "read", filePath: "/repo/a.ts" }), + toolCall("6", { type: "read", filePath: "/repo/b.ts" }), + ], + isTurnActive: true, + preparedHistory: prepared, + }); + + expect(first.tail).toBe(prepared.grouped.tail); + expect(second.tail).toBe(prepared.grouped.tail); + expect(first.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1")); + expect(second.groupsByHostId.get("1")).toBe(prepared.grouped.groupsByHostId.get("1")); + expect(first.historyGroupUpdatesByHostId.size).toBe(0); + expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId); + expect(second.groupsByHostId.get("5")?.run.calls).toHaveLength(2); + }); + + it("preserves projected history identity during assistant-only head updates", () => { + const trailingCalls = [ + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "read", filePath: "/repo/a.ts" }), + ]; + const tail = [assistant("before"), ...trailingCalls]; + const prepared = prepareToolCallHistory("overview", tail); + if (!prepared) { + throw new Error("Overview history must be prepared"); + } + + const firstHead = [assistant("answer")]; + const secondHead = [{ ...firstHead[0], text: "answer grows" }]; + const first = project({ + level: "overview", + tail, + head: firstHead, + isTurnActive: true, + preparedHistory: prepared, + }); + const second = project({ + level: "overview", + tail, + head: secondHead, + isTurnActive: true, + preparedHistory: prepared, + }); + + expect(first.tail).toBe(prepared.grouped.tail); + expect(second.tail).toBe(prepared.grouped.tail); + expect(first.groupsByHostId).toBe(prepared.grouped.groupsByHostId); + expect(second.groupsByHostId).toBe(prepared.grouped.groupsByHostId); + expect(first.historyGroupUpdatesByHostId.size).toBe(0); + expect(second.historyGroupUpdatesByHostId).toBe(first.historyGroupUpdatesByHostId); + }); + + it("forms one group across the retained-history and live-head boundary", () => { + const tail = [ + assistant("before"), + toolCall("1", { type: "shell", command: "one" }), + toolCall("2", { type: "shell", command: "two" }), + ]; + const head = [ + toolCall("3", { type: "read", filePath: "/repo/a.ts" }), + toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }), + ]; + + const result = project({ level: "overview", tail, head, isTurnActive: true }); + + expect(result.tail).toEqual([ + tail[0], + expect.objectContaining({ id: "1", timestamp: tail[2]?.timestamp }), + ]); + expect(result.head).toEqual([]); + expect(result.groupsByHostId.get("1")?.run).toMatchObject({ + calls: [...tail.slice(1), ...head], + latest: head[1], + isSealed: false, + }); + expect(result.historyGroupUpdatesByHostId.get("1")).toBe(result.groupsByHostId.get("1")); + }); + + it("keeps a trailing history-only group in the retained segment", () => { + const tail = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id })); + + const result = project({ level: "overview", tail, isTurnActive: false }); + + expect(result.tail).toEqual([ + expect.objectContaining({ id: "1", timestamp: tail[3]?.timestamp }), + ]); + expect(result.head).toEqual([]); + expect(result.groupsByHostId.get("1")?.run.isSealed).toBe(true); + }); + + it("hosts single calls while leaving plans and spoken messages ungrouped", () => { + const singleCall = toolCall("1", { type: "shell", command: "one" }); + const plan = toolCall("2", { type: "plan", text: "Plan" }); + const speak = toolCall( + "3", + { type: "unknown", input: "Hello", output: null }, + { name: "speak" }, + ); + + const result = project({ level: "overview", head: [singleCall, plan, speak] }); + + expect(result.head).toEqual([singleCall, plan, speak]); + expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]); + expect(result.groupsByHostId.size).toBe(1); + }); +}); diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts new file mode 100644 index 000000000..1740e8500 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -0,0 +1,60 @@ +import type { StreamItem } from "@/types/stream"; +import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; +import { + groupLiveToolCalls, + prepareGroupedHistory, + type GroupedHistory, + type GroupedToolCalls, +} from "./grouping"; +import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model"; + +export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; +export type ToolCallDetailGroup = OverviewToolCallGroup; + +export interface PreparedToolCallHistory { + mode: "overview"; + grouped: GroupedHistory; +} + +export interface ToolCallDetailProjection extends GroupedToolCalls {} + +const EMPTY_TOOL_CALL_GROUPS = new Map(); + +export function prepareToolCallHistory( + level: ToolCallDetailLevel, + tail: StreamItem[], +): PreparedToolCallHistory | null { + if (level === "detailed") { + return null; + } + return { + mode: "overview", + grouped: prepareGroupedHistory({ tail, buildGroup: buildOverviewGroup }), + }; +} + +export function projectToolCallDetailLevel(input: { + level: ToolCallDetailLevel; + tail: StreamItem[]; + head: StreamItem[]; + preparedHistory: PreparedToolCallHistory | null; + isTurnActive: boolean; +}): ToolCallDetailProjection { + if (input.level === "detailed") { + return { + tail: input.tail, + head: input.head, + groupsByHostId: EMPTY_TOOL_CALL_GROUPS, + historyGroupUpdatesByHostId: EMPTY_TOOL_CALL_GROUPS, + }; + } + if (!input.preparedHistory || input.preparedHistory.mode !== input.level) { + throw new Error(`Missing prepared ${input.level} tool call history`); + } + return groupLiveToolCalls({ + history: input.preparedHistory.grouped, + head: input.head, + isTurnActive: input.isTurnActive, + buildGroup: buildOverviewGroup, + }); +} diff --git a/packages/app/src/tool-calls/grouping.test.ts b/packages/app/src/tool-calls/grouping.test.ts deleted file mode 100644 index e88a28edc..000000000 --- a/packages/app/src/tool-calls/grouping.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; -import type { StreamItem, ToolCallItem } from "@/types/stream"; -import { compactToolCallRuns } from "./grouping"; - -function toolCall( - id: string, - detail: ToolCallDetail, - options: { - name?: string; - status?: "running" | "completed" | "failed" | "canceled"; - } = {}, -): ToolCallItem { - return { - kind: "tool_call", - id, - timestamp: new Date(`2026-01-01T00:00:${id.padStart(2, "0")}.000Z`), - payload: { - source: "agent", - data: { - provider: "claude", - callId: id, - name: options.name ?? detail.type, - status: options.status ?? "completed", - error: options.status === "failed" ? "boom" : null, - detail, - }, - }, - }; -} - -function assistant(id: string): StreamItem { - return { - kind: "assistant_message", - id, - text: id, - timestamp: new Date("2026-01-01T00:01:00.000Z"), - }; -} - -describe("compactToolCallRuns", () => { - it("preserves the original arrays when compaction is disabled", () => { - const tail = [toolCall("1", { type: "shell", command: "one" })]; - const head = [toolCall("2", { type: "shell", command: "two" })]; - - const result = compactToolCallRuns({ tail, head, enabled: false }); - - expect(result.tail).toBe(tail); - expect(result.head).toBe(head); - expect(result.groupsByHostId.size).toBe(0); - }); - - it("replaces four contiguous calls with a stable first-call host and latest timestamp", () => { - const calls = [ - toolCall("1", { type: "shell", command: "one" }), - toolCall("2", { type: "shell", command: "two" }), - toolCall("3", { type: "read", filePath: "/repo/src/a.ts" }), - toolCall("4", { type: "edit", filePath: "/repo/src/a.ts" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ id: "1", timestamp: calls[3]?.timestamp }); - expect(result.head).toEqual([]); - expect(result.groupsByHostId.get("1")?.id).toBe("1"); - expect(result.groupsByHostId.get("1")?.calls).toEqual(calls); - expect(result.groupsByHostId.get("1")).toMatchObject({ - editedFileCount: 1, - commandCount: 2, - readFileCount: 1, - otherToolCount: 0, - categories: [ - { key: "shell", label: "Shell", callCount: 2, resources: [] }, - { key: "read", label: "Read", callCount: 1, resources: ["a.ts"] }, - { key: "edit", label: "Edit", callCount: 1, resources: ["a.ts"] }, - ], - }); - - const nextCall = toolCall("5", { type: "shell", command: "five" }, { status: "running" }); - const updated = compactToolCallRuns({ - tail: [...calls, nextCall], - head: [], - enabled: true, - }); - expect(updated.tail[0]).toMatchObject({ id: "1", timestamp: nextCall.timestamp }); - expect(updated.groupsByHostId.get("1")?.id).toBe("1"); - }); - - it("does not compact short runs and stops at visible content boundaries", () => { - const firstRun = [ - toolCall("1", { type: "shell", command: "one" }), - toolCall("2", { type: "shell", command: "two" }), - toolCall("3", { type: "shell", command: "three" }), - ]; - const boundary = assistant("assistant"); - const secondRun = [ - toolCall("4", { type: "read", filePath: "/repo/a.ts" }), - toolCall("5", { type: "read", filePath: "/repo/b.ts" }), - toolCall("6", { type: "read", filePath: "/repo/c.ts" }), - toolCall("7", { type: "read", filePath: "/repo/d.ts" }), - ]; - - const result = compactToolCallRuns({ - tail: [...firstRun, boundary, ...secondRun], - head: [], - enabled: true, - }); - - expect(result.tail.slice(0, -1)).toEqual([...firstRun, boundary]); - expect(result.tail.at(-1)).toMatchObject({ id: "4", timestamp: secondRun[3]?.timestamp }); - expect([...result.groupsByHostId.keys()]).toEqual(["4"]); - }); - - it("forms one group across the history and live-head boundary", () => { - const tail = [ - assistant("assistant"), - toolCall("1", { type: "shell", command: "one" }), - toolCall("2", { type: "shell", command: "two" }), - ]; - const head = [ - toolCall("3", { type: "read", filePath: "/repo/a.ts" }), - toolCall("4", { type: "edit", filePath: "/repo/a.ts" }, { status: "running" }), - ]; - - const result = compactToolCallRuns({ tail, head, enabled: true }); - - expect(result.tail).toEqual([tail[0]]); - expect(result.head).toHaveLength(1); - expect(result.head[0]).toMatchObject({ id: "1", timestamp: head[1]?.timestamp }); - expect(result.groupsByHostId.get("1")?.calls).toEqual([...tail.slice(1), ...head]); - expect(result.groupsByHostId.get("1")?.isRunning).toBe(true); - }); - - it("separates reads and searches from other tools", () => { - const calls = [ - toolCall("1", { type: "read", filePath: "/repo/src/a.ts" }), - toolCall("2", { type: "read", filePath: "C:\\repo\\src\\beta.ts" }), - toolCall("3", { type: "fetch", url: "https://github.com/org/repo" }), - toolCall( - "4", - { type: "search", query: "paseo", toolName: "web_search" }, - { status: "failed" }, - ), - toolCall("5", { type: "fetch", url: "not a url" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - const group = result.groupsByHostId.get("1"); - - expect(group).toMatchObject({ - failedCount: 1, - isRunning: false, - editedFileCount: 0, - commandCount: 0, - readFileCount: 2, - searchCount: 1, - otherToolCount: 2, - }); - }); - - it("counts unique edited files while counting each shell command", () => { - const calls = [ - toolCall("1", { type: "edit", filePath: "/repo/a.ts" }), - toolCall("2", { type: "edit", filePath: "/repo/a.ts" }), - toolCall("3", { type: "write", filePath: "/repo/b.ts" }), - toolCall("4", { type: "shell", command: "npm test" }), - toolCall("5", { type: "shell", command: "npm run lint" }), - toolCall("6", { type: "read", filePath: "/repo/c.ts" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - editedFileCount: 2, - commandCount: 2, - readFileCount: 1, - otherToolCount: 0, - }); - }); - - it("counts Paseo calls separately from other tools", () => { - const calls = [ - toolCall("1", { type: "unknown", input: null, output: null }, { name: "paseo.list_agents" }), - toolCall( - "2", - { type: "unknown", input: null, output: null }, - { name: "mcp__paseo__list_worktrees" }, - ), - toolCall("3", { type: "fetch", url: "https://paseo.sh" }), - toolCall("4", { type: "fetch", url: "https://github.com/getpaseo" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - otherToolCount: 2, - paseoCallCount: 2, - }); - }); - - it("classifies direct Brave and Paseo runtime tool names", () => { - const unknownDetail = { type: "unknown" as const, input: null, output: null }; - const calls = [ - toolCall("1", unknownDetail, { name: "brave-search_brave_web_search" }), - toolCall("2", unknownDetail, { name: "brave-search_brave_llm_context" }), - toolCall("3", unknownDetail, { name: "paseo_list_providers" }), - toolCall("4", unknownDetail, { name: "paseo_list_worktrees" }), - toolCall("5", unknownDetail, { name: "paseo_list_worktrees" }), - toolCall("6", unknownDetail, { name: "mcp__exa__web_search" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - searchCount: 3, - otherToolCount: 0, - paseoCallCount: 3, - }); - }); - - it("keeps plan and spoken-message calls outside compact groups", () => { - const shellCalls = ["1", "2", "3", "4"].map((id) => - toolCall(id, { type: "shell", command: id }), - ); - const plan = toolCall("5", { type: "plan", text: "Plan" }); - const speak = toolCall( - "6", - { type: "unknown", input: "Hello", output: null }, - { name: "speak" }, - ); - - const result = compactToolCallRuns({ - tail: [...shellCalls, plan, speak], - head: [], - enabled: true, - }); - - expect(result.tail[0]).toMatchObject({ id: "1", timestamp: shellCalls[3]?.timestamp }); - expect(result.tail.slice(1)).toEqual([plan, speak]); - expect(result.groupsByHostId.get("1")?.calls).toEqual(shellCalls); - }); - - it("deduplicates file resources by full path while displaying basenames", () => { - const calls = [ - toolCall("1", { type: "read", filePath: "/repo/src/index.ts" }), - toolCall("2", { type: "read", filePath: "/repo/tests/index.ts" }), - toolCall("3", { type: "read", filePath: "/repo/src/other.ts" }), - toolCall("4", { type: "read", filePath: "/repo/src/index.ts" }), - ]; - - const result = compactToolCallRuns({ tail: calls, head: [], enabled: true }); - - expect(result.groupsByHostId.get("1")).toMatchObject({ - readFileCount: 3, - categories: [ - { - key: "read", - resources: ["index.ts", "index.ts", "other.ts"], - }, - ], - }); - }); -}); diff --git a/packages/app/src/tool-calls/grouping.ts b/packages/app/src/tool-calls/grouping.ts deleted file mode 100644 index f8fce5976..000000000 --- a/packages/app/src/tool-calls/grouping.ts +++ /dev/null @@ -1,325 +0,0 @@ -import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; -import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; -import { getFileNameFromPath } from "@/attachments/utils"; -import type { StreamItem, ToolCallItem } from "@/types/stream"; -import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; -import { resolveToolCallIconName, type ToolCallIcon } from "@/utils/tool-call-icon-name"; - -export const MIN_COMPACT_TOOL_CALLS = 4; - -const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; -const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; - -export interface ToolCallCategorySummary { - key: string; - label: string; - iconName: ToolCallIcon; - callCount: number; - failedCount: number; - runningCount: number; - resources: string[]; -} - -export interface CompactToolCallGroup { - id: string; - calls: ToolCallItem[]; - callCount: number; - failedCount: number; - isRunning: boolean; - editedFileCount: number; - commandCount: number; - readFileCount: number; - searchCount: number; - otherToolCount: number; - paseoCallCount: number; - categories: ToolCallCategorySummary[]; -} - -export interface CompactToolCallRunsResult { - tail: StreamItem[]; - head: StreamItem[]; - groupsByHostId: Map; -} - -interface CompactToolCallRunsInput { - tail: StreamItem[]; - head: StreamItem[]; - enabled: boolean; -} - -interface TaggedStreamItem { - segment: "tail" | "head"; - item: StreamItem; -} - -interface ToolCallDescriptor { - detail: ToolCallDetail; - name: string; - status: "running" | "completed" | "failed" | "canceled"; - error: unknown; - metadata?: Record; -} - -interface ResourceSummary { - key: string; - label: string; -} - -function describeToolCall(item: ToolCallItem): ToolCallDescriptor { - if (item.payload.source === "agent") { - const { data } = item.payload; - return { - detail: data.detail, - name: data.name, - status: data.status, - error: data.error, - metadata: data.metadata, - }; - } - - const { data } = item.payload; - return { - detail: { - type: "unknown", - input: data.arguments ?? null, - output: data.result ?? null, - }, - name: data.toolName, - status: data.status === "executing" ? "running" : data.status, - error: data.error, - }; -} - -function isCompactableToolCall(item: StreamItem): item is ToolCallItem { - if (item.kind !== "tool_call") { - return false; - } - const descriptor = describeToolCall(item); - return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; -} - -function isDirectPaseoToolName(name: string): boolean { - return name.startsWith(DIRECT_PASEO_TOOL_PREFIX); -} - -function isDirectSearchToolName(name: string): boolean { - return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); -} - -function resourceForDetail(detail: ToolCallDetail): ResourceSummary | null { - if (detail.type === "read" || detail.type === "edit" || detail.type === "write") { - return { - key: detail.filePath, - label: getFileNameFromPath(detail.filePath) ?? detail.filePath, - }; - } - if (detail.type !== "fetch") { - return null; - } - try { - const hostname = new URL(detail.url).hostname || detail.url; - return { key: hostname, label: hostname }; - } catch { - return { key: detail.url, label: detail.url }; - } -} - -function categoryIdentity(input: { - descriptor: ToolCallDescriptor; - normalizedName: string; - displayName: string; -}): { key: string; label: string; iconName: ToolCallIcon } { - if (isPaseoToolName(input.descriptor.name) || isDirectPaseoToolName(input.normalizedName)) { - return { key: "paseo", label: "Paseo", iconName: "paseo" }; - } - if ( - (input.descriptor.detail.type === "search" && - input.descriptor.detail.toolName === "web_search") || - isDirectSearchToolName(input.normalizedName) - ) { - return { key: "web_search", label: "Web search", iconName: "search" }; - } - if (input.descriptor.detail.type === "fetch") { - return { key: "fetch", label: "Web fetch", iconName: "search" }; - } - if (input.descriptor.detail.type !== "unknown" && input.descriptor.detail.type !== "plain_text") { - return { - key: input.descriptor.detail.type, - label: input.displayName, - iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail), - }; - } - return { - key: `tool:${input.displayName.toLowerCase()}`, - label: input.displayName, - iconName: resolveToolCallIconName(input.descriptor.name, input.descriptor.detail), - }; -} - -function buildCompactToolCallGroup(calls: ToolCallItem[]) { - const editedFiles = new Set(); - const readFiles = new Set(); - const categories = new Map(); - const categoryResourceKeys = new Map>(); - let failedCount = 0; - let isRunning = false; - let commandCount = 0; - let searchCount = 0; - let otherToolCount = 0; - let paseoCallCount = 0; - - for (const call of calls) { - const descriptor = describeToolCall(call); - const isFailed = descriptor.status === "failed"; - const isCallRunning = descriptor.status === "running"; - failedCount += isFailed ? 1 : 0; - isRunning ||= isCallRunning; - const normalizedName = descriptor.name.trim().toLowerCase(); - const display = buildToolCallDisplayModel({ - name: descriptor.name, - status: descriptor.status, - error: descriptor.error, - detail: descriptor.detail, - metadata: descriptor.metadata, - }); - const identity = categoryIdentity({ - descriptor, - normalizedName, - displayName: display.displayName, - }); - let category = categories.get(identity.key); - if (!category) { - category = { - ...identity, - callCount: 0, - failedCount: 0, - runningCount: 0, - resources: [], - }; - categories.set(identity.key, category); - } - category.callCount += 1; - category.failedCount += isFailed ? 1 : 0; - category.runningCount += isCallRunning ? 1 : 0; - const resource = resourceForDetail(descriptor.detail); - if (resource) { - let resourceKeys = categoryResourceKeys.get(identity.key); - if (!resourceKeys) { - resourceKeys = new Set(); - categoryResourceKeys.set(identity.key, resourceKeys); - } - if (!resourceKeys.has(resource.key)) { - resourceKeys.add(resource.key); - category.resources.push(resource.label); - } - } - - if (isPaseoToolName(descriptor.name) || isDirectPaseoToolName(normalizedName)) { - paseoCallCount += 1; - continue; - } - if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { - editedFiles.add(descriptor.detail.filePath); - continue; - } - if (descriptor.detail.type === "shell") { - commandCount += 1; - continue; - } - if (descriptor.detail.type === "read") { - readFiles.add(descriptor.detail.filePath); - continue; - } - if (descriptor.detail.type === "search" || isDirectSearchToolName(normalizedName)) { - searchCount += 1; - continue; - } - otherToolCount += 1; - } - - const firstCall = calls[0]; - if (!firstCall) { - throw new Error("Cannot build an empty tool call group"); - } - return { - id: firstCall.id, - calls, - callCount: calls.length, - failedCount, - isRunning, - editedFileCount: editedFiles.size, - commandCount, - readFileCount: readFiles.size, - searchCount, - otherToolCount, - paseoCallCount, - categories: [...categories.values()], - } satisfies CompactToolCallGroup; -} - -export function compactToolCallRuns(input: CompactToolCallRunsInput): CompactToolCallRunsResult { - if (!input.enabled) { - return { - tail: input.tail, - head: input.head, - groupsByHostId: new Map(), - }; - } - - const taggedItems: TaggedStreamItem[] = [ - ...input.tail.map((item) => ({ segment: "tail" as const, item })), - ...input.head.map((item) => ({ segment: "head" as const, item })), - ]; - const compactedTail: StreamItem[] = []; - const compactedHead: StreamItem[] = []; - const groupsByHostId = new Map(); - let pendingRun: TaggedStreamItem[] = []; - - const append = ({ segment, item }: TaggedStreamItem) => { - (segment === "tail" ? compactedTail : compactedHead).push(item); - }; - const flushRun = () => { - if (pendingRun.length >= MIN_COMPACT_TOOL_CALLS) { - const first = pendingRun[0]; - const latest = pendingRun.at(-1); - if (!first || !latest) { - throw new Error("Cannot compact an empty tool call run"); - } - const calls = pendingRun.map(({ item }) => item as ToolCallItem); - const stableHost: TaggedStreamItem = { - segment: latest.segment, - item: { ...latest.item, id: first.item.id }, - }; - append(stableHost); - groupsByHostId.set(stableHost.item.id, buildCompactToolCallGroup(calls)); - } else { - for (const entry of pendingRun) { - append(entry); - } - } - pendingRun = []; - }; - - for (const entry of taggedItems) { - if (isCompactableToolCall(entry.item)) { - pendingRun.push(entry); - continue; - } - flushRun(); - append(entry); - } - flushRun(); - - if (groupsByHostId.size === 0) { - return { - tail: input.tail, - head: input.head, - groupsByHostId, - }; - } - return { - tail: compactedTail, - head: compactedHead, - groupsByHostId, - }; -} diff --git a/packages/app/src/types/react-native-flat-list.d.ts b/packages/app/src/types/react-native-flat-list.d.ts new file mode 100644 index 000000000..29e82d1db --- /dev/null +++ b/packages/app/src/types/react-native-flat-list.d.ts @@ -0,0 +1,10 @@ +// React Native 0.81 implements FlatList's renderer memoization flag and ships +// it in generated types, but omits it from the legacy declarations exposed by +// the package entry point. +import "react-native"; + +declare module "react-native" { + interface FlatListProps { + strictMode?: boolean; + } +} diff --git a/packages/app/src/utils/to-xterm-theme.ts b/packages/app/src/utils/to-xterm-theme.ts index c762bab1b..7c7d38043 100644 --- a/packages/app/src/utils/to-xterm-theme.ts +++ b/packages/app/src/utils/to-xterm-theme.ts @@ -12,7 +12,6 @@ export function toXtermTheme(terminal: TerminalPalette): ITheme { cursorAccent: terminal.cursorAccent, selectionBackground: terminal.selectionBackground, selectionForeground: terminal.selectionForeground, - black: terminal.black, red: terminal.red, green: terminal.green, From 50ed0d0ab11d8f5d9543096329436bb37c303ff0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Jul 2026 23:01:14 +0200 Subject: [PATCH 04/20] Fix desktop layout and window controls at half-screen width (#1983) * fix(app): preserve desktop layout in half-screen windows Treat native window controls as physical corner obstructions instead of deriving clearance from compact layout state. Keep 751px windows in desktop mode while preserving usable content when persisted sidebars are wide. * fix(app): retain sidebar width across window resizes * fix(app): yield app sidebar to narrow settings * test(app): wait for mobile sidebar motion to settle * fix(app): yield navigation to narrow workspace explorer * fix(app): mark sidebar sizing helpers as worklets * fix(app): hide app navigation when space is constrained --- docs/design.md | 4 + docs/development.md | 11 + packages/app/e2e/helpers/sidebar.ts | 9 +- packages/app/e2e/sidebar-workspace.spec.ts | 43 ++ packages/app/src/app/_layout.tsx | 62 +- .../src/components/attachment-lightbox.tsx | 88 +-- .../components/desktop-sidebar-layout.test.ts | 70 +++ .../src/components/desktop-sidebar-layout.ts | 78 +++ .../app/src/components/explorer-sidebar.tsx | 62 +- .../src/components/headers/screen-header.tsx | 27 +- packages/app/src/components/left-sidebar.tsx | 91 +-- .../components/split-container-focus.test.ts | 37 ++ .../src/components/split-container-focus.ts | 21 + .../app/src/components/split-container.tsx | 119 ++-- packages/app/src/constants/layout.ts | 7 + packages/app/src/desktop/host.ts | 1 + .../app/src/mobile-panels/presentation.tsx | 3 +- packages/app/src/screens/settings-screen.tsx | 80 ++- .../screens/workspace/workspace-screen.tsx | 71 ++- packages/app/src/styles/unistyles.ts | 2 +- packages/app/src/utils/desktop-window.test.ts | 129 ++--- packages/app/src/utils/desktop-window.ts | 187 ------ packages/app/src/utils/desktop-window.tsx | 238 ++++++++ packages/desktop/package.json | 1 + .../desktop/scripts/verify-electron-cdp.mjs | 533 ++++++++++++------ packages/desktop/src/preload.ts | 2 + packages/desktop/src/window/window-manager.ts | 5 + 27 files changed, 1263 insertions(+), 718 deletions(-) create mode 100644 packages/app/src/components/desktop-sidebar-layout.test.ts create mode 100644 packages/app/src/components/desktop-sidebar-layout.ts create mode 100644 packages/app/src/components/split-container-focus.test.ts create mode 100644 packages/app/src/components/split-container-focus.ts delete mode 100644 packages/app/src/utils/desktop-window.ts create mode 100644 packages/app/src/utils/desktop-window.tsx diff --git a/docs/design.md b/docs/design.md index 172416984..588b53904 100644 --- a/docs/design.md +++ b/docs/design.md @@ -131,6 +131,10 @@ The branching is one `useIsCompactFormFactor()` check at the top of the screen c The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop. +On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths. + +Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate. + A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR. --- diff --git a/docs/development.md b/docs/development.md index d435f11f0..b86772680 100644 --- a/docs/development.md +++ b/docs/development.md @@ -72,6 +72,17 @@ Starting the service must not create, focus, reveal, or leave behind macOS Simul It launches its own Electron-flavored Expo server and passes that URL to Electron. Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy. +With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen +transition, and 751-pixel settings split with: + +```bash +npm run verify:electron-cdp --workspace=@getpaseo/desktop +``` + +The verifier reads the same `EXPO_PORT` and +`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set both when +testing an isolated instance on non-default ports. + When running a dedicated Electron QA instance against a non-default Expo port, set `EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so `PASEO_PORT=57928` alone starts Metro on 57928 but Electron still loads 8081. diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts index 63a6e0cec..c6d0fb3ce 100644 --- a/packages/app/e2e/helpers/sidebar.ts +++ b/packages/app/e2e/helpers/sidebar.ts @@ -61,13 +61,14 @@ export async function openMobileAgentSidebar(page: Page): Promise { export async function closeMobileAgentSidebar(page: Page): Promise { const closeButton = page.getByTestId("sidebar-close"); - await expect(closeButton).toBeInViewport({ timeout: 5_000 }); - await closeButton.click({ force: true }); + await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 }); + await closeButton.click(); } -// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position. +// The mobile sidebar panel animates via translateX. Waiting for its header to be fully visible +// prevents a close click from targeting a button while the panel is still moving. export async function expectMobileAgentSidebarVisible(page: Page): Promise { - await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 }); + await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1, timeout: 5_000 }); } export async function expectMobileAgentSidebarHidden(page: Page): Promise { diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index ed6940203..0a7ec1a7f 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -165,3 +165,46 @@ test.describe("Mobile sidebar panelState transition", () => { await expectMobileAgentSidebarHidden(page); }); }); + +test.describe("Half-screen desktop layout", () => { + test.use({ viewport: { width: 751, height: 982 } }); + + test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => { + await gotoAppShell(page); + await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); + await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible(); + }); + + test("yields app navigation to the settings split", async ({ page }) => { + await gotoAppShell(page); + await page.getByTestId("sidebar-settings").click(); + + await expect(page.getByTestId("settings-sidebar")).toBeVisible(); + await expect(page.getByTestId("settings-detail-pane")).toBeVisible(); + await expect(page.getByTestId("sidebar-settings")).not.toBeVisible(); + }); + + test("yields app navigation to the Explorer", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + await openWorkspaceFromSidebar(page, workspace.workspaceId); + + await page.getByTestId("workspace-explorer-toggle").first().click(); + await expect( + page.getByTestId("explorer-tab-files").filter({ visible: true }).first(), + ).toBeVisible(); + await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); + await expect + .poll( + async () => + (await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0, + ) + .toBeGreaterThanOrEqual(400); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 1c0449fd3..a61a603fa 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -16,7 +16,7 @@ import { useState, useSyncExternalStore, } from "react"; -import { View } from "react-native"; +import { useWindowDimensions, View } from "react-native"; import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -38,6 +38,10 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; +import { + canDesktopAppSidebarShare, + resolveDesktopAppContentMinimum, +} from "@/components/desktop-sidebar-layout"; import { isNative, isWeb } from "@/constants/platform"; import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context"; import { SessionProvider } from "@/contexts/session-context"; @@ -91,6 +95,7 @@ import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; +import { WindowChromeProvider, WindowChromeRegion } from "@/utils/desktop-window"; import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -409,6 +414,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); + const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const sidebarWidth = usePanelStore((state) => state.sidebarWidth); + const explorerWidth = usePanelStore((state) => state.explorerWidth); + const { width: viewportWidth } = useWindowDimensions(); const cycleTheme = useCallback(() => { const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName); @@ -454,22 +464,46 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon useActiveWorktreeNewAction(); useGlobalNewWorkspaceAction(); + const appContentMinimumWidth = resolveDesktopAppContentMinimum({ + isSettingsRoute: pathname.includes("/settings"), + isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen, + requestedExplorerWidth: explorerWidth, + viewportWidth, + }); + const desktopSidebarRendered = + !isCompactLayout && + chromeEnabled && + !isFocusModeEnabled && + isDesktopAgentListOpen && + canDesktopAppSidebarShare({ + contentMinimumWidth: appContentMinimumWidth, + requestedSidebarWidth: sidebarWidth, + viewportWidth, + }); + const contentWindowChromeCorners = desktopSidebarRendered ? "top-right" : "both"; const sidebarChrome = ( ); - const workspaceChrome = ( - {!isCompactLayout ? sidebarChrome : null} + {!isCompactLayout ? ( + + {sidebarChrome} + + ) : null} {isCompactLayout && chromeEnabled ? ( - {children} + + {children} + ) : ( - {children} + + {children} + )} ); @@ -863,13 +897,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) { function RootProviders({ children }: { children: ReactNode }) { return ( - - - - {children} - - - + + + + + {children} + + + + ); } diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index b44e76928..473278229 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { isWeb } from "@/constants/platform"; +import { WindowChromeRootRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; interface AttachmentLightboxProps { metadata: AttachmentMetadata | null; @@ -38,15 +39,18 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp }; }, [metadata, onClose]); - const closeButtonStyle = useMemo( + const closeButtonRowStyle = useMemo( () => [ - styles.closeButton, + styles.closeButtonRow, { top: insets.top + theme.spacing[3], - right: insets.right + theme.spacing[3], }, ], - [insets.top, insets.right, theme.spacing], + [insets.top, theme.spacing], + ); + const closeButtonStyle = useMemo( + () => [styles.closeButton, { marginRight: insets.right + theme.spacing[3] }], + [insets.right, theme.spacing], ); const handleImageError = useCallback(() => setErrored(true), []); @@ -61,42 +65,46 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp return ( - - - - - {hasError ? ( - {t("message.attachments.imageLoadFailed")} - ) : ( - - - - )} - + + - - + style={styles.backdrop} + /> + + + {hasError ? ( + {t("message.attachments.imageLoadFailed")} + ) : ( + + + + )} + + + + + + + - + ); } @@ -129,6 +137,13 @@ const styles = StyleSheet.create((theme) => ({ bottom: 0, pointerEvents: "box-none", }, + closeButtonRow: { + position: "absolute", + left: 0, + right: 0, + alignItems: "flex-end", + pointerEvents: "box-none", + }, imageArea: { flex: 1, alignItems: "center", @@ -148,7 +163,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, closeButton: { - position: "absolute", width: 32, height: 32, borderRadius: 16, diff --git a/packages/app/src/components/desktop-sidebar-layout.test.ts b/packages/app/src/components/desktop-sidebar-layout.test.ts new file mode 100644 index 000000000..ca9d8a97f --- /dev/null +++ b/packages/app/src/components/desktop-sidebar-layout.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + canDesktopAppSidebarShare, + resolveDesktopAppContentMinimum, + resolveDesktopExplorerWidth, + resolveDesktopSidebarWidth, +} from "@/components/desktop-sidebar-layout"; + +describe("desktop sidebar layout", () => { + it("clamps a persisted wide sidebar to preserve the center pane", () => { + const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 }); + expect(atHalfScreen).toBe(351); + expect(751 - atHalfScreen).toBe(400); + + const atBreakpoint = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 720 }); + expect(atBreakpoint).toBe(320); + expect(720 - atBreakpoint).toBe(400); + + expect(resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 1440 })).toBe(600); + }); + + it("keeps a temporarily narrow explorer render-only", () => { + expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 751 })).toBe(351); + expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 1440 })).toBe(400); + }); + + it("yields app navigation when settings or Explorer need the shell width", () => { + const settingsMinimum = resolveDesktopAppContentMinimum({ + isSettingsRoute: true, + isWorkspaceExplorerOpen: false, + requestedExplorerWidth: 400, + viewportWidth: 751, + }); + expect(settingsMinimum).toBe(720); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth: settingsMinimum, + requestedSidebarWidth: 320, + viewportWidth: 751, + }), + ).toBe(false); + + const explorerMinimum = resolveDesktopAppContentMinimum({ + isSettingsRoute: false, + isWorkspaceExplorerOpen: true, + requestedExplorerWidth: 400, + viewportWidth: 751, + }); + expect(explorerMinimum).toBe(751); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth: explorerMinimum, + requestedSidebarWidth: 320, + viewportWidth: 751, + }), + ).toBe(false); + expect( + canDesktopAppSidebarShare({ + contentMinimumWidth: resolveDesktopAppContentMinimum({ + isSettingsRoute: false, + isWorkspaceExplorerOpen: true, + requestedExplorerWidth: 400, + viewportWidth: 1120, + }), + requestedSidebarWidth: 320, + viewportWidth: 1120, + }), + ).toBe(true); + }); +}); diff --git a/packages/app/src/components/desktop-sidebar-layout.ts b/packages/app/src/components/desktop-sidebar-layout.ts new file mode 100644 index 000000000..e71b42273 --- /dev/null +++ b/packages/app/src/components/desktop-sidebar-layout.ts @@ -0,0 +1,78 @@ +import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout"; +import { + MAX_EXPLORER_SIDEBAR_WIDTH, + MAX_SIDEBAR_WIDTH, + MIN_EXPLORER_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, +} from "@/stores/panel-store"; + +export const MIN_DESKTOP_CENTER_WIDTH = 400; + +function resolveDesktopPanelWidth(input: { + requestedWidth: number; + viewportWidth: number; + minimumWidth: number; + maximumWidth: number; +}): number { + "worklet"; + const maximumVisibleWidth = Math.max( + input.minimumWidth, + Math.min(input.maximumWidth, input.viewportWidth - MIN_DESKTOP_CENTER_WIDTH), + ); + return Math.max(input.minimumWidth, Math.min(maximumVisibleWidth, input.requestedWidth)); +} + +export function resolveDesktopSidebarWidth(input: { + requestedWidth: number; + viewportWidth: number; +}): number { + "worklet"; + return resolveDesktopPanelWidth({ + ...input, + minimumWidth: MIN_SIDEBAR_WIDTH, + maximumWidth: MAX_SIDEBAR_WIDTH, + }); +} + +export function resolveDesktopExplorerWidth(input: { + requestedWidth: number; + viewportWidth: number; +}): number { + "worklet"; + return resolveDesktopPanelWidth({ + ...input, + minimumWidth: MIN_EXPLORER_SIDEBAR_WIDTH, + maximumWidth: MAX_EXPLORER_SIDEBAR_WIDTH, + }); +} + +export function resolveDesktopAppContentMinimum(input: { + isSettingsRoute: boolean; + isWorkspaceExplorerOpen: boolean; + requestedExplorerWidth: number; + viewportWidth: number; +}): number { + const workspaceMinimum = input.isWorkspaceExplorerOpen + ? MIN_DESKTOP_CENTER_WIDTH + + resolveDesktopExplorerWidth({ + requestedWidth: input.requestedExplorerWidth, + viewportWidth: input.viewportWidth, + }) + : 0; + return Math.max(input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0, workspaceMinimum); +} + +export function canDesktopAppSidebarShare(input: { + contentMinimumWidth: number; + requestedSidebarWidth: number; + viewportWidth: number; +}): boolean { + return ( + input.viewportWidth - + resolveDesktopSidebarWidth({ + requestedWidth: input.requestedSidebarWidth, + viewportWidth: input.viewportWidth, + }) >= + input.contentMinimumWidth + ); +} diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 1d964dc7a..c691332e0 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -22,13 +22,7 @@ import { } from "@/git/pull-request-panel"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; -import { - usePanelStore, - selectIsFileExplorerOpen, - MIN_EXPLORER_SIDEBAR_WIDTH, - MAX_EXPLORER_SIDEBAR_WIDTH, - type ExplorerTab, -} from "@/stores/panel-store"; +import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store"; import { useToast } from "@/contexts/toast-context"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; @@ -36,13 +30,13 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { GitDiffPane } from "@/git/diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { RetainedPanelActivity } from "@/components/retained-panel"; import { isWeb } from "@/constants/platform"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; +import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; -const MIN_CHAT_WIDTH = 400; function logExplorerSidebar(_event: string, _details: Record): void {} interface ExplorerSidebarProps { @@ -163,18 +157,16 @@ export function ExplorerSidebar({ isGit, }); const { width: viewportWidth } = useWindowDimensions(); - const startWidthRef = useRef(explorerWidth); - const resizeWidth = useSharedValue(explorerWidth); + const visibleExplorerWidth = resolveDesktopExplorerWidth({ + requestedWidth: explorerWidth, + viewportWidth, + }); + const startWidthRef = useRef(visibleExplorerWidth); + const resizeWidth = useSharedValue(visibleExplorerWidth); useEffect(() => { - const maxWidth = Math.max( - MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - if (explorerWidth > maxWidth) { - setExplorerWidth(maxWidth); - } - }, [explorerWidth, setExplorerWidth, viewportWidth]); + resizeWidth.value = visibleExplorerWidth; + }, [resizeWidth, visibleExplorerWidth]); const handleDesktopClose = useCallback(() => { logExplorerSidebar("handleClose", { @@ -190,22 +182,20 @@ export function ExplorerSidebar({ .enabled(true) .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { - startWidthRef.current = explorerWidth; - resizeWidth.value = explorerWidth; + startWidthRef.current = visibleExplorerWidth; + resizeWidth.value = visibleExplorerWidth; }) .onUpdate((event) => { const newWidth = startWidthRef.current - event.translationX; - const maxWidth = Math.max( - MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); - resizeWidth.value = clampedWidth; + resizeWidth.value = resolveDesktopExplorerWidth({ + requestedWidth: newWidth, + viewportWidth, + }); }) .onEnd(() => { runOnJS(setExplorerWidth)(resizeWidth.value); }), - [explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], + [resizeWidth, setExplorerWidth, viewportWidth, visibleExplorerWidth], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ @@ -300,7 +290,6 @@ function ExplorerSidebarContent({ const { theme } = useUnistyles(); const { t } = useTranslation(); const toast = useToast(); - const padding = useWindowControlsPadding("explorerSidebar"); const canQueryPullRequest = isGit && Boolean(workspaceRoot); const prPane = usePrPaneData({ serverId, @@ -325,15 +314,15 @@ function ExplorerSidebarContent({ [serverId, workspaceId, workspaceRoot], ); - const headerStyle = useMemo( - () => [styles.header, { paddingRight: padding.right }], - [padding.right], - ); - return ( {/* Header with tabs and close button */} - + {isGit && ( @@ -376,7 +365,7 @@ function ExplorerSidebarContent({ )} - + {/* Content based on active tab */} @@ -476,7 +465,6 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingHorizontal: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index ce2ca302f..b8f078e15 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -9,7 +9,7 @@ import { HEADER_TOP_PADDING_MOBILE, useIsCompactFormFactor, } from "@/constants/layout"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; interface ScreenHeaderProps { @@ -18,7 +18,6 @@ interface ScreenHeaderProps { leftStyle?: StyleProp; rightStyle?: StyleProp; borderless?: boolean; - windowControlsPaddingRole?: "header" | "detailHeader"; onRowLayout?: (event: LayoutChangeEvent) => void; } @@ -32,13 +31,11 @@ export function ScreenHeader({ leftStyle, rightStyle, borderless, - windowControlsPaddingRole = "header", onRowLayout, }: ScreenHeaderProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isMobile = useIsCompactFormFactor(); - const padding = useWindowControlsPadding(windowControlsPaddingRole); // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const baseHorizontalPadding = theme.spacing[2]; @@ -47,28 +44,23 @@ export function ScreenHeader({ () => [styles.inner, { paddingTop: insets.top + topPadding }], [insets.top, topPadding], ); - const rowStyle = useMemo( - () => [ - styles.row, - { - paddingLeft: baseHorizontalPadding + padding.left, - paddingRight: baseHorizontalPadding + padding.right, - }, - borderless && styles.borderless, - ], - [baseHorizontalPadding, padding.left, padding.right, borderless], - ); + const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]); const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]); const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]); return ( - + {left} {right} - + ); @@ -88,7 +80,6 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingHorizontal: theme.spacing[2], borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, userSelect: "none", diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index db0145b47..25b957ed8 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -25,6 +25,7 @@ import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-nativ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu"; @@ -50,13 +51,8 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHosts } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspace } from "@/stores/session-store-hooks"; -import { - MAX_SIDEBAR_WIDTH, - MIN_SIDEBAR_WIDTH, - selectIsAgentListOpen, - usePanelStore, -} from "@/stores/panel-store"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; +import { WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { @@ -73,8 +69,6 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; -const MIN_CHAT_WIDTH = 400; - type SidebarTheme = ReturnType["theme"]; interface SidebarSharedProps { @@ -603,6 +597,7 @@ function MobileSidebar({ panelStyle={mobileSidebarInsetStyle} > + - - {({ hovered, pressed }) => ( - - )} - + + + {({ hovered, pressed }) => ( + + )} + + {isInitialLoad && !hasActiveHostFilter ? ( @@ -713,47 +710,47 @@ function DesktopSidebar({ const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const isSessionsActive = pathname.includes("/sessions"); const isSchedulesActive = pathname.includes("/schedules"); - const padding = useWindowControlsPadding("sidebar"); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); const { width: viewportWidth } = useWindowDimensions(); + const visibleSidebarWidth = resolveDesktopSidebarWidth({ + requestedWidth: sidebarWidth, + viewportWidth, + }); - const startWidthRef = useRef(sidebarWidth); - const resizeWidth = useSharedValue(sidebarWidth); + const startWidthRef = useRef(visibleSidebarWidth); + const resizeWidth = useSharedValue(visibleSidebarWidth); useEffect(() => { - resizeWidth.value = sidebarWidth; - }, [sidebarWidth, resizeWidth]); + resizeWidth.value = visibleSidebarWidth; + }, [resizeWidth, visibleSidebarWidth]); const resizeGesture = useMemo( () => Gesture.Pan() .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { - startWidthRef.current = sidebarWidth; - resizeWidth.value = sidebarWidth; + startWidthRef.current = visibleSidebarWidth; + resizeWidth.value = visibleSidebarWidth; }) .onUpdate((event) => { // Dragging right (positive translationX) increases width const newWidth = startWidthRef.current + event.translationX; - const maxWidth = Math.max( - MIN_SIDEBAR_WIDTH, - Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), - ); - const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); - resizeWidth.value = clampedWidth; + resizeWidth.value = resolveDesktopSidebarWidth({ + requestedWidth: newWidth, + viewportWidth, + }); }) .onEnd(() => { runOnJS(setSidebarWidth)(resizeWidth.value); }), - [sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth], + [resizeWidth, setSidebarWidth, viewportWidth, visibleSidebarWidth], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ width: resizeWidth.value, })); - const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]); const desktopSidebarStyle = useMemo( () => [staticStyles.desktopSidebar, resizeAnimatedStyle], [resizeAnimatedStyle], @@ -776,7 +773,7 @@ function DesktopSidebar({ - {padding.top > 0 ? : null} + ({ flex: 1, minHeight: 0, }, - mobileCloseButton: { + mobileCloseButtonRow: { position: "absolute", top: theme.spacing[3], - right: theme.spacing[4], + left: 0, + right: 0, zIndex: 2, + alignItems: "flex-end", + pointerEvents: "box-none", + }, + mobileCloseButton: { + marginRight: theme.spacing[4], width: 32, height: 32, alignItems: "center", diff --git a/packages/app/src/components/split-container-focus.test.ts b/packages/app/src/components/split-container-focus.test.ts new file mode 100644 index 000000000..5e67d784a --- /dev/null +++ b/packages/app/src/components/split-container-focus.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { resolveSplitContainerRoot } from "@/components/split-container-focus"; +import type { SplitNode } from "@/stores/workspace-layout-store"; + +const pane = (id: string): SplitNode => ({ + kind: "pane", + pane: { id, tabIds: [], focusedTabId: null }, +}); +const root: SplitNode = { + kind: "group", + group: { + id: "root", + direction: "horizontal", + children: [pane("left"), pane("right")], + sizes: [0.5, 0.5], + }, +}; + +describe("split focus root", () => { + it("renders only the valid focused pane in focus mode", () => { + expect( + resolveSplitContainerRoot({ root, focusedPaneId: "right", focusModeEnabled: true }), + ).toEqual({ root: pane("right"), usesFallbackStrip: false }); + }); + + it("keeps the full tree and reserves the boundary strip when focus is missing", () => { + expect( + resolveSplitContainerRoot({ root, focusedPaneId: "missing", focusModeEnabled: true }), + ).toEqual({ root, usesFallbackStrip: true }); + }); + + it("keeps normal splits unclaimed", () => { + expect( + resolveSplitContainerRoot({ root, focusedPaneId: "right", focusModeEnabled: false }), + ).toEqual({ root, usesFallbackStrip: false }); + }); +}); diff --git a/packages/app/src/components/split-container-focus.ts b/packages/app/src/components/split-container-focus.ts new file mode 100644 index 000000000..e16f4571d --- /dev/null +++ b/packages/app/src/components/split-container-focus.ts @@ -0,0 +1,21 @@ +import type { SplitNode, SplitPane } from "@/stores/workspace-layout-store"; + +export function resolveSplitContainerRoot(input: { + root: SplitNode; + focusedPaneId: string | null; + focusModeEnabled: boolean | undefined; +}): { root: SplitNode; usesFallbackStrip: boolean } { + if (!input.focusModeEnabled) return { root: input.root, usesFallbackStrip: false }; + const focusedPane = input.focusedPaneId ? findPane(input.root, input.focusedPaneId) : null; + if (!focusedPane) return { root: input.root, usesFallbackStrip: true }; + return { root: { kind: "pane", pane: focusedPane }, usesFallbackStrip: false }; +} + +function findPane(node: SplitNode, paneId: string): SplitPane | null { + if (node.kind === "pane") return node.pane.id === paneId ? node.pane : null; + for (const child of node.group.children) { + const pane = findPane(child, paneId); + if (pane) return pane; + } + return null; +} diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index bf90d1f98..c9e4294f9 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -32,8 +32,14 @@ import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; import { RetainedPanel } from "@/components/retained-panel"; +import { resolveSplitContainerRoot } from "@/components/split-container-focus"; import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { + WindowChromeRegion, + WindowChromeSafeArea, + useWindowChromeCorners, + type WindowChromeCorners, +} from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { computeTabDropPreview, @@ -158,6 +164,7 @@ interface SplitNodeViewProps extends Omit { pane: SplitPane; uiTabs: WorkspaceTab[]; @@ -383,6 +391,8 @@ export function SplitContainer({ renderPaneEmptyState = () => null, focusModeEnabled, }: SplitContainerProps) { + const inheritedWindowChromeCorners = useWindowChromeCorners(); + const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none"; const [activeDragTabId, setActiveDragTabId] = useState(null); const [dropPreview, setDropPreview] = useState(null); const [tabDropPreview, setTabDropPreview] = useState(null); @@ -399,18 +409,16 @@ export function SplitContainer({ ); const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); - - const effectiveRoot = useMemo(() => { - if (!focusModeEnabled) { - return layout.root; - } - const focusedPane = layout.focusedPaneId ? panesById.get(layout.focusedPaneId) : null; - if (!focusedPane) { - return layout.root; - } - return { kind: "pane" as const, pane: focusedPane }; - }, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]); - const renderRoot = useMemo(() => wrapRootPaneForStableMount(effectiveRoot), [effectiveRoot]); + const splitRoot = useMemo( + () => + resolveSplitContainerRoot({ + root: layout.root, + focusedPaneId: layout.focusedPaneId, + focusModeEnabled, + }), + [focusModeEnabled, layout.focusedPaneId, layout.root], + ); + const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]); const handleDragStart = useCallback((event: DragStartEvent) => { const data = asWorkspaceTabDragData(event.active.data.current); @@ -565,6 +573,7 @@ export function SplitContainer({ onDragCancel={handleDragCancel} onDragEnd={handleDragEnd} > + {splitRoot.usesFallbackStrip && } {activeDragTabId ? ( @@ -744,6 +754,7 @@ function SplitNodeView({ showDropZones, dropPreview, tabDropPreview, + windowChromeCorners, }: SplitNodeViewProps) { const groupId = node.kind === "group" ? node.group.id : null; const groupDirection = node.kind === "group" ? node.group.direction : null; @@ -762,41 +773,43 @@ function SplitNodeView({ if (node.kind === "pane") { return ( - + + + ); } @@ -843,6 +856,7 @@ function SplitNodeView({ showDropZones={showDropZones} dropPreview={dropPreview} tabDropPreview={tabDropPreview} + windowChromeCorners={windowChromeCorners} /> {index < node.group.children.length - 1 ? ( @@ -898,7 +912,6 @@ function SplitPaneView({ const { theme: _theme } = useUnistyles(); const paneRef = useRef(null); const stableOnFocusPane = useStableEvent(onFocusPane); - const padding = useWindowControlsPadding("tabRow"); const paneState = useMemo( () => deriveWorkspacePaneState({ @@ -995,15 +1008,11 @@ function SplitPaneView({ () => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }), [onSplitPaneEmpty, paneId], ); - const paneTabsStyle = useMemo( - () => [styles.paneTabs, { paddingLeft: padding.left, paddingRight: padding.right }], - [padding.left, padding.right], - ); return ( - + - + {mountedPaneTabIds.length > 0 diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index cf77d5dc0..676f1bb86 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -15,6 +15,13 @@ export const HEADER_TOP_PADDING_MOBILE = 8; export const MAX_CONTENT_WIDTH = 820; export const COMPACT_FORM_FACTOR_WIDTH = 500; +// Settings uses the canonical desktop list + detail layout. Its sidebar and +// detail target must fit together before it can share width with app navigation. +export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320; +export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400; +export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH = + SETTINGS_DESKTOP_SIDEBAR_WIDTH + SETTINGS_DESKTOP_DETAIL_MIN_WIDTH; + // Desktop app constants for macOS traffic light buttons // These buttons (close/minimize/maximize) overlay the top-left corner export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78; diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index f76a2c528..768552b92 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -97,6 +97,7 @@ export interface DesktopWindowControlsOverlayUpdate { export interface DesktopWindowBridge { label?: string; toggleMaximize?: () => Promise; + setFullscreen?: (fullscreen: boolean) => Promise; isFullscreen?: () => Promise; updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise; onResized?: ( diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index 2a6cdf950..ca0987756 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -3,6 +3,7 @@ import { Pressable, StyleSheet, View } from "react-native"; import { GestureDetector, type GestureType } from "react-native-gesture-handler"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { isWeb } from "@/constants/platform"; +import { WindowChromeRootRegion } from "@/utils/desktop-window"; import { usePanelStore, type MobilePanelView } from "@/stores/panel-store"; import { getMobilePanelFrame } from "./model"; import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider"; @@ -82,7 +83,7 @@ export function MobilePanelOverlay({ - {children} + {children} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index b5e2bf16e..6267a4fa1 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -57,7 +57,7 @@ import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; import { confirmDialog } from "@/utils/confirm-dialog"; import { BackHeader } from "@/components/headers/back-header"; import { ScreenHeader } from "@/components/headers/screen-header"; @@ -97,7 +97,7 @@ import { } from "@/screens/settings/host-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; -import { useIsCompactFormFactor } from "@/constants/layout"; +import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { type EnableBuiltInDaemonOption, @@ -992,7 +992,6 @@ function SettingsSidebar({ const isDesktopApp = isElectronRuntime(); const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp); const insets = useSafeAreaInsets(); - const padding = useWindowControlsPadding("sidebar"); const isDesktop = layout === "desktop"; const outerContainerStyle = useMemo( () => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer], @@ -1005,7 +1004,6 @@ function SettingsSidebar({ const selectedSectionId = view.kind === "section" ? view.section : null; const selectedHostSection = view.kind === "host" ? view.section : null; const isProjectsSelected = view.kind === "projects" || view.kind === "project"; - const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]); const sidebarBody = ( <> @@ -1087,7 +1085,7 @@ function SettingsSidebar({ - {padding.top > 0 ? : null} + + + + + {detailHeader.title} + {detailHeader.titleAccessory} + + ) : null; + const addHostModals = ( <> - - - - - - - - {detailHeader.title} - - {detailHeader.titleAccessory} - - ) : null - } - leftStyle={desktopStyles.detailLeft} + + - - {content} - - + + + + + + {content} + + + {addHostModals} @@ -1654,7 +1650,7 @@ const desktopStyles = StyleSheet.create((theme) => ({ const sidebarStyles = StyleSheet.create((theme) => ({ desktopContainer: { - width: 320, + width: SETTINGS_DESKTOP_SIDEBAR_WIDTH, borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index b35b3a1b4..1ae7c0778 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -6,6 +6,7 @@ import { useRef, useState, type ReactElement, + type ComponentProps, type ReactNode, } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; @@ -61,6 +62,7 @@ import { import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { SplitContainer } from "@/components/split-container"; import { RetainedPanel } from "@/components/retained-panel"; +import { WindowChromeRegion } from "@/utils/desktop-window"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { WorkspaceActions } from "@/git/workspace-actions"; import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; @@ -1566,6 +1568,46 @@ function shouldShowWorkspaceExplorerSidebar(input: { return !input.isMobile && input.isRouteFocused && shouldShowWorkspaceScreenHeader(input); } +interface WorkspaceChromeRowProps extends Omit< + ComponentProps, + "workspaceRoot" +> { + children: ReactNode; + explorerOpen: boolean; + portalHostName: string; + showExplorerSidebar: boolean; + workspaceRoot: string | null; +} + +function WorkspaceChromeRow({ + children, + explorerOpen, + portalHostName, + showExplorerSidebar, + workspaceRoot, + ...explorerProps +}: WorkspaceChromeRowProps) { + const explorerRendered = showExplorerSidebar && explorerOpen && workspaceRoot !== null; + + return ( + + + + {children} + + + + + + {showExplorerSidebar && workspaceRoot ? ( + + + + ) : null} + + ); +} + function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { if (!serverId || !workspaceId) { return null; @@ -3624,23 +3666,18 @@ function WorkspaceScreenContent({ workspaceId={normalizedWorkspaceId} isRouteFocused={isRouteFocused} /> - - - {workspaceCenterColumn} - - - - - {showExplorerSidebar && workspaceDirectory ? ( - - ) : null} - + + {workspaceCenterColumn} + { - it("keeps mac traffic-light padding available when the app window is not fullscreen", () => { +describe("window chrome", () => { + it("has no corner obstruction outside Electron or in fullscreen", () => { expect( - resolveRawWindowControlsPadding({ isElectron: true, isMac: true, isFullscreen: false }), - ).toEqual({ - left: 78, - right: 0, - top: 45, - }); + resolveWindowChromeObstruction({ isElectron: false, isMac: true, isFullscreen: false }), + ).toEqual({ topLeft: null, topRight: null }); + expect( + resolveWindowChromeObstruction({ isElectron: true, isMac: true, isFullscreen: true }), + ).toEqual({ topLeft: null, topRight: null }); }); - it("keeps Windows and Linux window-control padding available when the app window is not fullscreen", () => { + it("places native controls in their physical top corner", () => { expect( - resolveRawWindowControlsPadding({ isElectron: true, isMac: false, isFullscreen: false }), - ).toEqual({ - left: 0, - right: 140, - top: 48, - }); + resolveWindowChromeObstruction({ isElectron: true, isMac: true, isFullscreen: false }), + ).toEqual({ topLeft: { width: 78, height: 45 }, topRight: null }); + expect( + resolveWindowChromeObstruction({ isElectron: true, isMac: false, isFullscreen: false }), + ).toEqual({ topLeft: null, topRight: { width: 140, height: 48 } }); }); - it("does not reserve window-control padding when the app window is fullscreen", () => { + it("insets and reserves only claimed corners", () => { + const obstruction = { topLeft: { width: 80, height: 28 }, topRight: { width: 48, height: 32 } }; expect( - resolveRawWindowControlsPadding({ isElectron: true, isMac: true, isFullscreen: true }), - ).toEqual({ - left: 0, - right: 0, - top: 0, - }); + resolveWindowChromeSafeArea({ obstruction, corners: "top-left", placement: "inline" }), + ).toEqual({ paddingLeft: 80, paddingRight: 0 }); + expect( + resolveWindowChromeSafeArea({ obstruction, corners: "top-right", placement: "below" }), + ).toEqual({ height: 32 }); + expect( + resolveWindowChromeSafeArea({ obstruction, corners: "both", placement: "below" }), + ).toEqual({ height: 32 }); + expect( + resolveWindowChromeSafeArea({ obstruction, corners: "top-right", placement: "inline" }), + ).toEqual({ paddingLeft: 0, paddingRight: 48 }); }); - it("pads the main header for window controls when the app sidebar is closed", () => { - expect( - resolveWindowControlsPadding({ - role: "header", - rawPadding, - sidebarClosed: true, - explorerOpen: false, - focusModeEnabled: false, - }), - ).toEqual({ - left: 80, - right: 48, - top: 0, - }); - }); - - it("does not add left padding to detail headers with their own sidebar", () => { - expect( - resolveWindowControlsPadding({ - role: "detailHeader", - rawPadding, - sidebarClosed: true, - explorerOpen: false, - focusModeEnabled: false, - }), - ).toEqual({ - left: 0, - right: 48, - top: 0, - }); - }); - - it("pads a focus-mode tab row away from mac traffic lights even when the sidebar is logically open", () => { - expect( - resolveWindowControlsPadding({ - role: "tabRow", - rawPadding, - sidebarClosed: false, - explorerOpen: false, - focusModeEnabled: true, - }), - ).toEqual({ - left: 80, - right: 48, - top: 0, - }); - }); - - it("pads a focus-mode tab row away from right-side window controls even when the explorer is logically open", () => { - expect( - resolveWindowControlsPadding({ - role: "tabRow", - rawPadding: { left: 0, right: 140, top: 48 }, - sidebarClosed: true, - explorerOpen: true, - focusModeEnabled: true, - }), - ).toEqual({ - left: 0, - right: 140, - top: 0, - }); + it("intersects identical and empty corner claims", () => { + expect(intersectWindowChromeCorners("both", "both")).toBe("both"); + expect(intersectWindowChromeCorners("top-left", "top-left")).toBe("top-left"); + expect(intersectWindowChromeCorners("none", "both")).toBe("none"); + expect(intersectWindowChromeCorners("both", "none")).toBe("none"); + expect(intersectWindowChromeCorners("both", "top-left")).toBe("top-left"); + expect(intersectWindowChromeCorners("top-right", "both")).toBe("top-right"); + expect(intersectWindowChromeCorners("top-left", "top-right")).toBe("none"); }); }); diff --git a/packages/app/src/utils/desktop-window.ts b/packages/app/src/utils/desktop-window.ts deleted file mode 100644 index c939a5745..000000000 --- a/packages/app/src/utils/desktop-window.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { - getIsElectronRuntimeMac, - getIsElectronRuntime, - DESKTOP_TRAFFIC_LIGHT_WIDTH, - DESKTOP_TRAFFIC_LIGHT_HEIGHT, - DESKTOP_WINDOW_CONTROLS_WIDTH, - DESKTOP_WINDOW_CONTROLS_HEIGHT, -} from "@/constants/layout"; -import { getDesktopWindow } from "@/desktop/electron/window"; -import { usePanelStore } from "@/stores/panel-store"; -import { isNative } from "@/constants/platform"; - -interface RawWindowControlsPadding { - left: number; - right: number; - top: number; -} - -type WindowControlsPaddingRole = - | "sidebar" - | "header" - | "detailHeader" - | "tabRow" - | "explorerSidebar"; - -// Module-level cache so hook remounts (e.g., on navigation) don't briefly -// fall back to the default `false` while the async fullscreen check resolves. -// Without this, in fullscreen the sidebar flashes with traffic-light padding -// on first frame and then snaps to 0 once the async read completes. -let cachedIsFullscreen = false; -const fullscreenSubscribers = new Set<(value: boolean) => void>(); -let fullscreenSubscriptionStarted = false; - -function setCachedFullscreen(value: boolean) { - if (cachedIsFullscreen === value) return; - cachedIsFullscreen = value; - for (const sub of fullscreenSubscribers) { - sub(value); - } -} - -function startFullscreenSubscription() { - if (fullscreenSubscriptionStarted) return; - if (isNative || !getIsElectronRuntime()) return; - fullscreenSubscriptionStarted = true; - - void (async () => { - const win = getDesktopWindow(); - if (!win) return; - - if (typeof win.isFullscreen === "function") { - try { - setCachedFullscreen(await win.isFullscreen()); - } catch (error) { - console.warn("[DesktopWindow] Failed to read fullscreen state", error); - } - } - - if (typeof win.onResized !== "function") return; - - try { - await win.onResized(async () => { - if (typeof win.isFullscreen !== "function") return; - try { - setCachedFullscreen(await win.isFullscreen()); - } catch (error) { - console.warn("[DesktopWindow] Failed to read fullscreen state", error); - } - }); - } catch (error) { - console.warn("[DesktopWindow] Failed to subscribe to resize", error); - } - })(); -} - -function useRawWindowControlsPadding(): RawWindowControlsPadding { - const [isFullscreen, setIsFullscreen] = useState(cachedIsFullscreen); - - useEffect(() => { - startFullscreenSubscription(); - // Sync to any value that resolved between render and effect. - setIsFullscreen(cachedIsFullscreen); - fullscreenSubscribers.add(setIsFullscreen); - return () => { - fullscreenSubscribers.delete(setIsFullscreen); - }; - }, []); - - return resolveRawWindowControlsPadding({ - isElectron: getIsElectronRuntime(), - isMac: getIsElectronRuntimeMac(), - isFullscreen, - }); -} - -export function resolveRawWindowControlsPadding(input: { - isElectron: boolean; - isMac: boolean; - isFullscreen: boolean; -}): RawWindowControlsPadding { - if (!input.isElectron || input.isFullscreen) { - return { left: 0, right: 0, top: 0 }; - } - - if (input.isMac) { - return { - left: DESKTOP_TRAFFIC_LIGHT_WIDTH, - right: 0, - top: DESKTOP_TRAFFIC_LIGHT_HEIGHT, - }; - } - - return { - left: 0, - right: DESKTOP_WINDOW_CONTROLS_WIDTH, - top: DESKTOP_WINDOW_CONTROLS_HEIGHT, - }; -} - -export function useWindowControlsPadding(role: WindowControlsPaddingRole): { - left: number; - right: number; - top: number; -} { - const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen); - const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); - const rawPadding = useRawWindowControlsPadding(); - const sidebarClosed = !sidebarOpen; - - const { left, right, top } = resolveWindowControlsPadding({ - role, - rawPadding, - sidebarClosed, - explorerOpen, - focusModeEnabled, - }); - - return useMemo(() => ({ left, right, top }), [left, right, top]); -} - -export function resolveWindowControlsPadding(input: { - role: WindowControlsPaddingRole; - rawPadding: RawWindowControlsPadding; - sidebarClosed: boolean; - explorerOpen: boolean; - focusModeEnabled: boolean; -}): RawWindowControlsPadding { - if (input.role === "sidebar") { - return { - left: input.rawPadding.left, - right: 0, - top: input.rawPadding.top, - }; - } - - if (input.role === "header") { - return { - left: input.sidebarClosed ? input.rawPadding.left : 0, - right: input.explorerOpen ? 0 : input.rawPadding.right, - top: 0, - }; - } - - if (input.role === "detailHeader") { - return { - left: 0, - right: input.rawPadding.right, - top: 0, - }; - } - - if (input.role === "tabRow") { - return { - left: input.focusModeEnabled ? input.rawPadding.left : 0, - right: input.focusModeEnabled ? input.rawPadding.right : 0, - top: 0, - }; - } - - return { - left: 0, - right: input.rawPadding.right, - top: 0, - }; -} diff --git a/packages/app/src/utils/desktop-window.tsx b/packages/app/src/utils/desktop-window.tsx new file mode 100644 index 000000000..4af5e8733 --- /dev/null +++ b/packages/app/src/utils/desktop-window.tsx @@ -0,0 +1,238 @@ +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { View, type ViewProps } from "react-native"; +import { + DESKTOP_TRAFFIC_LIGHT_HEIGHT, + DESKTOP_TRAFFIC_LIGHT_WIDTH, + DESKTOP_WINDOW_CONTROLS_HEIGHT, + DESKTOP_WINDOW_CONTROLS_WIDTH, + getIsElectronRuntime, + getIsElectronRuntimeMac, +} from "@/constants/layout"; +import { getDesktopWindow } from "@/desktop/electron/window"; +import { isNative } from "@/constants/platform"; + +export type WindowChromeCorners = "none" | "top-left" | "top-right" | "both"; +type WindowChromeSafeAreaPlacement = "inline" | "below"; + +interface WindowChromeCornerObstruction { + width: number; + height: number; +} + +interface WindowChromeObstruction { + topLeft: WindowChromeCornerObstruction | null; + topRight: WindowChromeCornerObstruction | null; +} + +type WindowChromeSafeAreaStyle = { height: number } | { paddingLeft: number; paddingRight: number }; + +const EMPTY_OBSTRUCTION: WindowChromeObstruction = { topLeft: null, topRight: null }; +const WindowChromeContext = createContext(EMPTY_OBSTRUCTION); +const WindowChromeCornersContext = createContext("none"); + +function windowChromeCornersFromFlags(topLeft: boolean, topRight: boolean): WindowChromeCorners { + if (topLeft && topRight) return "both"; + if (topLeft) return "top-left"; + if (topRight) return "top-right"; + return "none"; +} + +export function intersectWindowChromeCorners( + inherited: WindowChromeCorners, + declared: WindowChromeCorners, +): WindowChromeCorners { + const inheritedTopLeft = inherited === "top-left" || inherited === "both"; + const inheritedTopRight = inherited === "top-right" || inherited === "both"; + const declaredTopLeft = declared === "top-left" || declared === "both"; + const declaredTopRight = declared === "top-right" || declared === "both"; + return windowChromeCornersFromFlags( + inheritedTopLeft && declaredTopLeft, + inheritedTopRight && declaredTopRight, + ); +} + +export function resolveWindowChromeObstruction(input: { + isElectron: boolean; + isMac: boolean; + isFullscreen: boolean; +}): WindowChromeObstruction { + if (!input.isElectron || input.isFullscreen) return EMPTY_OBSTRUCTION; + if (input.isMac) { + return { + topLeft: { width: DESKTOP_TRAFFIC_LIGHT_WIDTH, height: DESKTOP_TRAFFIC_LIGHT_HEIGHT }, + topRight: null, + }; + } + return { + topLeft: null, + topRight: { width: DESKTOP_WINDOW_CONTROLS_WIDTH, height: DESKTOP_WINDOW_CONTROLS_HEIGHT }, + }; +} + +export function resolveWindowChromeSafeArea(input: { + obstruction: WindowChromeObstruction; + corners: WindowChromeCorners; + placement: WindowChromeSafeAreaPlacement; +}): WindowChromeSafeAreaStyle { + const ownsTopLeft = input.corners === "top-left" || input.corners === "both"; + const ownsTopRight = input.corners === "top-right" || input.corners === "both"; + const topLeft = ownsTopLeft ? input.obstruction.topLeft : null; + const topRight = ownsTopRight ? input.obstruction.topRight : null; + if (input.placement === "below") { + return { height: Math.max(topLeft?.height ?? 0, topRight?.height ?? 0) }; + } + return { paddingLeft: topLeft?.width ?? 0, paddingRight: topRight?.width ?? 0 }; +} + +export function WindowChromeProvider({ children }: { children: ReactNode }) { + const [isElectronReady, setIsElectronReady] = useState(getIsElectronRuntime); + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + let active = true; + let dispose: (() => void) | undefined; + let connecting = false; + let retryCount = 0; + let retryTimer: ReturnType | undefined; + + function scheduleRetry(warnOnExhaustion = false) { + if (!active || dispose || retryTimer) return; + if (retryCount >= 40) { + if (warnOnExhaustion) { + console.warn("[DesktopWindow] Chrome bridge unavailable; window controls may overlap UI"); + } + return; + } + retryCount += 1; + retryTimer = setTimeout(() => { + retryTimer = undefined; + connect(); + }, 250); + } + + function connect() { + if (!active || dispose || connecting) return; + if (!getIsElectronRuntime()) return scheduleRetry(); + const desktopWindow = getDesktopWindow(); + if ( + !desktopWindow || + typeof desktopWindow.isFullscreen !== "function" || + typeof desktopWindow.onResized !== "function" + ) + return scheduleRetry(true); + const readFullscreen = desktopWindow.isFullscreen; + const subscribeToResized = desktopWindow.onResized; + connecting = true; + void (async () => { + async function syncFullscreen() { + try { + const fullscreen = await readFullscreen(); + if (active) setIsFullscreen(fullscreen); + } catch (error) { + if (active) console.warn("[DesktopWindow] Failed to read fullscreen state", error); + } + } + try { + const nextDispose = await subscribeToResized(syncFullscreen); + if (!active) return nextDispose(); + dispose = nextDispose; + setIsElectronReady(true); + await syncFullscreen(); + } catch (error) { + if (active) console.warn("[DesktopWindow] Failed to subscribe to resize", error); + } finally { + connecting = false; + if (!dispose) scheduleRetry(); + } + })(); + } + + if (!isNative) connect(); + + return () => { + active = false; + if (retryTimer) clearTimeout(retryTimer); + dispose?.(); + }; + }, []); + + const obstruction = useMemo( + () => + resolveWindowChromeObstruction({ + isElectron: isElectronReady, + isMac: getIsElectronRuntimeMac(), + isFullscreen, + }), + [isElectronReady, isFullscreen], + ); + return ( + + + {children} + + + ); +} + +/** Narrows inherited corner ownership to the corners occupied by this child surface. */ +export function WindowChromeRegion({ + corners, + children, +}: { + corners: WindowChromeCorners; + children: ReactNode; +}) { + const inheritedCorners = useContext(WindowChromeCornersContext); + const ownedCorners = intersectWindowChromeCorners(inheritedCorners, corners); + return ( + + {children} + + ); +} + +/** Restarts ownership for a new physical viewport such as a Modal or full-window overlay. */ +export function WindowChromeRootRegion({ + corners, + children, +}: { + corners: WindowChromeCorners; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useWindowChromeCorners(): WindowChromeCorners { + return useContext(WindowChromeCornersContext); +} + +type WindowChromeSafeAreaProps = ViewProps & { + placement: WindowChromeSafeAreaPlacement; + horizontalPadding?: number; +}; + +export function WindowChromeSafeArea({ + placement, + horizontalPadding = 0, + style, + ...props +}: WindowChromeSafeAreaProps) { + const obstruction = useContext(WindowChromeContext); + const corners = useContext(WindowChromeCornersContext); + const safeAreaStyle = useMemo(() => { + const resolved = resolveWindowChromeSafeArea({ obstruction, corners, placement }); + if (placement === "below") return resolved; + const paddingLeft = "paddingLeft" in resolved ? resolved.paddingLeft : 0; + const paddingRight = "paddingRight" in resolved ? resolved.paddingRight : 0; + return { + paddingLeft: paddingLeft + horizontalPadding, + paddingRight: paddingRight + horizontalPadding, + }; + }, [corners, horizontalPadding, obstruction, placement]); + const combinedStyle = useMemo(() => [style, safeAreaStyle], [safeAreaStyle, style]); + return ; +} diff --git a/packages/desktop/package.json b/packages/desktop/package.json index eb916638c..bcbe566cc 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -20,6 +20,7 @@ "capture-harness": "./capture-harness/run.sh", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", + "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", "test": "vitest run", "typecheck": "tsgo --noEmit -p tsconfig.json" }, diff --git a/packages/desktop/scripts/verify-electron-cdp.mjs b/packages/desktop/scripts/verify-electron-cdp.mjs index 0a795c2a7..d37e13f41 100644 --- a/packages/desktop/scripts/verify-electron-cdp.mjs +++ b/packages/desktop/scripts/verify-electron-cdp.mjs @@ -3,9 +3,11 @@ import path from "node:path"; import process from "node:process"; import { chromium } from "playwright"; -const CDP_URL = process.env.CDP_URL ?? "http://127.0.0.1:9223"; +const CDP_PORT = process.env.PASEO_ELECTRON_REMOTE_DEBUGGING_PORT ?? "9223"; +const EXPO_PORT = process.env.EXPO_PORT ?? "8082"; +const CDP_URL = process.env.CDP_URL ?? `http://127.0.0.1:${CDP_PORT}`; const OUTPUT_DIR = process.env.ELECTRON_VERIFY_OUTPUT_DIR ?? "/tmp/electron-verification"; -const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? "localhost:8081"; +const APP_URL_FRAGMENT = process.env.ELECTRON_VERIFY_APP_URL_FRAGMENT ?? `localhost:${EXPO_PORT}`; const REQUIRED_DESKTOP_KEYS = ["invoke", "events", "window", "dialog", "notification", "opener"]; const INTERACTIVE_SELECTOR = [ "button", @@ -32,10 +34,6 @@ function assert(condition, message) { } } -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - async function ensureDir(dirPath) { await fs.mkdir(dirPath, { recursive: true }); } @@ -46,6 +44,96 @@ async function captureScreenshot(page, fileName) { return filePath; } +function rectsIntersect(left, right) { + return ( + left.left < right.left + right.width && + left.left + left.width > right.left && + left.top < right.top + right.height && + left.top + left.height > right.top + ); +} + +function getWindowChromeObstruction(platform, innerWidth) { + if (platform === "darwin") { + return { corner: "top-left", left: 0, top: 0, width: 78, height: 45 }; + } + return { + corner: "top-right", + left: innerWidth - 140, + top: 0, + width: 140, + height: 48, + }; +} + +async function inspectSettingsGeometry(page) { + return page.evaluate(() => { + function rect(selector) { + const element = document.querySelector(selector); + if (!(element instanceof HTMLElement)) return null; + const bounds = element.getBoundingClientRect(); + return { + left: bounds.left, + top: bounds.top, + width: bounds.width, + height: bounds.height, + }; + } + + const title = document.querySelector('[data-testid="settings-detail-header-title"]'); + const headerLeft = title instanceof HTMLElement ? title.parentElement : null; + const headerLeftBounds = headerLeft?.getBoundingClientRect() ?? null; + + return { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + sidebarRect: rect('[data-testid="settings-sidebar"]'), + detailPaneRect: rect('[data-testid="settings-detail-pane"]'), + outerAppSidebarSettingsRect: rect('[data-testid="sidebar-settings"]'), + backButtonRect: rect('[data-testid="settings-back-to-workspace"]'), + detailTitleRect: rect('[data-testid="settings-detail-header-title"]'), + detailHeaderLeftRect: headerLeftBounds + ? { + left: headerLeftBounds.left, + top: headerLeftBounds.top, + width: headerLeftBounds.width, + height: headerLeftBounds.height, + } + : null, + }; + }); +} + +function settingsGeometryClearsWindowChrome(geometry, platform) { + const obstruction = getWindowChromeObstruction(platform, geometry.innerWidth); + const consumer = platform === "darwin" ? geometry.backButtonRect : geometry.detailHeaderLeftRect; + return Boolean(consumer && !rectsIntersect(consumer, obstruction)); +} + +async function readBridgeFullscreen(page) { + return page.evaluate( + async () => + (await window.paseoDesktop?.window?.getCurrentWindow?.()?.isFullscreen?.()) === true, + ); +} + +async function setNativeFullscreen(page, fullscreen) { + await page.evaluate(async (nextFullscreen) => { + const win = window.paseoDesktop?.window?.getCurrentWindow?.(); + if (typeof win?.setFullscreen !== "function") throw new Error("setFullscreen is unavailable"); + await win.setFullscreen(nextFullscreen); + }, fullscreen); +} + +async function waitForBridgeFullscreen(page, expected) { + for (let attempt = 0; attempt < 50; attempt += 1) { + if ((await readBridgeFullscreen(page)) === expected) return; + await page.waitForTimeout(200); + } + throw new Error(`Timed out waiting for fullscreen=${expected}`); +} + async function inspectTitlebarRegions(page) { return page.evaluate((interactiveSelector) => { const nodes = Array.from(document.querySelectorAll("*")); @@ -259,111 +347,152 @@ async function inspectTitlebarRegions(page) { }, INTERACTIVE_SELECTOR); } -async function inspectFullscreenResizer(page) { - const session = await page.context().newCDPSession(page); - let windowId = null; - let initialBounds = null; - let fullscreenEntered = false; +async function inspectFullscreenWindowChrome(page, platform) { + const initiallyFullscreen = await readBridgeFullscreen(page); try { - const windowInfo = await session.send("Browser.getWindowForTarget"); - windowId = windowInfo.windowId; - initialBounds = await session.send("Browser.getWindowBounds", { windowId }); - await session.send("Browser.setWindowBounds", { - windowId, - bounds: { windowState: "fullscreen" }, - }); - fullscreenEntered = true; - await page.waitForTimeout(1000); + assert(!initiallyFullscreen, "Electron verifier requires a non-fullscreen QA window"); + const before = await inspectSettingsGeometry(page); + await setNativeFullscreen(page, true); + await waitForBridgeFullscreen(page, true); const details = await page.evaluate(async () => { - const bridge = window.paseoDesktop?.window; + const bridge = window.paseoDesktop?.window?.getCurrentWindow?.(); const bridgeFullscreen = typeof bridge?.isFullscreen === "function" ? await bridge.isFullscreen() : null; - const visibleNoDragResizers = Array.from(document.querySelectorAll("*")) - .filter((node) => node instanceof HTMLElement) - .filter((node) => { - const element = node; - const rect = element.getBoundingClientRect(); - const style = window.getComputedStyle(element); - const appRegion = - style.webkitAppRegion || style.getPropertyValue("-webkit-app-region") || "none"; - return ( - rect.width > 0 && - rect.height > 0 && - style.display !== "none" && - style.visibility !== "hidden" && - style.opacity !== "0" && - appRegion === "no-drag" && - style.position === "absolute" && - Math.abs(rect.height - 4) <= 1 && - rect.top < 220 - ); - }) - .map((node) => { - const rect = node.getBoundingClientRect(); - return { - tagName: node.tagName.toLowerCase(), - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - }; - }); - - return { - bridgeFullscreen, - visibleNoDragResizers, - }; + return { bridgeFullscreen }; }); + const fullscreen = await inspectSettingsGeometry(page); + const screenshot = await captureScreenshot(page, "04-fullscreen-window-chrome.png"); + const clearanceRemoved = + platform === "darwin" + ? Boolean( + before.backButtonRect && + fullscreen.backButtonRect && + before.backButtonRect.top >= 45 && + fullscreen.backButtonRect.top < 45 && + fullscreen.backButtonRect.top < before.backButtonRect.top, + ) + : Boolean( + before.detailHeaderLeftRect && + fullscreen.detailHeaderLeftRect && + before.innerWidth - + (before.detailHeaderLeftRect.left + before.detailHeaderLeftRect.width) >= + 140 && + fullscreen.innerWidth - + (fullscreen.detailHeaderLeftRect.left + fullscreen.detailHeaderLeftRect.width) < + 40, + ); return { supported: true, - enteredFullscreen: fullscreenEntered, - initialBounds, + initiallyFullscreen, + before, + fullscreen, + clearanceRemoved, + screenshot, ...details, - passed: - details.bridgeFullscreen === true && - Array.isArray(details.visibleNoDragResizers) && - details.visibleNoDragResizers.length === 0, + passed: details.bridgeFullscreen === true && clearanceRemoved, }; } catch (error) { return { supported: false, error: String(error), - initialBounds, + initiallyFullscreen, }; } finally { - const previousWindowState = initialBounds?.bounds?.windowState ?? "normal"; - if (windowId !== null && fullscreenEntered) { - try { - await session.send("Browser.setWindowBounds", { - windowId, - bounds: { windowState: previousWindowState }, - }); - await page.waitForTimeout(500); - } catch { - // Best-effort restore only. - } + if (await readBridgeFullscreen(page)) { + await setNativeFullscreen(page, false); + await waitForBridgeFullscreen(page, false); } - await session.detach().catch(() => undefined); + } +} + +async function inspectHalfScreenSettingsLayout(page, platform) { + const initialBounds = await page.evaluate(() => ({ + width: window.outerWidth, + height: window.outerHeight, + })); + + try { + await page.evaluate(() => { + // Electron applies resizeTo to the native BrowserWindow. Unlike + // page.setViewportSize, this exercises the real window/layout boundary. + window.resizeTo(751, Math.max(window.outerHeight, 700)); + }); + await page.waitForFunction(() => window.innerWidth === 751, undefined, { timeout: 10_000 }); + + const sidebar = page.getByTestId("settings-sidebar"); + const detail = page.getByTestId("settings-detail-pane"); + const outerAppSidebarSettings = page.getByTestId("sidebar-settings"); + await sidebar.waitFor({ state: "visible", timeout: 10_000 }); + await detail.waitFor({ state: "visible", timeout: 10_000 }); + await outerAppSidebarSettings.waitFor({ state: "hidden", timeout: 10_000 }); + + const details = await inspectSettingsGeometry(page); + const obstruction = getWindowChromeObstruction(platform, details.innerWidth); + const clearsWindowChrome = settingsGeometryClearsWindowChrome(details, platform); + const sidebarRight = details.sidebarRect + ? details.sidebarRect.left + details.sidebarRect.width + : null; + const detailRight = details.detailPaneRect + ? details.detailPaneRect.left + details.detailPaneRect.width + : null; + const screenshot = await captureScreenshot(page, "06-half-screen-settings.png"); + + return { + supported: true, + initialBounds, + ...details, + obstruction, + clearsWindowChrome, + screenshot, + passed: + details.innerWidth === 751 && + details.sidebarRect !== null && + details.sidebarRect.width >= 300 && + details.detailPaneRect !== null && + details.detailPaneRect.width >= 400 && + details.outerAppSidebarSettingsRect === null && + Math.abs(details.sidebarRect.left) <= 1 && + sidebarRight !== null && + Math.abs(sidebarRight - details.detailPaneRect.left) <= 1 && + detailRight !== null && + Math.abs(detailRight - details.innerWidth) <= 1 && + clearsWindowChrome, + }; + } catch (error) { + return { supported: false, initialBounds, error: String(error) }; + } finally { + await page.evaluate((bounds) => window.resizeTo(bounds.width, bounds.height), initialBounds); + await page.waitForFunction( + (width) => Math.abs(window.outerWidth - width) <= 1, + initialBounds.width, + { timeout: 10_000 }, + ); } } async function findAppPage(browser) { - function findMatchingPage() { + function findMatchingPages() { + const matches = []; for (const context of browser.contexts()) { for (const page of context.pages()) { if (page.url().includes(APP_URL_FRAGMENT) && !page.url().startsWith("devtools://")) { - return page; + matches.push(page); } } } - return null; + return matches; } async function poll(attempt) { - const page = findMatchingPage(); - if (page) return page; + const pages = findMatchingPages(); + if (pages.length > 1) { + throw new Error( + `Expected one Electron QA page for ${APP_URL_FRAGMENT}, found ${pages.length}`, + ); + } + if (pages.length === 1) return pages[0]; if (attempt >= 29) { throw new Error(`Unable to find Electron app page for ${APP_URL_FRAGMENT}`); } @@ -412,24 +541,46 @@ async function navigateToSettings(page, serverId) { await page.evaluate((nextServerId) => { window.location.href = `/h/${nextServerId}/settings`; }, serverId); - await page.waitForURL(new RegExp(`/h/${escapeRegExp(serverId)}/settings$`), { - timeout: 30_000, - }); - await page.getByText("Daemon management", { exact: true }).waitFor({ - timeout: 30_000, - }); + await page.getByTestId("settings-sidebar").waitFor({ state: "visible", timeout: 30_000 }); + await page + .getByTestId("settings-detail-header-title") + .waitFor({ state: "visible", timeout: 30_000 }); } -async function dismissMobileSidebarIfVisible(page) { +async function dismissOuterAppSidebarIfVisible(page) { const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]').first(); const menuToggle = page.locator('[data-testid="menu-button"]').first(); const bothVisible = (await sidebarSettingsButton.isVisible().catch(() => false)) && (await menuToggle.isVisible().catch(() => false)); - if (!bothVisible) return; + if (!bothVisible) return false; await menuToggle.click(); - await sidebarSettingsButton.waitFor({ state: "hidden", timeout: 10_000 }).catch(() => undefined); + await sidebarSettingsButton.waitFor({ state: "hidden", timeout: 10_000 }); await page.waitForTimeout(500); + return true; +} + +async function restoreOuterAppSidebar(page, wasDismissed) { + if (!wasDismissed) return; + const menuToggle = page.locator('[data-testid="menu-button"]').first(); + const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]').first(); + await menuToggle.click(); + await sidebarSettingsButton.waitFor({ state: "visible", timeout: 10_000 }); +} + +async function clearTitlebarAnnotations(page) { + await page.evaluate(() => { + document.getElementById("electron-verify-titlebar-style")?.remove(); + for (const attribute of [ + "data-electron-verify-drag", + "data-electron-verify-resizer", + "data-electron-verify-interactive", + ]) { + for (const element of document.querySelectorAll(`[${attribute}]`)) { + element.removeAttribute(attribute); + } + } + }); } function evaluateDragRegionCheck(dragRegionCheck) { @@ -444,13 +595,13 @@ function evaluateDragRegionCheck(dragRegionCheck) { ); } -function evaluateTrafficLightPadding(dragRegionCheck) { - if (process.platform !== "darwin") return true; - const observedPaddingLeft = dragRegionCheck.candidate?.parent?.paddingLeft ?? null; - return ( - typeof observedPaddingLeft === "number" && - observedPaddingLeft >= 78 && - observedPaddingLeft <= 110 +function evaluateTrafficLightAvoidance(dragRegionCheck) { + const firstInteractive = dragRegionCheck.candidate?.explicitNoDragInteractive?.find( + (entry) => entry.testId === "settings-back-to-workspace", + ); + return Boolean( + firstInteractive && + !rectsIntersect(firstInteractive, { left: 0, top: 0, width: 78, height: 45 }), ); } @@ -462,14 +613,22 @@ async function collectDragRegionResults(page, dragRegionCheck, dragScreenshot, r screenshot: dragScreenshot, }); - const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-padding.png"); + const trafficLightScreenshot = await captureScreenshot(page, "04-traffic-light-avoidance.png"); + const firstInteractive = dragRegionCheck.candidate?.explicitNoDragInteractive?.find( + (entry) => entry.testId === "settings-back-to-workspace", + ); results.push({ - check: "traffic-light-padding", - pass: evaluateTrafficLightPadding(dragRegionCheck), + check: "traffic-light-avoidance", + pass: process.platform === "darwin" ? evaluateTrafficLightAvoidance(dragRegionCheck) : true, + skipped: process.platform !== "darwin", details: { platform: process.platform, - observedPaddingLeft: dragRegionCheck.candidate?.parent?.paddingLeft ?? null, - note: "Traffic-light padding is only validated structurally on macOS in this verifier.", + obstruction: process.platform === "darwin" ? { width: 78, height: 45 } : null, + firstInteractive: firstInteractive ?? null, + note: + process.platform === "darwin" + ? "The first interactive sidebar row must not intersect the traffic-light rectangle." + : "Skipped here; the half-screen check exercises the right-side obstruction on Windows/Linux.", candidate: dragRegionCheck.candidate, }, screenshot: trafficLightScreenshot, @@ -489,25 +648,28 @@ async function collectDragRegionResults(page, dragRegionCheck, dragScreenshot, r }); } -async function collectDaemonManagementResult(page, serverId, desktopStatus, results) { - const daemonManagementVisible = await Promise.all([ - page.getByText("Built-in daemon", { exact: true }).isVisible(), - page.getByText("Daemon management", { exact: true }).isVisible(), - page.getByRole("button", { name: "Restart daemon" }).first().isVisible(), - ]).then((values) => values.every(Boolean)); - const daemonManagementScreenshot = await captureScreenshot( - page, - "05-settings-daemon-management.png", - ); +async function collectSettingsSplitResult(page, serverId, desktopStatus, results) { + const geometry = await inspectSettingsGeometry(page); + const sidebarRight = geometry.sidebarRect + ? geometry.sidebarRect.left + geometry.sidebarRect.width + : null; + const settingsScreenshot = await captureScreenshot(page, "05-settings-split.png"); results.push({ - check: "settings-daemon-management", - pass: daemonManagementVisible, + check: "settings-split", + pass: Boolean( + geometry.sidebarRect && + geometry.detailPaneRect && + geometry.detailTitleRect && + sidebarRight !== null && + Math.abs(sidebarRight - geometry.detailPaneRect.left) <= 1, + ), details: { route: page.url(), serverId, desktopStatus, + geometry, }, - screenshot: daemonManagementScreenshot, + screenshot: settingsScreenshot, }); } @@ -515,77 +677,108 @@ async function main() { await ensureDir(OUTPUT_DIR); const browser = await chromium.connectOverCDP(CDP_URL); - const page = await findAppPage(browser); - const consoleMessages = []; - const results = []; + let page = null; + let initialPageUrl = null; + let outerSidebarDismissed = false; - attachConsoleCollector(page, consoleMessages); - await navigateToWelcome(page); + try { + page = await findAppPage(browser); + initialPageUrl = page.url(); + const consoleMessages = []; + const results = []; - const welcomeScreenshot = await captureScreenshot(page, "01-welcome.png"); - const desktopDetection = await detectDesktopBridge(page); + attachConsoleCollector(page, consoleMessages); + await navigateToWelcome(page); - const hasExpectedDesktopShape = - desktopDetection.exists && - REQUIRED_DESKTOP_KEYS.every((key) => desktopDetection.keys.includes(key)); + const welcomeScreenshot = await captureScreenshot(page, "01-welcome.png"); + const desktopDetection = await detectDesktopBridge(page); - results.push({ - check: "desktop-detection", - pass: hasExpectedDesktopShape, - details: desktopDetection, - screenshot: welcomeScreenshot, - }); + const hasExpectedDesktopShape = + desktopDetection.exists && + REQUIRED_DESKTOP_KEYS.every((key) => desktopDetection.keys.includes(key)); + assert( + ["darwin", "win32", "linux"].includes(desktopDetection.platform), + `Unexpected Electron platform: ${desktopDetection.platform}`, + ); - const desktopStatus = await page.evaluate(() => - window.paseoDesktop.invoke("desktop_daemon_status"), - ); - assert( - typeof desktopStatus?.serverId === "string" && desktopStatus.serverId.trim().length > 0, - "desktop_daemon_status did not return a serverId", - ); + results.push({ + check: "desktop-detection", + pass: hasExpectedDesktopShape, + details: desktopDetection, + screenshot: welcomeScreenshot, + }); - const serverId = desktopStatus.serverId.trim(); - await navigateToSettings(page, serverId); + const desktopStatus = await page.evaluate(() => + window.paseoDesktop.invoke("desktop_daemon_status"), + ); + assert( + typeof desktopStatus?.serverId === "string" && desktopStatus.serverId.trim().length > 0, + "desktop_daemon_status did not return a serverId", + ); - await captureScreenshot(page, "02-settings-page.png"); - await dismissMobileSidebarIfVisible(page); + const serverId = desktopStatus.serverId.trim(); + await navigateToSettings(page, serverId); - const dragRegionCheck = await inspectTitlebarRegions(page); - const dragScreenshot = await captureScreenshot(page, "03-drag-region.png"); - await collectDragRegionResults(page, dragRegionCheck, dragScreenshot, results); + await captureScreenshot(page, "02-settings-page.png"); + outerSidebarDismissed = await dismissOuterAppSidebarIfVisible(page); - const fullscreenDetails = await inspectFullscreenResizer(page); - const fullscreenScreenshot = await captureScreenshot(page, "04-fullscreen-resizer.png"); - results.push({ - check: "fullscreen-resizer", - pass: fullscreenDetails.supported ? fullscreenDetails.passed : true, - details: fullscreenDetails, - screenshot: fullscreenScreenshot, - }); + const dragRegionCheck = await inspectTitlebarRegions(page); + const dragScreenshot = await captureScreenshot(page, "03-drag-region.png"); + await collectDragRegionResults(page, dragRegionCheck, dragScreenshot, results); - await collectDaemonManagementResult(page, serverId, desktopStatus, results); + const fullscreenDetails = await inspectFullscreenWindowChrome(page, desktopDetection.platform); + results.push({ + check: "fullscreen-window-chrome", + pass: fullscreenDetails.supported && fullscreenDetails.passed, + details: fullscreenDetails, + screenshot: fullscreenDetails.screenshot ?? null, + }); - const desktopDetectionScreenshot = await captureScreenshot(page, "06-desktop-detection.png"); - results[0].screenshot = desktopDetectionScreenshot; + await collectSettingsSplitResult(page, serverId, desktopStatus, results); - const report = { - cdpUrl: CDP_URL, - outputDir: OUTPUT_DIR, - pageUrl: page.url(), - desktopStatus, - results, - consoleMessages, - }; + if (outerSidebarDismissed) { + await restoreOuterAppSidebar(page, outerSidebarDismissed); + outerSidebarDismissed = false; + } - const reportPath = path.join(OUTPUT_DIR, "report.json"); - await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + const halfScreenDetails = await inspectHalfScreenSettingsLayout( + page, + desktopDetection.platform, + ); + results.push({ + check: "half-screen-settings-layout", + pass: halfScreenDetails.supported && halfScreenDetails.passed, + details: halfScreenDetails, + screenshot: halfScreenDetails.screenshot ?? null, + }); - const failedChecks = results.filter((result) => !result.pass); - console.log(JSON.stringify(report, null, 2)); - await browser.close(); + const desktopDetectionScreenshot = await captureScreenshot(page, "07-desktop-detection.png"); + results[0].screenshot = desktopDetectionScreenshot; - if (failedChecks.length > 0) { - process.exitCode = 1; + const report = { + cdpUrl: CDP_URL, + outputDir: OUTPUT_DIR, + pageUrl: page.url(), + desktopStatus, + results, + consoleMessages, + }; + + const reportPath = path.join(OUTPUT_DIR, "report.json"); + await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + + const failedChecks = results.filter((result) => !result.pass); + console.log(JSON.stringify(report, null, 2)); + if (failedChecks.length > 0) process.exitCode = 1; + } finally { + if (page && !page.isClosed()) { + await clearTitlebarAnnotations(page); + await restoreOuterAppSidebar(page, outerSidebarDismissed); + if (initialPageUrl && page.url() !== initialPageUrl) { + await page.goto(initialPageUrl, { waitUntil: "domcontentloaded" }); + } + } + await browser.close(); } } diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 1ce664be2..e109b9186 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -24,6 +24,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", { ipcRenderer.invoke("paseo:window:openNew", options), getCurrentWindow: () => ({ toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"), + setFullscreen: (fullscreen: boolean) => + ipcRenderer.invoke("paseo:window:setFullscreen", fullscreen), isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"), updateWindowControls: (update: { height?: number; diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index a56bb51c1..19aaab22a 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -194,6 +194,11 @@ export function registerWindowManager(): void { return win?.isFullScreen() ?? false; }); + ipcMain.handle("paseo:window:setFullscreen", (event, fullscreen: unknown) => { + if (typeof fullscreen !== "boolean") return; + BrowserWindow.fromWebContents(event.sender)?.setFullScreen(fullscreen); + }); + ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: unknown) => { if (process.platform === "darwin" || process.platform === "linux") { const badgeCount = readBadgeCount(count); From d706c4339b0e98133b839771b756accc4ea87078 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 00:01:58 +0200 Subject: [PATCH 05/20] Fix hidden Codex subagents and stuck parent sessions (#2068) * fix(server): prevent hidden Codex subagents and stuck turns Codex can announce native children only through its mirrored lifecycle stream, while rejected interrupts previously looked like successful local cancellation. Preserve those child announcements and keep manager state active until the provider acknowledges cancellation. * fix(server): block actions after rejected cancellation A provider can accept a turn before publishing the turn ID needed to interrupt it. Keep that interval non-cancelable, and prevent reload, replacement, or rewind from proceeding without an acknowledged cancellation. * fix(server): surface rejected agent cancellations Replacement prompts and Stop requests could appear accepted after the provider kept ownership of the active turn. Complete replacement cancellation before detaching the stream, and return cancellation failures through the client response. * fix(server): handle turn completion during cancellation * fix: finish cancellation lifecycle handling * fix(server): close remaining cancellation races * fix(server): settle autonomous cancellations * fix: honor cancellation failures at call sites * refactor(server): centralize agent run state * fix(server): keep pending runs isolated from stale events * fix: surface remaining cancellation failures * fix(server): narrow cancellation error handling --- docs/agent-lifecycle.md | 4 + packages/app/src/composer/actions.test.ts | 20 + packages/app/src/composer/actions.ts | 8 +- packages/app/src/composer/index.tsx | 11 +- packages/app/src/composer/input/input.tsx | 50 +-- packages/app/src/composer/input/state.test.ts | 51 ++- packages/app/src/composer/input/state.ts | 21 + packages/app/src/contexts/session-context.tsx | 12 + .../cli/src/commands/agent/delete.test.ts | 39 ++ packages/cli/src/commands/agent/delete.ts | 2 +- packages/client/src/daemon-client.ts | 5 +- packages/protocol/src/messages.ts | 1 + .../src/server/agent/agent-manager.test.ts | 381 ++++++++++++++---- .../server/src/server/agent/agent-manager.ts | 249 ++++++------ .../src/server/agent/agent-prompt.test.ts | 57 ++- .../server/src/server/agent/agent-prompt.ts | 27 +- ...ground-run-state.ts => agent-run-state.ts} | 120 ++++-- .../server/agent/lifecycle-command.test.ts | 45 ++- .../src/server/agent/lifecycle-command.ts | 46 ++- .../server/agent/permission-response.test.ts | 4 +- .../src/server/agent/permission-response.ts | 2 +- .../providers/codex-app-server-agent.test.ts | 153 +++++++ .../agent/providers/codex-app-server-agent.ts | 39 +- .../codex/test-utils/fake-app-server.ts | 26 ++ .../src/server/agent/rewind/rewind.test.ts | 29 ++ .../src/server/daemon-client.e2e.test.ts | 259 +++++++----- .../server/src/server/loop-service.test.ts | 167 ++++++++ packages/server/src/server/loop-service.ts | 47 ++- packages/server/src/server/session.test.ts | 80 ++++ packages/server/src/server/session.ts | 30 +- .../src/server/session.workspaces.test.ts | 2 +- .../session/voice/voice-session.test.ts | 26 ++ .../src/server/session/voice/voice-session.ts | 16 +- 33 files changed, 1597 insertions(+), 432 deletions(-) create mode 100644 packages/cli/src/commands/agent/delete.test.ts rename packages/server/src/server/agent/{foreground-run-state.ts => agent-run-state.ts} (65%) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 2358b7e4d..01e392ffa 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -12,6 +12,10 @@ initializing → idle → running → idle (or error → closed) Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket. +### Cancellation + +Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn. + ## Relationships Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions: diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index f6af2e78d..ad29711a5 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -227,6 +227,7 @@ describe("cancelComposerAgent", () => { isAgentRunning: boolean; isCancellingAgent: boolean; isConnected: boolean; + onCancelFailed: (error: unknown) => void; } { const canceledIds: string[] = []; return { @@ -240,6 +241,7 @@ describe("cancelComposerAgent", () => { isAgentRunning: true, isCancellingAgent: false, isConnected: true, + onCancelFailed: () => undefined, }; } @@ -250,6 +252,24 @@ describe("cancelComposerAgent", () => { expect(input.client.canceledIds).toEqual(["agent"]); }); + it("reports a rejected cancel so the composer can leave its canceling state", async () => { + const cancellationError = new Error("Provider rejected the interrupt"); + const failures: unknown[] = []; + const input = baseInput(); + input.client.cancelAgent = async () => { + throw cancellationError; + }; + + const result = cancelComposerAgent({ + ...input, + onCancelFailed: (error: unknown) => failures.push(error), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(result).toBe(true); + expect(failures).toEqual([cancellationError]); + }); + it("does nothing when the agent is not running", () => { const input = baseInput(); const result = cancelComposerAgent({ ...input, isAgentRunning: false }); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 816736ac0..f2a90c8b7 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -142,12 +142,18 @@ export interface CancelComposerAgentInput { isAgentRunning: boolean; isCancellingAgent: boolean; isConnected: boolean; + onCancelFailed: (error: unknown) => void; } export function cancelComposerAgent(input: CancelComposerAgentInput): boolean { if (!input.isAgentRunning || input.isCancellingAgent) return false; if (!input.isConnected || !input.client) return false; - void input.client.cancelAgent(input.agentId); + try { + void Promise.resolve(input.client.cancelAgent(input.agentId)).catch(input.onCancelFailed); + } catch (error) { + input.onCancelFailed(error); + return false; + } return true; } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 396b279ad..3cd4760f7 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -416,7 +416,7 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R ); } -function resolveVoiceStartErrorMessage(error: unknown): string | null { +function resolveErrorMessage(error: unknown): string | null { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return null; @@ -438,7 +438,7 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void { if (voice.isVoiceModeForAgent(serverId, agentId)) return; void voice.startVoice(serverId, agentId).catch((error) => { console.error("[Composer] Failed to start voice mode", error); - const message = resolveVoiceStartErrorMessage(error); + const message = resolveErrorMessage(error); if (message && message.trim().length > 0) { toastErrorRef.current(message); } @@ -1466,6 +1466,13 @@ export function Composer({ isAgentRunning, isCancellingAgent, isConnected, + onCancelFailed: (error) => { + setIsCancellingAgent(false); + const message = resolveErrorMessage(error); + if (message && message.trim().length > 0) { + toastErrorRef.current(message); + } + }, }); if (!didCancel) return; setIsCancellingAgent(true); diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 4c68a2c40..b35a2811d 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -66,7 +66,12 @@ import { resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, } from "./labels"; -import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state"; +import { + computeCanStartDictation, + runAlternateSendAction, + runDefaultSendAction, + stopRealtimeVoice, +} from "./state"; const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; @@ -929,31 +934,6 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis await ctx.startDictation(); } -interface StopRealtimeVoiceContext { - voice: { stopVoice: () => Promise } | null | undefined; - isRealtimeVoiceForCurrentAgent: boolean; - isAgentRunning: boolean; - client: { cancelAgent: (agentId: string) => Promise } | null; - voiceAgentId: string | undefined; -} - -async function stopRealtimeVoiceImpl(ctx: StopRealtimeVoiceContext): Promise { - if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return; - - const tasks: Promise[] = []; - if (ctx.isAgentRunning && ctx.client && ctx.voiceAgentId) { - tasks.push(ctx.client.cancelAgent(ctx.voiceAgentId)); - } - tasks.push(ctx.voice.stopVoice()); - - const results = await Promise.allSettled(tasks); - results.forEach((result) => { - if (result.status === "rejected") { - console.error("[MessageInput] Failed to stop realtime voice", result.reason); - } - }); -} - interface VoicePressContext { isRealtimeVoiceForCurrentAgent: boolean; voice: { toggleMute: () => void } | null | undefined; @@ -1502,17 +1482,23 @@ export const MessageInput = forwardRef( discardFailedDictation(); }, [discardFailedDictation]); - const handleStopRealtimeVoice = useCallback( - () => - stopRealtimeVoiceImpl({ + const handleStopRealtimeVoice = useCallback(async () => { + try { + await stopRealtimeVoice({ voice, isRealtimeVoiceForCurrentAgent, isAgentRunning, client, voiceAgentId, - }), - [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId], - ); + }); + } catch (error) { + console.error("[MessageInput] Failed to stop realtime voice", error); + const message = extractErrorMessage(error); + if (message && message.trim().length > 0) { + toast.error(message); + } + } + }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, toast, voice, voiceAgentId]); const handleToggleRealtimeVoiceShortcut = useCallback(() => { toggleRealtimeVoiceImpl({ diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index ebea86bbb..8a11806ed 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state"; +import { describe, expect, it, vi } from "vitest"; +import { + computeCanStartDictation, + runAlternateSendAction, + runDefaultSendAction, + stopRealtimeVoice, +} from "./state"; const connected = { isConnected: true } as never; const disconnected = { isConnected: false } as never; @@ -149,3 +154,45 @@ describe("composer send behavior", () => { expect(alternateAction.calls).toEqual(["send"]); }); }); + +describe("stopRealtimeVoice", () => { + it("keeps voice mode active when the running agent refuses cancellation", async () => { + const cancellationError = new Error("active run cancellation was not acknowledged"); + const cancelAgent = vi.fn().mockRejectedValue(cancellationError); + const stopVoice = vi.fn().mockResolvedValue(undefined); + + await expect( + stopRealtimeVoice({ + voice: { stopVoice }, + isRealtimeVoiceForCurrentAgent: true, + isAgentRunning: true, + client: { cancelAgent }, + voiceAgentId: "agent-1", + }), + ).rejects.toBe(cancellationError); + + expect(stopVoice).not.toHaveBeenCalled(); + }); + + it("stops voice mode after the running agent acknowledges cancellation", async () => { + const calls: string[] = []; + + await stopRealtimeVoice({ + voice: { + stopVoice: async () => { + calls.push("stop voice"); + }, + }, + isRealtimeVoiceForCurrentAgent: true, + isAgentRunning: true, + client: { + cancelAgent: async () => { + calls.push("cancel agent"); + }, + }, + voiceAgentId: "agent-1", + }); + + expect(calls).toEqual(["cancel agent", "stop voice"]); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 9eaa554fc..e2f714623 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -3,6 +3,14 @@ import type { MessagePayload } from "@/composer/types"; export type SendBehavior = "interrupt" | "queue"; +interface StopRealtimeVoiceContext { + voice: { stopVoice: () => Promise } | null | undefined; + isRealtimeVoiceForCurrentAgent: boolean; + isAgentRunning: boolean; + client: { cancelAgent: (agentId: string) => Promise } | null; + voiceAgentId: string | undefined; +} + interface SendActionContext { defaultSendBehavior: SendBehavior; isAgentRunning: boolean; @@ -41,3 +49,16 @@ export function runAlternateSendAction(ctx: SendActionContext): void { ctx.handleQueueMessage(); } } + +export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise { + if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return; + + if (ctx.isAgentRunning) { + if (!ctx.client || !ctx.voiceAgentId) { + throw new Error("Cannot stop the running voice agent while the host is unavailable"); + } + await ctx.client.cancelAgent(ctx.voiceAgentId); + } + + await ctx.voice.stopVoice(); +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index e5fb01e84..7d2ab8074 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -476,6 +476,15 @@ function applyToolErrorToMessages( ); } +function notifyVoiceAbortFailure( + data: Extract["payload"], + notifyError: (message: string) => void, +): void { + if (data.type === "error" && data.metadata?.voiceAbortFailed === true) { + notifyError(data.content); + } +} + interface SessionProviderSharedProps { children: ReactNode; serverId: string; @@ -1570,6 +1579,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setMessages(serverId, applyToolError); } + notifyVoiceAbortFailure(data, toast.error); + let activityType: "system" | "info" | "success" | "error" = "info"; if (data.type === "error") activityType = "error"; @@ -1805,6 +1816,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider applyWorkspaceSetupProgress, applyTimelineResponse, updateSessionServerInfo, + toast, voiceRuntime, voiceAudioEngine, ]); diff --git a/packages/cli/src/commands/agent/delete.test.ts b/packages/cli/src/commands/agent/delete.test.ts new file mode 100644 index 000000000..56aa83dc6 --- /dev/null +++ b/packages/cli/src/commands/agent/delete.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; + +import { runDeleteCommand } from "./delete.js"; + +const agent = { + id: "11111111-1111-4111-8111-111111111111", + status: "running", + archivedAt: null, + cwd: "/tmp/project", +}; +const cancelAgent = vi.fn(async () => { + throw new Error("active run cancellation was not acknowledged"); +}); +const deleteAgent = vi.fn(async () => undefined); +const close = vi.fn(async () => undefined); + +vi.mock("../../utils/client.js", () => ({ + connectToDaemon: vi.fn(async () => ({ + fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })), + fetchAgent: vi.fn(async () => ({ agent })), + cancelAgent, + deleteAgent, + close, + })), + getDaemonHost: vi.fn(() => "ws://127.0.0.1:6767"), +})); + +describe("runDeleteCommand", () => { + it("force-deletes a running agent when graceful cancellation is refused", async () => { + const result = await runDeleteCommand(agent.id, {}, {} as never); + + expect(cancelAgent).toHaveBeenCalledWith(agent.id); + expect(deleteAgent).toHaveBeenCalledWith(agent.id); + expect(result.data).toEqual({ + deletedCount: 1, + agentIds: [agent.id], + }); + }); +}); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index dafb26e4c..c8502c9a1 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -92,7 +92,7 @@ export async function runDeleteCommand( agents.map(async (agent) => { try { if (agent.status === "running") { - await client.cancelAgent(agent.id); + await client.cancelAgent(agent.id).catch(() => {}); } await client.deleteAgent(agent.id); return { ok: true as const, id: agent.id }; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 1f827a0b3..9f5f3d7c3 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2664,7 +2664,7 @@ export class DaemonClient { agentId, requestId, }); - await this.sendRequest({ + const payload = await this.sendRequest({ requestId, message, options: { skipQueue: true }, @@ -2678,6 +2678,9 @@ export class DaemonClient { return msg.payload; }, }); + if (payload.error) { + throw new Error(payload.error); + } } async setAgentMode(agentId: string, modeId: string): Promise { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index ec05be176..1718e1117 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3139,6 +3139,7 @@ export const CancelAgentResponseMessageSchema = z.object({ requestId: z.string(), agentId: z.string(), agent: AgentSnapshotPayloadSchema.nullable(), + error: z.string().nullable().optional(), }), }); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 5cbff4b03..759f9b2f4 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -54,6 +54,28 @@ function deferred(): Deferred { return { promise, resolve, reject }; } +function waitForAgentLifecycle( + manager: AgentManager, + agentId: string, + lifecycle: ManagedAgent["lifecycle"], +): Promise { + return new Promise((resolve) => { + const unsubscribe = manager.subscribe( + (event) => { + if ( + event.type === "agent_state" && + event.agent.id === agentId && + event.agent.lifecycle === lifecycle + ) { + unsubscribe(); + resolve(); + } + }, + { agentId, replayState: false }, + ); + }); +} + const TEST_CAPABILITIES = { supportsStreaming: false, supportsSessionPersistence: false, @@ -402,6 +424,83 @@ class TestAgentSession implements AgentSession { async close(): Promise {} } +class ControlledInterruptSession extends TestAgentSession { + interruptCalled = false; + + constructor( + config: AgentSessionConfig, + readonly turnId: string, + private readonly interruptBehavior: (session: ControlledInterruptSession) => Promise, + ) { + super(config); + } + + override async startTurn(): Promise<{ turnId: string }> { + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId: this.turnId }); + }, 0); + return { turnId: this.turnId }; + } + + override async interrupt(): Promise { + this.interruptCalled = true; + await this.interruptBehavior(this); + } +} + +interface ControlledInterruptFixture { + agentId: string; + manager: AgentManager; + session: ControlledInterruptSession; + startForegroundRun(): Promise; + cleanup(): void; +} + +async function createControlledInterruptFixture(options: { + name: string; + agentId: string; + turnId: string; + interrupt: (session: ControlledInterruptSession) => Promise; +}): Promise { + const workdir = mkdtempSync(join(tmpdir(), `agent-manager-${options.name}-`)); + const session = new ControlledInterruptSession( + { provider: "codex", cwd: workdir }, + options.turnId, + options.interrupt, + ); + const client = new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: new AgentStorage(join(workdir, "agents"), logger), + logger, + rescueTimeouts: { interruptSessionMs: 10 }, + idFactory: () => options.agentId, + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + return { + agentId: agent.id, + manager, + session, + async startForegroundRun() { + const run = manager.streamAgent(agent.id, "exercise cancellation"); + void (async () => { + for await (const _event of run) { + // Keep the foreground stream subscribed until the controlled turn settles. + } + })(); + await manager.waitForAgentRunStart(agent.id); + }, + cleanup: () => rmSync(workdir, { recursive: true, force: true }), + }; +} + class HeldRuntimeInfoSession extends TestAgentSession { private readonly runtimeInfoRequested = deferred(); private readonly runtimeInfoAllowed = deferred(); @@ -1357,77 +1456,124 @@ test("reloadAgentSession completes when the previous session close hangs", async } }); -test("cancelAgentRun completes when provider interrupt hangs", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-interrupt-timeout-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class HangingInterruptSession extends TestAgentSession { - interruptCalled = false; - - override async interrupt(): Promise { - this.interruptCalled = true; - await new Promise(() => {}); - } - } - - class HangingInterruptClient extends TestAgentClient { - readonly session = new HangingInterruptSession({ - provider: "codex", - cwd: workdir, - }); - - override async createSession(): Promise { - return this.session; - } - } - - const client = new HangingInterruptClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - rescueTimeouts: { interruptSessionMs: 10 }, - idFactory: () => "00000000-0000-4000-8000-000000000303", +test("cancelAgentRun preserves running state when the provider interrupt hangs", async () => { + const fixture = await createControlledInterruptFixture({ + name: "interrupt-timeout", + agentId: "00000000-0000-4000-8000-000000000303", + turnId: "hanging-interrupt-turn", + interrupt: async () => await new Promise(() => {}), }); try { - const snapshot = await manager.createAgent( - { - provider: "codex", - cwd: workdir, - }, - undefined, - { workspaceId: undefined }, - ); + const running = waitForAgentLifecycle(fixture.manager, fixture.agentId, "running"); + fixture.session.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: "hanging-interrupt-turn", + }); + await running; - await new Promise((resolve) => { - const unsubscribe = manager.subscribe( - (event) => { - if ( - event.type === "agent_state" && - event.agent.id === snapshot.id && - event.agent.lifecycle === "running" - ) { - unsubscribe(); - resolve(); - } - }, - { agentId: snapshot.id, replayState: false }, - ); - client.session.pushEvent({ - type: "turn_started", - provider: "codex", - turnId: "hanging-interrupt-turn", - }); + await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({ + status: "refused", + }); + expect(fixture.session.interruptCalled).toBe(true); + expect(fixture.manager.getAgent(fixture.agentId)?.lifecycle).toBe("running"); + } finally { + fixture.cleanup(); + } +}); + +test("cancelAgentRun preserves the active turn when the provider rejects the interrupt", async () => { + const fixture = await createControlledInterruptFixture({ + name: "interrupt-rejected", + agentId: "00000000-0000-4000-8000-000000000304", + turnId: "provider-still-active-turn", + interrupt: async () => { + throw new Error("A foreground turn is already active"); + }, + }); + + try { + await fixture.startForegroundRun(); + + await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({ + status: "refused", + }); + expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({ + lifecycle: "running", + activeForegroundTurnId: "provider-still-active-turn", }); - await expect(manager.cancelAgentRun(snapshot.id)).resolves.toBe(true); - expect(client.session.interruptCalled).toBe(true); + fixture.session.pushEvent({ + type: "turn_completed", + provider: "codex", + turnId: "provider-still-active-turn", + }); } finally { - rmSync(workdir, { recursive: true, force: true }); + fixture.cleanup(); + } +}); + +test("cancelAgentRun succeeds when the foreground turn finishes before the provider rejects the interrupt", async () => { + let fixture!: ControlledInterruptFixture; + fixture = await createControlledInterruptFixture({ + name: "interrupt-after-completion", + agentId: "00000000-0000-4000-8000-000000000305", + turnId: "naturally-completed-turn", + interrupt: async (session) => { + const settled = waitForAgentLifecycle(fixture.manager, fixture.agentId, "idle"); + session.pushEvent({ + type: "turn_completed", + provider: session.provider, + turnId: "naturally-completed-turn", + }); + await settled; + throw new Error("turn already completed"); + }, + }); + + try { + await fixture.startForegroundRun(); + + await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({ + status: "settled", + }); + expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({ + lifecycle: "idle", + activeForegroundTurnId: null, + }); + } finally { + fixture.cleanup(); + } +}); + +test("cancelAgentRun succeeds when the provider queues completion before rejecting the interrupt", async () => { + const fixture = await createControlledInterruptFixture({ + name: "interrupt-queued-completion", + agentId: "00000000-0000-4000-8000-000000000306", + turnId: "queued-completion-turn", + interrupt: async (session) => { + session.pushEvent({ + type: "turn_completed", + provider: session.provider, + turnId: "queued-completion-turn", + }); + throw new Error("turn already completed"); + }, + }); + + try { + await fixture.startForegroundRun(); + + await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({ + status: "settled", + }); + expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({ + lifecycle: "idle", + activeForegroundTurnId: null, + }); + } finally { + fixture.cleanup(); } }); @@ -4290,7 +4436,7 @@ test("replaceAgentRun does not emit idle or resolve waiters between interrupted await manager.waitForAgentRunStart(snapshot.id); const waitPromise = manager.waitForAgentEvent(snapshot.id); - const secondRun = manager.replaceAgentRun(snapshot.id, "second run"); + const secondRun = await manager.replaceAgentRun(snapshot.id, "second run"); const secondRunDrain = (async () => { for await (const _event of secondRun) { // Drain replacement run. @@ -4421,12 +4567,7 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the const replaceUpdatesStart = stateUpdates.length; const beforeReplaceUpdatedAt = manager.getAgent(snapshot.id)?.updatedAt.getTime() ?? 0; - const secondRun = manager.replaceAgentRun(snapshot.id, "replacement run"); - const secondRunDrain = (async () => { - for await (const _event of secondRun) { - // Drain replacement run. - } - })(); + const secondRunPromise = manager.replaceAgentRun(snapshot.id, "replacement run"); await interruptStarted.promise; const replacementUpdates = stateUpdates.slice(replaceUpdatesStart); @@ -4438,15 +4579,28 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the expect(replacementUpdates.map((update) => update.lifecycle)).not.toContain("idle"); allowInterruptToFinish.resolve(); + const secondRun = await secondRunPromise; + const secondRunDrain = (async () => { + for await (const _event of secondRun) { + // Drain replacement run. + } + })(); await secondStartEntered.promise; const replaceGapSnapshot = manager.getAgent(snapshot.id) as | { pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string } | undefined; - expect(replaceGapSnapshot?.pendingReplacement).toBe(false); + expect(replaceGapSnapshot?.pendingReplacement).toBe(true); expect(replaceGapSnapshot?.activeForegroundTurnId).toBeNull(); expect(replaceGapSnapshot?.lifecycle).toBe("running"); + const replacementStart = manager.waitForAgentRunStart(snapshot.id); + const prematureStart = await Promise.race([ + replacementStart.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)), + ]); + expect(prematureStart).toBe("pending"); + capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" }); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -4458,7 +4612,7 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the allowSecondStartToResolve.resolve(); - await manager.waitForAgentRunStart(snapshot.id); + await replacementStart; await firstRunDrain; await secondRunDrain; unsubscribe(); @@ -4546,16 +4700,18 @@ test("applies live autonomous events while no foreground run is active", async ( expect(lifecycleUpdates).toContain("idle"); }); -test("cancelAgentRun can interrupt autonomous running state without a foreground activeForegroundTurnId", async () => { +test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); class LiveInterruptSession extends TestAgentSession { public interruptCount = 0; + readonly interruptCalled = deferred(); override async interrupt(): Promise { this.interruptCount += 1; + this.interruptCalled.resolve(undefined); } } @@ -4618,9 +4774,80 @@ test("cancelAgentRun can interrupt autonomous running state without a foreground expect(beforeCancel?.lifecycle).toBe("running"); expect(beforeCancel?.activeForegroundTurnId).toBeNull(); - const cancelled = await manager.cancelAgentRun(snapshot.id); - expect(cancelled).toBe(true); + let cancelSettled = false; + const cancelPromise = manager.cancelAgentRun(snapshot.id).finally(() => { + cancelSettled = true; + }); + await capturedSession.interruptCalled.promise; + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(cancelSettled).toBe(false); expect(client.lastSession?.interruptCount).toBe(1); + + capturedSession.pushEvent({ + type: "turn_canceled", + provider: "codex", + turnId: "autonomous-cancel-1", + reason: "interrupted", + }); + + await expect(cancelPromise).resolves.toEqual({ status: "settled" }); + expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); +}); + +test("failed replacement cancellation preserves an autonomous running state", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-replace-rejected-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + + class RejectingLiveInterruptSession extends TestAgentSession { + override async interrupt(): Promise { + throw new Error("provider still owns the autonomous turn"); + } + } + + class RejectingLiveInterruptClient extends TestAgentClient { + readonly session = new RejectingLiveInterruptSession({ + provider: "codex", + cwd: workdir, + }); + + override async createSession(): Promise { + return this.session; + } + } + + const client = new RejectingLiveInterruptClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + rescueTimeouts: { interruptSessionMs: 10 }, + idFactory: () => "00000000-0000-4000-8000-000000000130", + }); + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const running = waitForAgentLifecycle(manager, agent.id, "running"); + + client.session.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: "autonomous-replace-1", + }); + await running; + + await expect(manager.replaceAgentRun(agent.id, "replacement prompt")).rejects.toThrow( + `Cannot replace agent ${agent.id} because its active run cancellation was not acknowledged`, + ); + expect(manager.getAgent(agent.id)).toMatchObject({ + lifecycle: "running", + activeForegroundTurnId: null, + }); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } }); test("waitForAgentEvent waitForActive resolves for autonomous live-event run", async () => { @@ -7217,7 +7444,7 @@ test("replaceAgentRun succeeds when foreground turn terminal event is never deli // Replace the hung run. cancelAgentRun will time out after 2s because // no terminal event arrives. After the fix, it should force-clear the // stale foreground state so streamAgent can proceed. - const secondRun = manager.replaceAgentRun(snapshot.id, "replacement prompt"); + const secondRun = await manager.replaceAgentRun(snapshot.id, "replacement prompt"); const collectedEvents: AgentStreamEvent[] = []; const secondRunDrain = (async () => { for await (const event of secondRun) { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index f74bbdc99..fa34cbaa1 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -59,7 +59,7 @@ import { AgentStreamCoalescer, } from "./agent-stream-coalescer.js"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; -import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js"; +import { AgentRunState, type ForegroundTurnWaiter } from "./agent-run-state.js"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { isSystemInjectedEnvelope } from "./agent-prompt.js"; @@ -95,6 +95,20 @@ export class AgentManagerShuttingDownError extends Error { } } +export class AgentRunCancellationError extends Error { + constructor(agentId: string, action: "reload" | "replace" | "rewind" | "stop") { + super( + `Cannot ${action} agent ${agentId} because its active run cancellation was not acknowledged`, + ); + this.name = "AgentRunCancellationError"; + } +} + +export type AgentRunCancellationResult = + | { status: "not_running" } + | { status: "settled" } + | { status: "refused" }; + interface PreparedSessionConfig { storedConfig: AgentSessionConfig; launchConfig: AgentSessionConfig; @@ -543,7 +557,7 @@ export class AgentManager { private readonly providerSubagents = new ProviderSubagentStore(); private readonly agentsAwaitingInitialSnapshotPersist = new Set(); private readonly sessionEventTails = new Map>(); - private readonly foregroundRuns = new ForegroundRunState(); + private readonly runs = new AgentRunState(); private readonly subscribers = new Set(); private readonly idFactory: () => string; private readonly registry?: AgentStorage; @@ -722,7 +736,7 @@ export class AgentManager { return ( agent.lifecycle === "running" || Boolean(agent.activeForegroundTurnId) || - this.foregroundRuns.hasPendingRun(agentId) + this.runs.hasRun(agentId) ); } @@ -1161,7 +1175,7 @@ export class AgentManager { this.assertAcceptingAgentRegistrations(); let existing = this.requireSessionAgent(agentId); if (this.hasInFlightRun(agentId)) { - await this.cancelAgentRun(agentId); + await this.cancelAgentRunBefore(agentId, "reload"); existing = this.requireSessionAgent(agentId); } const rehydrateFromDisk = options?.rehydrateFromDisk ?? false; @@ -1836,13 +1850,13 @@ export class AgentManager { turnId: existingAgent.activeForegroundTurnId ?? undefined, lifecycle: existingAgent.lifecycle, activeForegroundTurnId: existingAgent.activeForegroundTurnId, - hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId), + hasTrackedRun: this.runs.hasRun(agentId), promptType: typeof prompt === "string" ? "string" : "structured", hasRunOptions: Boolean(options), }, "agent.manager.stream.request", ); - if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) { + if (existingAgent.activeForegroundTurnId || this.runs.hasRun(agentId)) { this.logger.trace( { agentId, @@ -1850,7 +1864,7 @@ export class AgentManager { sessionId: existingAgent.persistence?.sessionId ?? undefined, turnId: existingAgent.activeForegroundTurnId ?? undefined, lifecycle: existingAgent.lifecycle, - hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId), + hasTrackedRun: this.runs.hasRun(agentId), }, "agent.manager.stream.reject", ); @@ -1858,18 +1872,19 @@ export class AgentManager { } const agent = existingAgent; - agent.pendingReplacement = false; + const isReplacement = agent.pendingReplacement; agent.lastError = undefined; - const pendingRun = this.foregroundRuns.createPendingRun(agentId); + const pendingRun = this.runs.createPendingRun(agentId); const streamForwarder = async function* streamForwarder(this: AgentManager) { let turnId: string; - let turnStream: ReturnType | null = null; + let turnStream: ReturnType | null = null; try { const result = await agent.session.startTurn(prompt, options); turnId = result.turnId; } catch (error) { + agent.pendingReplacement = false; const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; await this.handleStreamEvent(agent, { type: "turn_failed", @@ -1877,11 +1892,15 @@ export class AgentManager { error: errorMsg, }); this.finalizeForegroundTurn(agent); - this.foregroundRuns.settlePendingRun(agentId, pendingRun.token); + this.runs.settleForegroundRun(agentId, pendingRun.token); throw error; } pendingRun.started = true; + pendingRun.turnId = turnId; + if (isReplacement) { + agent.pendingReplacement = false; + } agent.activeForegroundTurnId = turnId; agent.lifecycle = "running"; this.touchUpdatedAt(agent); @@ -1898,8 +1917,8 @@ export class AgentManager { "agent.manager.stream.start", ); - turnStream = this.foregroundRuns.createTurnStream(turnId); - this.foregroundRuns.addWaiter(agent, turnStream.waiter); + turnStream = this.runs.createTurnStream(turnId); + this.runs.addWaiter(agent, turnStream.waiter); try { for await (const event of turnStream.events(isTurnTerminalEvent)) { @@ -1907,9 +1926,9 @@ export class AgentManager { } } finally { if (turnStream) { - this.foregroundRuns.deleteWaiter(agent, turnStream.waiter); + this.runs.deleteWaiter(agent, turnStream.waiter); } - this.foregroundRuns.settlePendingRun(agentId, pendingRun.token); + this.runs.settleForegroundRun(agentId, pendingRun.token); if (!agent.activeForegroundTurnId) { await this.refreshRuntimeInfo(agent); } @@ -1922,7 +1941,7 @@ export class AgentManager { private finalizeForegroundTurn(agent: ActiveManagedAgent, turnId?: string): void { const mutableAgent = agent; if (turnId) { - this.foregroundRuns.rememberFinalizedTurn(mutableAgent, turnId); + this.runs.rememberFinalizedTurn(mutableAgent, turnId); } mutableAgent.activeForegroundTurnId = null; const terminalError = mutableAgent.lastError; @@ -1962,16 +1981,16 @@ export class AgentManager { } } - replaceAgentRun( + async replaceAgentRun( agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions, - ): AsyncGenerator { + ): Promise> { const snapshot = this.requireAgent(agentId); if ( snapshot.lifecycle !== "running" && !snapshot.activeForegroundTurnId && - !this.foregroundRuns.hasPendingRun(agentId) + !this.runs.hasRun(agentId) ) { return this.streamAgent(agentId, prompt, options); } @@ -1982,27 +2001,16 @@ export class AgentManager { this.touchUpdatedAt(agent); this.emitState(agent); - return async function* replaceRunForwarder(this: AgentManager) { - try { - await this.cancelAgentRun(agentId); - const nextRun = this.streamAgent(agentId, prompt, options); - for await (const event of nextRun) { - yield event; - } - } catch (error) { - const latest = this.agents.get(agentId); - if (latest) { - const latestActive = latest; - latestActive.pendingReplacement = false; - if (!latestActive.activeForegroundTurnId && latestActive.lifecycle === "running") { - (latestActive as ActiveManagedAgent).lifecycle = "idle"; - this.touchUpdatedAt(latestActive); - this.emitState(latestActive); - } - } - throw error; + try { + await this.cancelAgentRunBefore(agentId, "replace"); + return this.streamAgent(agentId, prompt, options); + } catch (error) { + const latest = this.agents.get(agentId); + if (latest) { + latest.pendingReplacement = false; } - }.call(this); + throw error; + } } async waitForAgentRunStart(agentId: string, options?: WaitForAgentStartOptions): Promise { @@ -2011,7 +2019,7 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found`); } - const pendingRun = this.foregroundRuns.getPendingRun(agentId); + const pendingRun = this.runs.getPendingRun(agentId); if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) { return; } @@ -2075,7 +2083,7 @@ export class AgentManager { return true; } - const currentPendingRun = this.foregroundRuns.getPendingRun(agentId); + const currentPendingRun = this.runs.getPendingRun(agentId); if ( (current.lifecycle === "running" || currentPendingRun?.started) && !current.pendingReplacement @@ -2146,106 +2154,70 @@ export class AgentManager { } } - async cancelAgentRun(agentId: string): Promise { + async cancelAgentRun(agentId: string): Promise { const agent = this.requireSessionAgent(agentId); - const pendingRun = this.foregroundRuns.getPendingRun(agentId); - const foregroundTurnId = agent.activeForegroundTurnId; - const hasForegroundTurn = Boolean(foregroundTurnId); - const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun; - - if (!hasForegroundTurn && !isAutonomousRunning && !pendingRun) { - return false; + const run = + this.runs.getRun(agentId) ?? + (agent.lifecycle === "running" ? this.runs.trackAutonomousRun(agentId, null) : null); + if (!run) { + return { status: "not_running" }; } - await this.interruptSession(agent.session, agentId); + const interruptAcknowledged = await this.interruptSession(agent.session, agentId); + const settlement = await this.waitWithTimeout({ + operation: run.settledPromise, + timeoutMs: interruptAcknowledged + ? INTERRUPT_SESSION_TIMEOUT_MS + : this.rescueTimeouts.interruptSessionMs, + }); - // The interrupt will produce a turn_canceled/turn_failed event via subscribe(), - // which flows through the session event dispatcher and settles the foreground turn waiter. - // Wait briefly for the event to propagate if there's an active foreground turn. - if (foregroundTurnId) { - const waiter = Array.from(agent.foregroundTurnWaiters).find( - (candidate) => candidate.turnId === foregroundTurnId, - ); - const timeout = new Promise((resolvePromise) => setTimeout(resolvePromise, 2000)); - if (waiter) { - await Promise.race([waiter.settledPromise, timeout]); - } else if (agent.activeForegroundTurnId === foregroundTurnId) { - await Promise.race([ - new Promise((resolvePromise) => { - const unsubscribe = this.subscribe( - (event) => { - if ( - event.type === "agent_state" && - event.agent.id === agentId && - !event.agent.activeForegroundTurnId - ) { - unsubscribe(); - resolvePromise(); - } - }, - { agentId, replayState: false }, - ); - }), - timeout, - ]); - } - // The waiter settling wakes up the streamForwarder generator, but its - // finally block (which deletes the pendingForegroundRun) runs asynchronously. - // Wait for the pending run to be fully cleaned up so the next streamAgent - // call doesn't see a stale entry and reject with "already has an active run". - if (pendingRun && !pendingRun.settled) { - await Promise.race([pendingRun.settledPromise, timeout]); - } - } else if (pendingRun) { - const timeout = new Promise((resolvePromise) => setTimeout(resolvePromise, 2000)); - await Promise.race([pendingRun.settledPromise, timeout]); + if (!interruptAcknowledged) { + return { status: settlement === "completed" ? "settled" : "refused" }; } - // If the foreground turn is still stuck after the timeout, force-dispatch a - // synthetic turn_canceled so the normal event pipeline cleans up - // activeForegroundTurnId, settles waiters, and unblocks the streamForwarder. - if (foregroundTurnId && agent.activeForegroundTurnId === foregroundTurnId) { + if (settlement === "timed_out" && run.turnId) { this.logger.warn( - { agentId, foregroundTurnId }, - "cancelAgentRun: foreground turn still active after timeout, force-canceling", + { agentId, turnId: run.turnId, kind: run.kind }, + "cancelAgentRun: acknowledged turn still active after timeout, force-canceling", ); - void this.dispatchSessionEvent(agent, { + await this.dispatchSessionEvent(agent, { + type: "turn_canceled", + provider: agent.provider, + reason: "interrupted", + turnId: run.turnId, + }); + await run.settledPromise; + } else if (settlement === "timed_out" && run.kind === "autonomous") { + this.logger.warn( + { agentId, kind: run.kind }, + "cancelAgentRun: acknowledged turn still active after timeout, force-canceling", + ); + await this.dispatchSessionEvent(agent, { type: "turn_canceled", provider: agent.provider, reason: "interrupted", - turnId: foregroundTurnId, }); - // The synthetic event unblocks the streamForwarder generator, whose finally - // block settles the pending foreground run asynchronously. Wait for it. - const staleRun = this.foregroundRuns.getPendingRun(agentId); - if (staleRun && !staleRun.settled) { - await staleRun.settledPromise; - } } - // Clear any pending permissions that weren't cleaned up by handleStreamEvent. if (agent.pendingPermissions.size > 0) { - for (const [requestId] of agent.pendingPermissions) { - this.dispatchStream( - agent.id, - { - type: "permission_resolved", - provider: agent.provider, - requestId, - resolution: { behavior: "deny", message: "Interrupted" }, - }, - { timestamp: new Date().toISOString() }, - ); - } - agent.pendingPermissions.clear(); + this.resolvePendingPermissionsForAgent(agent, agent.provider, undefined, "Interrupted"); this.touchUpdatedAt(agent); this.emitState(agent); } - - return true; + return { status: "settled" }; } - private async interruptSession(session: AgentSession, agentId: string): Promise { + private async cancelAgentRunBefore( + agentId: string, + action: "reload" | "replace" | "rewind", + ): Promise { + const result = await this.cancelAgentRun(agentId); + if (result.status === "refused") { + throw new AgentRunCancellationError(agentId, action); + } + } + + private async interruptSession(session: AgentSession, agentId: string): Promise { try { const result = await this.waitWithTimeout({ operation: session.interrupt(), @@ -2263,9 +2235,12 @@ export class AgentManager { { agentId, timeoutMs: this.rescueTimeouts.interruptSessionMs }, "Timed out interrupting session during cancel", ); + return false; } + return true; } catch (error) { this.logger.error({ err: error, agentId }, "Failed to interrupt session"); + return false; } } @@ -2294,13 +2269,11 @@ export class AgentManager { async rewind(agentId: string, messageId: string, mode: RewindMode): Promise { const agent = this.requireSessionAgent(agentId); - const hadActiveRun = - Boolean(agent.activeForegroundTurnId) || this.foregroundRuns.hasPendingRun(agentId); - if (hadActiveRun) { - await this.cancelAgentRun(agentId); + if (this.hasInFlightRun(agentId)) { + await this.cancelAgentRunBefore(agentId, "rewind"); } - const lock = this.foregroundRuns.createPendingRun(agentId); + const lock = this.runs.createPendingRun(agentId); try { this.logger.info( { agentId, provider: agent.provider, messageId, mode }, @@ -2323,7 +2296,7 @@ export class AgentManager { ); throw error; } finally { - this.foregroundRuns.settlePendingRun(agentId, lock.token); + this.runs.settleForegroundRun(agentId, lock.token); } } @@ -2421,7 +2394,7 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found`); } - const pendingForegroundRun = this.foregroundRuns.getPendingRun(agentId); + const pendingForegroundRun = this.runs.getPendingRun(agentId); const hasForegroundTurn = Boolean(snapshot.activeForegroundTurnId) || Boolean(pendingForegroundRun); @@ -2793,13 +2766,13 @@ export class AgentManager { agent.unsubscribeSession(); agent.unsubscribeSession = null; } - this.foregroundRuns.cancelWaiters(agent, (turnId) => ({ + this.runs.cancelWaiters(agent, (turnId) => ({ type: "turn_canceled", provider: agent.provider, reason: cancelReason, turnId, })); - this.foregroundRuns.settlePendingRun(agent.id); + this.runs.clearAgentRun(agent.id); return { ...agent, lifecycle: "closed", @@ -2883,7 +2856,7 @@ export class AgentManager { return; } const turnId = getAgentStreamEventTurnId(event); - const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId); + const matchingWaiters = this.runs.getMatchingWaiters(agent, turnId); this.logger.trace( { agentId: agent.id, @@ -2902,7 +2875,7 @@ export class AgentManager { return; } - this.foregroundRuns.notifyWaiters(matchingWaiters, event, { + this.runs.notifyWaiters(matchingWaiters, event, { terminal: isTurnTerminalEvent(event), }); this.logger.trace( @@ -3121,7 +3094,7 @@ export class AgentManager { return; } - this.foregroundRuns.notifyAgentWaiters(agent, event); + this.runs.notifyAgentWaiters(agent, event); this.logger.trace( { agentId, @@ -3151,7 +3124,7 @@ export class AgentManager { if ( eventTurnId && isTurnTerminalEvent(event) && - this.foregroundRuns.hasFinalizedTurn(agent, eventTurnId) + this.runs.hasFinalizedTurn(agent, eventTurnId) ) { return false; } @@ -3180,8 +3153,11 @@ export class AgentManager { await dispatchPromise; } - if (!options?.fromHistory && isForegroundEvent && isTurnTerminalEvent(event)) { - this.finalizeForegroundTurn(agent, eventTurnId); + if (!options?.fromHistory && isTurnTerminalEvent(event)) { + this.runs.settleTerminalRun(agent.id, eventTurnId); + if (isForegroundEvent) { + this.finalizeForegroundTurn(agent, eventTurnId); + } } if (!options?.fromHistory && flags.shouldDispatchEvent) { @@ -3496,6 +3472,7 @@ export class AgentManager { "agent.manager.turn.started", ); if (!isForegroundEvent) { + this.runs.trackAutonomousRun(agent.id, eventTurnId ?? null); agent.lifecycle = "running"; this.emitState(agent); } diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index a88a8b9a3..d726509a8 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -1,4 +1,5 @@ import { expect, it, test, vi } from "vitest"; +import pino, { type Logger } from "pino"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; @@ -11,8 +12,34 @@ import { } from "./agent-prompt.js"; import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; +interface CapturedLogger { + logger: Logger; + records: Array>; + nextRecord: Promise; +} + +function createCapturedLogger(): CapturedLogger { + const records: Array> = []; + let resolveNextRecord!: () => void; + const nextRecord = new Promise((resolve) => { + resolveNextRecord = resolve; + }); + const logger = pino( + { level: "error" }, + { + write(line: string) { + records.push(JSON.parse(line) as Record); + resolveNextRecord(); + }, + }, + ); + return { logger, records, nextRecord }; +} + interface FinishNotificationScenarioOptions { childLastAssistantMessage?: string | null; + parentPromptError?: Error; + logger?: Logger; } interface FinishNotificationScenario { @@ -56,11 +83,15 @@ function createFinishNotificationScenario( return options?.childLastAssistantMessage ?? null; }); Reflect.set(agentManager, "tryRunOutOfBand", () => false); - Reflect.set(agentManager, "hasInFlightRun", () => false); + Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError)); Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => { resolveParentPrompt?.(prompt); return (async function* noop() {})(); }); + Reflect.set(agentManager, "replaceAgentRun", async (_agentId: string, prompt: string) => { + resolveParentPrompt?.(prompt); + throw options?.parentPromptError; + }); const agentStorage: AgentStorage = Object.create(AgentStorage.prototype); Reflect.set(agentStorage, "get", async (agentId: string) => { @@ -77,7 +108,7 @@ function createFinishNotificationScenario( agentStorage, childAgentId: "child-agent", callerAgentId: "caller-agent", - logger: createTestLogger(), + logger: options?.logger ?? createTestLogger(), }); }, async finishChildAndReadParentPrompt() { @@ -161,6 +192,28 @@ test("finish notifications tell the parent the child's last assistant message", ); }); +test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => { + const captured = createCapturedLogger(); + const scenario = createFinishNotificationScenario({ + parentPromptError: new Error("parent provider rejected replacement"), + logger: captured.logger, + }); + + scenario.startWatchingChild(); + await scenario.finishChildAndReadParentPrompt(); + await captured.nextRecord; + + expect(captured.records).toEqual([ + expect.objectContaining({ + msg: "Failed to notify caller agent", + childAgentId: "child-agent", + callerAgentId: "caller-agent", + reason: "finished", + err: expect.objectContaining({ message: "parent provider rejected replacement" }), + }), + ]); +}); + it("does not notify archived callers", async () => { let subscriber: ((event: AgentManagerEvent) => void) | null = null; diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 74664b630..0178ee578 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -15,13 +15,13 @@ export interface StartAgentRunOptions { runOptions?: AgentRunOptions; } -export function startAgentRun( +export async function startAgentRun( agentManager: AgentRunController, agentId: string, prompt: AgentPromptInput, logger: Logger, options?: StartAgentRunOptions, -): { outOfBand: boolean } { +): Promise<{ outOfBand: boolean }> { const snapshot = agentManager.getAgent(agentId); logger.trace( { @@ -44,7 +44,7 @@ export function startAgentRun( const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); const runOptions = options?.runOptions; const iterator = shouldReplace - ? agentManager.replaceAgentRun(agentId, prompt, runOptions) + ? await agentManager.replaceAgentRun(agentId, prompt, runOptions) : agentManager.streamAgent(agentId, prompt, runOptions); logger.trace( { @@ -197,7 +197,7 @@ export async function sendPromptToAgent( ? { ...params.runOptions, messageId: params.messageId } : params.runOptions; - return startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, { + return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, { replaceRunning: true, runOptions, }); @@ -215,7 +215,7 @@ export async function startCreatedAgentInitialPrompt( return currentSnapshot; } - const dispatchResult = startAgentRun( + const dispatchResult = await startAgentRun( params.agentManager, params.agentId, params.prompt, @@ -298,6 +298,15 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): }); } + function notifySafely(reason: "finished" | "errored" | "needs permission"): void { + void notify(reason).catch((error) => { + logger.error( + { err: error, childAgentId, callerAgentId, reason }, + "Failed to notify caller agent", + ); + }); + } + unsubscribe = agentManager.subscribe( (event) => { if (fired) { @@ -310,11 +319,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } if (event.agent.lifecycle === "error") { - void notify("errored"); + notifySafely("errored"); return; } if (event.agent.lifecycle === "idle" && hasSeenRunning) { - void notify("finished"); + notifySafely("finished"); return; } if (event.agent.lifecycle === "closed") { @@ -326,7 +335,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): } if (event.event.type === "permission_requested") { - void notify("needs permission"); + notifySafely("needs permission"); } }, { agentId: childAgentId, replayState: false }, @@ -345,6 +354,6 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): if (childSnapshot.lifecycle === "running") { hasSeenRunning = true; } else if (childSnapshot.lifecycle === "error") { - void notify("errored"); + notifySafely("errored"); } } diff --git a/packages/server/src/server/agent/foreground-run-state.ts b/packages/server/src/server/agent/agent-run-state.ts similarity index 65% rename from packages/server/src/server/agent/foreground-run-state.ts rename to packages/server/src/server/agent/agent-run-state.ts index 705c59a38..e74731718 100644 --- a/packages/server/src/server/agent/foreground-run-state.ts +++ b/packages/server/src/server/agent/agent-run-state.ts @@ -12,45 +12,102 @@ export interface ForegroundTurnWaiter { export interface PendingForegroundRun { token: string; + kind: "foreground"; + turnId: string | null; started: boolean; settled: boolean; settledPromise: Promise; resolveSettled: () => void; } +export interface AutonomousAgentRun { + token: string; + kind: "autonomous"; + turnId: string | null; + started: true; + settled: boolean; + settledPromise: Promise; + resolveSettled: () => void; +} + +export type TrackedAgentRun = PendingForegroundRun | AutonomousAgentRun; + export interface ForegroundRunAgentState { foregroundTurnWaiters: Set; finalizedForegroundTurnIds: Set; } -export class ForegroundRunState { - private readonly pendingRuns = new Map(); +export class AgentRunState { + private readonly runs = new Map(); createPendingRun(agentId: string): PendingForegroundRun { const pendingRun = createPendingForegroundRun(); - this.pendingRuns.set(agentId, pendingRun); + this.runs.set(agentId, pendingRun); return pendingRun; } getPendingRun(agentId: string): PendingForegroundRun | null { - return this.pendingRuns.get(agentId) ?? null; + const run = this.runs.get(agentId); + return run?.kind === "foreground" ? run : null; } hasPendingRun(agentId: string): boolean { - return this.pendingRuns.has(agentId); + return this.getPendingRun(agentId) !== null; } - settlePendingRun(agentId: string, token?: string): void { - const pendingRun = this.pendingRuns.get(agentId); - if (!pendingRun) { + getRun(agentId: string): TrackedAgentRun | null { + return this.runs.get(agentId) ?? null; + } + + hasRun(agentId: string): boolean { + return this.runs.has(agentId); + } + + trackAutonomousRun(agentId: string, turnId: string | null): TrackedAgentRun { + const current = this.runs.get(agentId); + if (current) { + return current; + } + + const run = createTrackedRun({ kind: "autonomous", turnId, started: true }); + this.runs.set(agentId, run); + return run; + } + + settleTerminalRun(agentId: string, turnId: string | undefined): void { + const run = this.runs.get(agentId); + if (!run) { return; } - if (token && pendingRun.token !== token) { + if (run.kind === "foreground" && (run.turnId === null || run.turnId !== turnId)) { + return; + } + if ( + run.kind === "autonomous" && + run.turnId !== null && + turnId !== undefined && + run.turnId !== turnId + ) { return; } - this.pendingRuns.delete(agentId); - settlePendingForegroundRun(pendingRun); + this.clearRun(agentId, run); + } + + settleForegroundRun(agentId: string, token: string): void { + const run = this.runs.get(agentId); + if (run?.kind !== "foreground" || run.token !== token) { + return; + } + + this.clearRun(agentId, run); + } + + clearAgentRun(agentId: string): void { + const run = this.runs.get(agentId); + if (run) { + this.clearRun(agentId, run); + } } createTurnStream(turnId: string): ForegroundTurnStream { @@ -120,14 +177,6 @@ export class ForegroundRunState { agent.foregroundTurnWaiters.clear(); } - clearAgent(agentId: string, agent: ForegroundRunAgentState): void { - for (const waiter of agent.foregroundTurnWaiters) { - this.settleWaiter(waiter); - } - agent.foregroundTurnWaiters.clear(); - this.settlePendingRun(agentId); - } - rememberFinalizedTurn(agent: ForegroundRunAgentState, turnId: string): void { agent.finalizedForegroundTurnIds.add(turnId); if (agent.finalizedForegroundTurnIds.size <= 50) { @@ -143,6 +192,11 @@ export class ForegroundRunState { hasFinalizedTurn(agent: ForegroundRunAgentState, turnId: string): boolean { return agent.finalizedForegroundTurnIds.has(turnId); } + + private clearRun(agentId: string, run: TrackedAgentRun): void { + this.runs.delete(agentId); + settleTrackedRun(run); + } } export class ForegroundTurnStream { @@ -205,24 +259,42 @@ export class ForegroundTurnStream { } function createPendingForegroundRun(): PendingForegroundRun { + return createTrackedRun({ kind: "foreground", turnId: null, started: false }); +} + +function createTrackedRun(input: { + kind: "foreground"; + turnId: null; + started: false; +}): PendingForegroundRun; +function createTrackedRun(input: { + kind: "autonomous"; + turnId: string | null; + started: true; +}): AutonomousAgentRun; +function createTrackedRun( + input: + | { kind: "foreground"; turnId: null; started: false } + | { kind: "autonomous"; turnId: string | null; started: true }, +): TrackedAgentRun { let resolveSettled!: () => void; const settledPromise = new Promise((resolvePromise) => { resolveSettled = resolvePromise; }); return { token: randomUUID(), - started: false, + ...input, settled: false, settledPromise, resolveSettled, }; } -function settlePendingForegroundRun(pendingRun: PendingForegroundRun): void { - if (pendingRun.settled) { +function settleTrackedRun(run: TrackedAgentRun): void { + if (run.settled) { return; } - pendingRun.settled = true; - pendingRun.resolveSettled(); + run.settled = true; + run.resolveSettled(); } diff --git a/packages/server/src/server/agent/lifecycle-command.test.ts b/packages/server/src/server/agent/lifecycle-command.test.ts index 9a3da4aa6..6e5714d48 100644 --- a/packages/server/src/server/agent/lifecycle-command.test.ts +++ b/packages/server/src/server/agent/lifecycle-command.test.ts @@ -43,6 +43,8 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { readonly modeUpdates: Array<{ agentId: string; modeId: string }> = []; readonly detachedAgentIds: string[] = []; inFlightAgentIds = new Set(); + readonly settledDuringCancellationAgentIds = new Set(); + readonly rejectedCancellationAgentIds = new Set(); constructor(private readonly storage: FakeLifecycleAgentStorage) {} @@ -54,9 +56,18 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { return this.inFlightAgentIds.has(agentId); } - async cancelAgentRun(agentId: string): Promise { + async cancelAgentRun(agentId: string) { this.cancelledAgentIds.push(agentId); - return this.inFlightAgentIds.delete(agentId); + if (this.settledDuringCancellationAgentIds.delete(agentId)) { + this.inFlightAgentIds.delete(agentId); + return { status: "not_running" } as const; + } + if (this.rejectedCancellationAgentIds.has(agentId)) { + return { status: "refused" } as const; + } + return this.inFlightAgentIds.delete(agentId) + ? ({ status: "settled" } as const) + : ({ status: "not_running" } as const); } async clearAgentAttention(agentId: string): Promise { @@ -168,6 +179,21 @@ describe("agent lifecycle commands", () => { expect(manager.cancelledAgentIds).toEqual(["agent-1"]); }); + test("accepts a stop when the run settles during cancellation", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + manager.liveAgents.set("agent-1", managedAgent("agent-1", "running")); + manager.inFlightAgentIds.add("agent-1"); + manager.settledDuringCancellationAgentIds.add("agent-1"); + + await expect( + cancelAgentRunCommand({ agentManager: manager, logger }, "agent-1"), + ).resolves.toEqual({ + agent: manager.liveAgents.get("agent-1"), + cancelled: false, + }); + }); + test("archives a live agent after canceling and clearing attention", async () => { const storage = new FakeLifecycleAgentStorage(); const manager = new FakeLifecycleAgentManager(storage); @@ -193,6 +219,21 @@ describe("agent lifecycle commands", () => { expect(manager.archivedAgentIds).toEqual(["agent-1"]); }); + test("archives a live agent when its graceful cancellation is rejected", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + manager.liveAgents.set("agent-1", managedAgent("agent-1", "running")); + manager.inFlightAgentIds.add("agent-1"); + manager.rejectedCancellationAgentIds.add("agent-1"); + storage.records.set("agent-1", storedAgent("agent-1")); + + await expect( + archiveAgentCommand({ agentManager: manager, agentStorage: storage, logger }, "agent-1"), + ).resolves.toMatchObject({ agentId: "agent-1" }); + expect(manager.cancelledAgentIds).toEqual(["agent-1"]); + expect(manager.archivedAgentIds).toEqual(["agent-1"]); + }); + test("archives a stored agent when no live agent exists", async () => { const storage = new FakeLifecycleAgentStorage(); const manager = new FakeLifecycleAgentManager(storage); diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts index b6318c383..cb172ce62 100644 --- a/packages/server/src/server/agent/lifecycle-command.ts +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -1,6 +1,10 @@ import type { Logger } from "pino"; -import type { ManagedAgent } from "./agent-manager.js"; +import { + AgentRunCancellationError, + type AgentRunCancellationResult, + type ManagedAgent, +} from "./agent-manager.js"; import type { StoredAgentRecord } from "./agent-storage.js"; import type { AgentProviderNotice } from "./agent-sdk-types.js"; @@ -9,7 +13,7 @@ export type LifecycleAgentSnapshot = Pick; + cancelAgentRun(agentId: string): Promise; clearAgentAttention(agentId: string): Promise; archiveAgent(agentId: string): Promise<{ archivedAt: string }>; archiveSnapshot(agentId: string, archivedAt: string): Promise; @@ -47,10 +51,14 @@ export interface CancelAgentRunResult { cancelled: boolean; } -export async function cancelAgentRunCommand( +interface RequestedAgentRunCancellation extends CancelAgentRunResult { + cancellation: AgentRunCancellationResult; +} + +async function requestAgentRunCancellation( dependencies: Pick, agentId: string, -): Promise { +): Promise { const { agentManager, logger } = dependencies; const agent = agentManager.getAgent(agentId); if (!agent) { @@ -64,7 +72,7 @@ export async function cancelAgentRunCommand( { agentId, lifecycle: agent.lifecycle, hasInFlightRun }, "cancelAgentRunCommand: skipping because agent is not running", ); - return { agent, cancelled: false }; + return { agent, cancelled: false, cancellation: { status: "not_running" } }; } logger.debug( @@ -72,23 +80,33 @@ export async function cancelAgentRunCommand( "cancelAgentRunCommand: interrupting", ); const startedAt = Date.now(); - const cancelled = await agentManager.cancelAgentRun(agentId); + const cancellation = await agentManager.cancelAgentRun(agentId); logger.debug( - { agentId, cancelled, durationMs: Date.now() - startedAt }, + { agentId, cancellation: cancellation.status, durationMs: Date.now() - startedAt }, "cancelAgentRunCommand: cancelAgentRun completed", ); - if (!cancelled) { - logger.warn( + return { + agent, + cancelled: cancellation.status === "settled", + cancellation, + }; +} + +export async function cancelAgentRunCommand( + dependencies: Pick, + agentId: string, +): Promise { + const result = await requestAgentRunCancellation(dependencies, agentId); + if (result.cancellation.status === "refused") { + dependencies.logger.warn( { agentId }, "cancelAgentRunCommand: reported running but no active run was cancelled", ); + throw new AgentRunCancellationError(agentId, "stop"); } - return { - agent, - cancelled, - }; + return { agent: result.agent, cancelled: result.cancelled }; } export interface ArchiveAgentResult { @@ -104,7 +122,7 @@ export async function archiveAgentCommand( const liveAgent = dependencies.agentManager.getAgent(agentId); let record: StoredAgentRecord | null; if (liveAgent) { - await cancelAgentRunCommand(dependencies, agentId); + await requestAgentRunCancellation(dependencies, agentId); await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined); await dependencies.agentManager.archiveAgent(agentId); record = await dependencies.agentStorage.get(agentId); diff --git a/packages/server/src/server/agent/permission-response.test.ts b/packages/server/src/server/agent/permission-response.test.ts index 89b320b00..a5cf477e6 100644 --- a/packages/server/src/server/agent/permission-response.test.ts +++ b/packages/server/src/server/agent/permission-response.test.ts @@ -53,11 +53,11 @@ class FakePermissionAgentManager { return emptyAgentStream(); } - replaceAgentRun( + async replaceAgentRun( agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions, - ): AsyncGenerator { + ): Promise> { this.replacementRuns.push({ agentId, prompt, options }); return emptyAgentStream(); } diff --git a/packages/server/src/server/agent/permission-response.ts b/packages/server/src/server/agent/permission-response.ts index 5d473e128..7cb58e6d2 100644 --- a/packages/server/src/server/agent/permission-response.ts +++ b/packages/server/src/server/agent/permission-response.ts @@ -33,7 +33,7 @@ export async function respondToAgentPermission( if (result?.followUpPrompt) { logger.debug({ agentId }, "Permission response requires follow-up turn, starting agent stream"); - startAgentRun(agentManager, agentId, result.followUpPrompt, logger, { + await startAgentRun(agentManager, agentId, result.followUpPrompt, logger, { replaceRunning: true, }); } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 8f77da542..4db6c748c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -32,6 +32,7 @@ import { type FakeCodexAppServer, waitForNextPermission, waitForNextTimelineItem, + waitForProviderSubagent, waitForTimelineToolCall, } from "./codex/test-utils/fake-app-server.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; @@ -2457,6 +2458,158 @@ describe("Codex app-server provider", () => { expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); }); + test("discovers a MultiAgentV2 child from a legacy-only lifecycle notification", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + const resultPromise = session.run("Delegate the investigation."); + await appServer.waitForTurnStart(); + const child = waitForProviderSubagent(session, "legacy-only-child-thread"); + const spawn = waitForTimelineToolCall(session, "spawn-legacy-only-child"); + + appServer.startsTurn({ threadId: "thread-1", turnId: "turn-with-legacy-only-child" }); + appServer.startsLegacyOnlySubAgent({ + callId: "spawn-legacy-only-child", + threadId: "legacy-only-child-thread", + agentPath: "/root/legacy-only-child", + }); + + await expect(child).resolves.toMatchObject({ + type: "provider_subagent", + provider: "codex", + turnId: "codex-turn-0", + event: { + type: "upsert", + id: "legacy-only-child-thread", + status: "running", + }, + }); + await expect(spawn).resolves.toMatchObject({ + type: "timeline", + provider: "codex", + turnId: "codex-turn-0", + item: { + type: "tool_call", + callId: "spawn-legacy-only-child", + status: "running", + detail: { + type: "sub_agent", + description: "/root/legacy-only-child", + }, + }, + }); + + appServer.completeTurn(); + await resultPromise; + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("reports when Codex rejects a foreground turn interrupt", async () => { + const appServer = createFakeCodexAppServer({ + "turn/interrupt": async () => { + throw new Error("A foreground turn is already active"); + }, + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + const resultPromise = session.run("Wait for the child."); + await appServer.waitForTurnStart(); + appServer.startsTurn({ threadId: "thread-1", turnId: "turn-waiting-for-child" }); + + await expect(session.interrupt()).rejects.toThrow("A foreground turn is already active"); + + appServer.completeTurn(); + await resultPromise; + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("rejects an interrupt until Codex identifies the accepted turn", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + const resultPromise = session.run("Start working."); + await appServer.waitForTurnStart(); + + await expect(session.interrupt()).rejects.toThrow( + "Cannot interrupt Codex before turn/started identifies the active turn", + ); + + appServer.startsTurn({ threadId: "thread-1", turnId: "turn-identified-late" }); + appServer.completeTurn(); + await resultPromise; + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("rejects an interrupt before Codex initializes the thread", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + await expect(session.interrupt()).rejects.toThrow( + "Cannot interrupt Codex before the active thread is initialized", + ); + + await session.close(); + }); + + test("interrupts an autonomous Codex turn identified by live notifications", async () => { + const session = createSession(); + const requests: Array<{ method: string; params: unknown }> = []; + session.activeForegroundTurnId = null; + session.client = { + request: async (method, params) => { + requests.push({ method, params }); + return {}; + }, + }; + + asInternals(session).handleNotification("turn/started", { + threadId: "test-thread", + turn: { id: "autonomous-turn" }, + }); + + await session.interrupt(); + + expect(requests).toContainEqual({ + method: "turn/interrupt", + params: { + threadId: "test-thread", + turnId: "autonomous-turn", + }, + }); + }); + test("never replaces the root identity with an early child thread start", () => { const session = createSession(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index dc77fdb7e..48aeddb90 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1647,6 +1647,10 @@ function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null }; } +function shouldIgnoreMirroredLifecycleItem(source: "item" | "codex_event", item: unknown): boolean { + return source === "codex_event" && !readCodexSubAgentActivity(item); +} + function settleHistoricalSubAgentActivity( item: ToolCallTimelineItem, kind: CodexSubAgentActivity["kind"], @@ -3800,6 +3804,7 @@ export class CodexAppServerAgentSession implements AgentSession { const turnId = this.createTurnId(); this.activeForegroundTurnId = turnId; + this.currentTurnId = null; try { this.logTurnStartSummary({ @@ -4191,19 +4196,20 @@ export class CodexAppServerAgentSession implements AgentSession { } async interrupt(): Promise { - if (!this.client || !this.currentThreadId || !this.currentTurnId) return; - try { - await this.client.request( - "turn/interrupt", - { - threadId: this.currentThreadId, - turnId: this.currentTurnId, - }, - INTERRUPT_TIMEOUT_MS, - ); - } catch (error) { - this.logger.warn({ error }, "Failed to interrupt Codex turn"); + if (!this.client || !this.currentThreadId) { + throw new Error("Cannot interrupt Codex before the active thread is initialized"); } + if (!this.currentTurnId) { + throw new Error("Cannot interrupt Codex before turn/started identifies the active turn"); + } + await this.client.request( + "turn/interrupt", + { + threadId: this.currentThreadId, + turnId: this.currentTurnId, + }, + INTERRUPT_TIMEOUT_MS, + ); } async close(): Promise { @@ -5528,9 +5534,10 @@ export class CodexAppServerAgentSession implements AgentSession { parsed: Extract, ): void { // Codex emits mirrored lifecycle notifications via both `codex/event/item_*` - // and canonical `item/*`. We render only the canonical channel to avoid - // duplicated assistant/reasoning rows. - if (parsed.source === "codex_event") { + // and canonical `item/*`. Render ordinary items only from the canonical + // channel, but accept a legacy-only child announcement so it can establish + // the provider-subagent route. + if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } if (this.isUserMessageItem(parsed.item)) { @@ -5684,7 +5691,7 @@ export class CodexAppServerAgentSession implements AgentSession { private handleItemStartedNotification( parsed: Extract, ): void { - if (parsed.source === "codex_event") { + if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } if (this.isUserMessageItem(parsed.item)) { diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 9ef0c354e..0e7488e9a 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -58,6 +58,12 @@ export interface FakeCodexAppServer { agentPath: string; parentThreadId?: string; }): void; + startsLegacyOnlySubAgent(params: { + callId: string; + threadId: string; + agentPath: string; + parentThreadId?: string; + }): void; beginsSubAgentActivity(params: FakeSubAgentActivity): void; completesSubAgentActivity(params: FakeSubAgentActivity): void; completesCompaction(params: { threadId: string; itemId: string }): void; @@ -324,6 +330,18 @@ export function createFakeCodexAppServer( startsSubAgent(params) { writeSubAgentActivity("item/completed", { ...params, kind: "started" }); }, + startsLegacyOnlySubAgent(params) { + writeLegacyEvent(params.parentThreadId ?? "thread-1", "codex/event/item_completed", { + type: "item_completed", + item: { + type: "subAgentActivity", + id: params.callId, + kind: "started", + agentThreadId: params.threadId, + agentPath: params.agentPath, + }, + }); + }, beginsSubAgentActivity(params) { writeSubAgentActivity("item/started", params); }, @@ -534,6 +552,7 @@ function waitForNextEvent( } type TimelineEvent = StreamEventOfType<"timeline">; +type ProviderSubagentEvent = StreamEventOfType<"provider_subagent">; export function waitForNextPermission( session: AgentSession, @@ -555,3 +574,10 @@ export function waitForTimelineToolCall( (event) => event.item.type === "tool_call" && event.item.callId === callId, ); } + +export function waitForProviderSubagent( + session: AgentSession, + id: string, +): Promise { + return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id); +} diff --git a/packages/server/src/server/agent/rewind/rewind.test.ts b/packages/server/src/server/agent/rewind/rewind.test.ts index 15efcb1ad..a321fb79f 100644 --- a/packages/server/src/server/agent/rewind/rewind.test.ts +++ b/packages/server/src/server/agent/rewind/rewind.test.ts @@ -100,6 +100,35 @@ describe("AgentManager rewind", () => { expect(session.recordedRewinds).toEqual([{ mode: "files", messageId: "message-1" }]); }); + test("does not rewind when the in-flight turn rejects cancellation", async () => { + class RejectingInterruptSession extends FakeRewindSession { + override async interrupt(): Promise { + throw new Error("provider still owns the active turn"); + } + } + + const session = new RejectingInterruptSession(); + const manager = new AgentManager({ + clients: { claude: new FakeRewindClient(session) }, + logger: createTestLogger(), + idFactory: () => "00000000-0000-4000-8000-000000000902", + }); + const agent = await manager.createAgent({ provider: "claude", cwd: process.cwd() }, undefined, { + workspaceId: undefined, + }); + const run = manager.streamAgent(agent.id, "keep working"); + await run.next(); + + await expect(manager.rewind(agent.id, "message-1", "files")).rejects.toThrow( + `Cannot rewind agent ${agent.id} because its active run cancellation was not acknowledged`, + ); + expect(session.recordedRewinds).toEqual([]); + expect(manager.getAgent(agent.id)).toMatchObject({ + lifecycle: "running", + activeForegroundTurnId: "turn-1", + }); + }); + test("blocks new prompts until the rehydrate epoch broadcasts", async () => { const historyGate = new RewindHistoryGate(); historyGate.hold(); diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 89f66f351..944ca4465 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -210,105 +210,120 @@ test("createAgent with background initialPrompt returns a running snapshot befor } }); +interface StubAgentOptions { + sessionId: string; + supportsStreaming: boolean; + startError?: string; + interruptError?: string; +} + +class StubAgentSession implements AgentSession { + readonly provider = "codex" as const; + readonly capabilities; + private activeTurnId: string | null = null; + + constructor(private readonly options: StubAgentOptions) { + this.capabilities = { + supportsStreaming: options.supportsStreaming, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + } as const; + } + + get id(): string { + return this.options.sessionId; + } + + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + async startTurn(): Promise<{ turnId: string }> { + if (this.options.startError) throw new Error(this.options.startError); + if (this.activeTurnId) throw new Error("A foreground turn is already active"); + this.activeTurnId = "provider-owned-turn"; + return { turnId: this.activeTurnId }; + } + + subscribe(): () => void { + return () => undefined; + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: "gpt-5.4-mini", + modeId: "full-access", + }; + } + + async getAvailableModes() { + return [{ id: "full-access", label: "Full access", description: "No prompts" }]; + } + + async getCurrentMode(): Promise { + return "full-access"; + } + + async setMode(): Promise {} + getPendingPermissions() { + return []; + } + async respondToPermission(): Promise {} + describePersistence(): AgentPersistenceHandle { + return { provider: this.provider, sessionId: this.id }; + } + async interrupt(): Promise { + if (this.options.interruptError) throw new Error(this.options.interruptError); + } + async close(): Promise { + this.activeTurnId = null; + } +} + +class StubAgentClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities; + + constructor(private readonly options: StubAgentOptions) { + this.capabilities = new StubAgentSession(options).capabilities; + } + + async isAvailable(): Promise { + return true; + } + async createSession(): Promise { + return new StubAgentSession(this.options); + } + async resumeSession(): Promise { + return new StubAgentSession(this.options); + } + async fetchCatalog() { + return { + models: [{ id: "gpt-5.4-mini", label: "GPT-5.4 mini", provider: this.provider }], + modes: [{ id: "full-access", label: "Full access", description: "No prompts" }], + }; + } +} + test("createAgent fails when the initial turn cannot start", async () => { - class StartTurnFailureSession implements AgentSession { - readonly provider = "codex" as const; - readonly id = "start-turn-failure-session"; - readonly capabilities = { - supportsStreaming: false, - supportsSessionPersistence: true, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - supportsRewindConversation: false, - supportsRewindFiles: false, - supportsRewindBoth: false, - } as const; - - async run(): Promise { - return { - sessionId: this.id, - finalText: "", - timeline: [], - }; - } - - async startTurn(): Promise<{ turnId: string }> { - throw new Error("Initial turn failed to start"); - } - - subscribe(): () => void { - return () => undefined; - } - - async *streamHistory(): AsyncGenerator { - yield* []; - } - - async getRuntimeInfo() { - return { - provider: "codex" as const, - sessionId: this.id, - model: "gpt-5.4-mini", - modeId: "full-access", - }; - } - - async getAvailableModes(): Promise> { - return [{ id: "full-access", label: "Full access", description: "No prompts" }]; - } - - async getCurrentMode(): Promise { - return "full-access"; - } - - async setMode(): Promise {} - - getPendingPermissions() { - return []; - } - - async respondToPermission(): Promise {} - - describePersistence(): AgentPersistenceHandle | null { - return { provider: "codex", sessionId: this.id }; - } - - async interrupt(): Promise {} - - async close(): Promise {} - } - - class StartTurnFailureClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = { - supportsStreaming: false, - supportsSessionPersistence: true, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - supportsRewindConversation: false, - supportsRewindFiles: false, - supportsRewindBoth: false, - } as const; - - async isAvailable(): Promise { - return true; - } - - async createSession(_config: AgentSessionConfig): Promise { - return new StartTurnFailureSession(); - } - - async resumeSession(): Promise { - return new StartTurnFailureSession(); - } - } + const testAgent = new StubAgentClient({ + sessionId: "start-turn-failure-session", + supportsStreaming: false, + startError: "Initial turn failed to start", + }); const daemon = await createTestPaseoDaemon({ - agentClients: { codex: new StartTurnFailureClient() }, + agentClients: { codex: testAgent }, }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, @@ -335,6 +350,58 @@ test("createAgent fails when the initial turn cannot start", async () => { } }); +function createUninterruptibleClient(): AgentClient { + return new StubAgentClient({ + sessionId: "uninterruptible-session", + supportsStreaming: true, + interruptError: "Provider did not acknowledge cancellation", + }); +} + +test("DaemonClient rejects a replacement prompt when cancellation is not acknowledged", async () => { + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: createUninterruptibleClient() }, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + const agent = await client.createAgent({ provider: "codex", cwd }); + await client.sendMessage(agent.id, "Keep working on the first prompt."); + + await expect(client.sendMessage(agent.id, "Replace it with this prompt.")).rejects.toThrow( + `Cannot replace agent ${agent.id} because its active run cancellation was not acknowledged`, + ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30_000); + +test("DaemonClient rejects Stop when cancellation is not acknowledged", async () => { + const cwd = tmpCwd(); + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: createUninterruptibleClient() }, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + const agent = await client.createAgent({ provider: "codex", cwd }); + await client.sendMessage(agent.id, "Keep working until stopped."); + + await expect(client.cancelAgent(agent.id)).rejects.toThrow( + `Cannot stop agent ${agent.id} because its active run cancellation was not acknowledged`, + ); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30_000); + function waitForSignal( timeoutMs: number, setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void, diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 67edfd173..37280535e 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -1033,6 +1033,173 @@ describe("LoopService", () => { expect(finalLoop.logs.some((entry) => entry.text.includes("Stop requested"))).toBe(true); }); + test("force-closes a loop worker when graceful cancellation is refused", async () => { + let release: (() => void) | null = null; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const cancelledAgentIds: string[] = []; + const closedAgentIds: string[] = []; + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + await blocker; + return "finished"; + } + return '{"passed":true,"reason":"ok"}'; + }, + }), + }, + registry: storage, + logger, + }); + manager.cancelAgentRun = async (agentId) => { + cancelledAgentIds.push(agentId); + return { status: "refused" }; + }; + const closeAgent = manager.closeAgent.bind(manager); + manager.closeAgent = async (agentId) => { + closedAgentIds.push(agentId); + await closeAgent(agentId); + }; + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Wait forever", + cwd: workspaceDir, + model: "test-model", + verifyChecks: ["test -f never.txt"], + }); + const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id); + const stopPromise = service.stopLoop(loop.id); + let closeWaitError: unknown; + + try { + await waitForCancelledAgent(cancelledAgentIds, workerAgentId); + await waitForCancelledAgent(closedAgentIds, workerAgentId); + } catch (error) { + closeWaitError = error; + } finally { + release?.(); + } + + const stopped = await stopPromise; + if (closeWaitError) { + throw closeWaitError; + } + expect(stopped.status).toBe("stopped"); + expect(cancelledAgentIds).toEqual([workerAgentId]); + expect(closedAgentIds).toContain(workerAgentId); + }); + + test("tolerates a loop worker closing while graceful cancellation is refused", async () => { + let release: (() => void) | null = null; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const cancelledAgentIds: string[] = []; + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + await blocker; + return "finished"; + } + return '{"passed":true,"reason":"ok"}'; + }, + }), + }, + registry: storage, + logger, + }); + const closeAgent = manager.closeAgent.bind(manager); + manager.cancelAgentRun = async (agentId) => { + cancelledAgentIds.push(agentId); + await closeAgent(agentId); + return { status: "refused" }; + }; + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Finish while Stop is canceling", + cwd: workspaceDir, + model: "test-model", + verifyChecks: ["test -f never.txt"], + }); + const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id); + const stopPromise = service.stopLoop(loop.id); + + await waitForCancelledAgent(cancelledAgentIds, workerAgentId); + release?.(); + + await expect(stopPromise).resolves.toMatchObject({ status: "stopped" }); + }); + + test("reports unexpected loop worker cancellation errors", async () => { + let release: (() => void) | null = null; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + await blocker; + return "finished"; + } + return '{"passed":true,"reason":"ok"}'; + }, + }), + }, + registry: storage, + logger, + }); + manager.cancelAgentRun = async () => { + throw new Error("cancellation transport failed"); + }; + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Fail while Stop is canceling", + cwd: workspaceDir, + model: "test-model", + verifyChecks: ["test -f never.txt"], + }); + await waitForActiveWorkerRun(service, manager, loop.id); + const execution = ( + service as unknown as { running: Map }> } + ).running.get(loop.id)?.promise; + + try { + await expect(service.stopLoop(loop.id)).rejects.toThrow("cancellation transport failed"); + } finally { + release?.(); + await execution; + } + }); + test("stops while waiting for loop workspace provisioning without starting a worker", async () => { let resolveWorkspace: ((workspaceId: string) => void) | null = null; const workspaceProvisioned = new Promise((resolve) => { diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index e8e24b93f..99e5f8ff3 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -10,7 +10,7 @@ import { type EnsureWorkspaceForCreate, formatProviderModel, } from "./agent/create-agent/create.js"; -import type { AgentManager } from "./agent/agent-manager.js"; +import type { AgentManager, AgentRunCancellationResult } from "./agent/agent-manager.js"; import { buildStructuredAgentResponsePrompt, getStructuredAgentResponse, @@ -232,9 +232,19 @@ function buildVerifierTitle(loop: LoopRecord, iterationIndex: number): string { return `${prefix} [loop ${iterationIndex} verifier]`; } +function isUnknownLoopAgentError(error: unknown, agentId: string): boolean { + return error instanceof Error && error.message === `Unknown agent '${agentId}'`; +} + type LoopAgentManager = Pick< AgentManager, - "archiveAgent" | "cancelAgentRun" | "closeAgent" | "runAgent" | "subscribe" | "waitForAgentEvent" + | "archiveAgent" + | "cancelAgentRun" + | "closeAgent" + | "getAgent" + | "runAgent" + | "subscribe" + | "waitForAgentEvent" >; interface LoopExecutionContext { @@ -523,10 +533,10 @@ export class LoopService { if (running) { if (loop.activeWorkerAgentId) { - await this.options.agentManager.cancelAgentRun(loop.activeWorkerAgentId).catch(() => {}); + await this.stopInternalAgent(loop.activeWorkerAgentId, loop.archive); } if (loop.activeVerifierAgentId) { - await this.options.agentManager.cancelAgentRun(loop.activeVerifierAgentId).catch(() => {}); + await this.stopInternalAgent(loop.activeVerifierAgentId, loop.archive); } await running.promise.catch(() => {}); } else { @@ -539,6 +549,35 @@ export class LoopService { return cloneLoop(loop); } + private async stopInternalAgent(agentId: string, archive: boolean): Promise { + let cancellation: AgentRunCancellationResult; + try { + cancellation = await this.options.agentManager.cancelAgentRun(agentId); + } catch (error) { + if (isUnknownLoopAgentError(error, agentId)) { + return; + } + throw error; + } + if (cancellation.status !== "refused") { + return; + } + if (!this.options.agentManager.getAgent(agentId)) { + return; + } + try { + if (archive) { + await this.options.agentManager.archiveAgent(agentId); + return; + } + await this.options.agentManager.closeAgent(agentId); + } catch (error) { + if (!isUnknownLoopAgentError(error, agentId)) { + throw error; + } + } + } + private async executeLoop(loopId: string, signal: AbortSignal): Promise { const loop = this.requireLoop(loopId); const deadline = loop.maxTimeMs ? Date.now() + loop.maxTimeMs : null; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index d85ce7f2b..ba7fabe7e 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -43,6 +43,7 @@ import type { } from "../services/github-service.js"; interface SessionHandlerInternals { + interruptAgentIfRunning(agentId: string): Promise; handleSendAgentMessage( agentId: string, text: string, @@ -89,6 +90,85 @@ function createBinaryMessageHandler( }; } +test("interruptAgentIfRunning rejects when graceful cancellation is refused", async () => { + const agentId = "11111111-1111-4111-8111-111111111111"; + const session = createSessionForTest({ + agentManager: { + getAgent: vi.fn(() => ({ id: agentId, provider: "codex", lifecycle: "running" })), + hasInFlightRun: vi.fn(() => true), + cancelAgentRun: vi.fn(async () => ({ status: "refused" as const })), + }, + }); + + await expect(asSessionInternals(session).interruptAgentIfRunning(agentId)).rejects.toThrow( + "active run cancellation was not acknowledged", + ); +}); + +test("cancel_agent_request reports refusal only through its response", async () => { + const agentId = "11111111-1111-4111-8111-111111111111"; + const messages: SessionOutboundMessage[] = []; + const getAgent = vi + .fn() + .mockReturnValueOnce({ id: agentId, provider: "codex", lifecycle: "running" }) + .mockReturnValue(null); + const session = createSessionForTest({ + messages, + agentManager: { + getAgent, + hasInFlightRun: vi.fn(() => true), + cancelAgentRun: vi.fn(async () => ({ status: "refused" as const })), + }, + }); + + await session.handleMessage({ + type: "cancel_agent_request", + agentId, + requestId: "cancel-refused", + }); + + expect(messages).toEqual([ + { + type: "cancel_agent_response", + payload: { + requestId: "cancel-refused", + agentId, + agent: null, + error: + "Cannot stop agent 11111111-1111-4111-8111-111111111111 because its active run cancellation was not acknowledged", + }, + }, + ]); +}); + +test("legacy cancel_agent_request reports refusal through the activity log", async () => { + const agentId = "11111111-1111-4111-8111-111111111111"; + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { + getAgent: vi.fn(() => ({ id: agentId, provider: "codex", lifecycle: "running" })), + hasInFlightRun: vi.fn(() => true), + cancelAgentRun: vi.fn(async () => ({ status: "refused" as const })), + }, + }); + + await session.handleMessage({ type: "cancel_agent_request", agentId }); + + expect(messages).toEqual([ + { + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: + "Failed to cancel running agent on request: Cannot stop agent 11111111-1111-4111-8111-111111111111 because its active run cancellation was not acknowledged", + }, + }, + ]); +}); + const checkoutGitMocks = vi.hoisted(() => ({ checkoutResolvedBranch: vi.fn(), commitChanges: vi.fn(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cc38733b2..69df4cd15 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -66,7 +66,7 @@ import { normalizeClientRestartRpcReason, } from "./lifecycle-reasons.js"; -import { AgentManager } from "./agent/agent-manager.js"; +import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { AgentManagerEvent, @@ -1085,16 +1085,17 @@ export class Session { ); const t0 = Date.now(); - const cancelled = await this.agentManager.cancelAgentRun(agentId); + const cancellation = await this.agentManager.cancelAgentRun(agentId); this.sessionLogger.debug( - { agentId, cancelled, durationMs: Date.now() - t0 }, + { agentId, cancellation: cancellation.status, durationMs: Date.now() - t0 }, "interruptAgentIfRunning: cancelAgentRun completed", ); - if (!cancelled) { + if (cancellation.status === "refused") { this.sessionLogger.warn( { agentId }, "interruptAgentIfRunning: reported running but no active run was cancelled", ); + throw new AgentRunCancellationError(agentId, "stop"); } } @@ -2857,11 +2858,30 @@ export class Session { requestId, agentId, agent: payload, + error: null, }, }); } } catch (error) { - this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request"); + if (requestId) { + this.sessionLogger.error( + { err: error, agentId }, + `Failed to cancel running agent on request for agent ${agentId}`, + ); + const agent = this.agentManager.getAgent(agentId); + const payload = agent ? await this.buildAgentPayload(agent) : null; + this.emit({ + type: "cancel_agent_response", + payload: { + requestId, + agentId, + agent: payload, + error: errorToFriendlyMessage(error), + }, + }); + } else { + this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request"); + } } } diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 5475a5854..f7aefff5c 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1609,7 +1609,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy archivedAt: null, }; const killTerminal = vi.fn(); - const cancelAgentRun = vi.fn(async () => true); + const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const })); const session = asTestSession( new Session({ clientId: "test-client", diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index 896546867..d6232ff4d 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -110,6 +110,32 @@ async function settle(): Promise { } describe("VoiceSession streaming transcription", () => { + test("surfaces a refused voice-mode agent interruption", async () => { + const { voiceSession, host } = createVoiceSession(); + host.interruptAgentIfRunning = vi.fn(async () => { + throw new Error("active run cancellation was not acknowledged"); + }); + + await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); + + await expect(voiceSession.handleAbort()).rejects.toThrow( + "active run cancellation was not acknowledged", + ); + expect(host.interruptAgentIfRunning).toHaveBeenCalledWith(VOICE_AGENT_ID); + expect(host.emitted).toContainEqual( + expect.objectContaining({ + type: "activity_log", + payload: expect.objectContaining({ + type: "error", + content: "Voice interruption failed: active run cancellation was not acknowledged", + metadata: { voiceAbortFailed: true }, + }), + }), + ); + + await voiceSession.cleanup(); + }); + test("delivers the streaming final transcript to the agent exactly once", async () => { const { voiceSession, detector, sttSession, host } = createVoiceSession(); diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index 2ac9998ed..f6331dba3 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -1084,10 +1084,18 @@ export class VoiceSession { try { await this.host.interruptAgentIfRunning(this.voiceModeAgentId); } catch (error) { - this.sessionLogger.warn( - { err: error, agentId: this.voiceModeAgentId }, - "Failed to interrupt active voice-mode agent on abort", - ); + const message = `Voice interruption failed: ${getErrorMessage(error)}`; + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: message, + metadata: { voiceAbortFailed: true }, + }, + }); + throw error; } } From 9c40cb76371247c2249e20fc708b4e02cd3360c6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 00:10:24 +0200 Subject: [PATCH 06/20] Render provider images from paths with spaces (#2074) * fix(server): render image paths with spaces Raw spaces in local Markdown image destinations made the renderer treat provider images as text. Emit encoded file URIs for spaced paths so the client parses and resolves them as images. * fix(server): encode all absolute image paths Provider image paths without spaces could still contain URI-significant characters such as fragment or query markers. Route every absolute local path through the encoded file URI boundary. * fix(server): encode network image paths Windows network-share paths bypassed the absolute local path handling. Normalize UNC and extended UNC paths before encoding them as file URIs. * fix(app): preserve network image paths Host-based file URIs were decoded as relative paths. Restore the UNC prefix so provider images on network shares resolve through the file RPC correctly. * fix(server): preserve double-slash POSIX image paths Classify only backslash-prefixed paths as Windows network paths so POSIX double-leading-slash paths retain their filesystem semantics. --- packages/app/src/attachments/utils.test.ts | 4 +++ packages/app/src/attachments/utils.ts | 6 +++- .../providers/codex-app-server-agent.test.ts | 8 ++--- .../providers/provider-image-output.test.ts | 31 +++++++++++++++++ .../agent/providers/provider-image-output.ts | 34 +++++++++++++++++-- 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index 6c50ce17c..319f247d2 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -34,6 +34,10 @@ describe("fileUriToPath", () => { it("converts Windows drive-letter file URIs back to paths", () => { expect(fileUriToPath("file:///C:/Users/file.txt")).toBe("C:/Users/file.txt"); }); + + it("converts host-based file URIs back to UNC paths", () => { + expect(fileUriToPath("file://server/share/shot%231.png")).toBe("\\\\server\\share\\shot#1.png"); + }); }); describe("localFileSourceToPath", () => { diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index e1422a7f4..bb1903f9f 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -162,7 +162,11 @@ export function fileUriToPath(uri: string): string { if (!uri.startsWith("file://")) { return uri; } - const decodedPath = decodeFilePathSource(uri.replace(/^file:\/\//, "")); + const fileSource = uri.slice("file://".length); + const decodedPath = decodeFilePathSource(fileSource); + if (!fileSource.startsWith("/")) { + return `\\\\${decodedPath.replace(/\//g, "\\")}`; + } return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1")); } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 4db6c748c..228f67124 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -3668,7 +3668,7 @@ describe("Codex app-server provider", () => { }); }); - test("emits imageView thread items as assistant markdown images using the path", () => { + test("emits imageView paths with spaces as valid assistant markdown images", () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -3688,15 +3688,15 @@ describe("Codex app-server provider", () => { turnId: "test-turn", item: { type: "assistant_message", - text: "![Image](/tmp/paseo image.png)", + text: "![Image](file:///tmp/paseo%20image.png)", }, }, ]); }); test.each([ - ["savedPath", { savedPath: "/tmp/generated-camel.png" }, "/tmp/generated-camel.png"], - ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "/tmp/generated-snake.png"], + ["savedPath", { savedPath: "/tmp/generated-camel.png" }, "file:///tmp/generated-camel.png"], + ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "file:///tmp/generated-snake.png"], ])( "emits imageGeneration thread items with %s as assistant markdown images", (_fieldName, imageFields, expectedPath) => { diff --git a/packages/server/src/server/agent/providers/provider-image-output.test.ts b/packages/server/src/server/agent/providers/provider-image-output.test.ts index 49ffcf94b..0c32af04f 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.test.ts @@ -49,6 +49,37 @@ describe("isProviderImageMarkdown", () => { expect(isProviderImageMarkdown(markdown)).toBe(true); }); + test("emits POSIX file paths with spaces as valid file URI markdown", () => { + const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png"); + + expect(markdown).toBe( + "![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)", + ); + }); + + test("encodes URI-significant characters in POSIX file paths", () => { + const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png"); + + expect(markdown).toBe("![Image](file:///tmp/screenshot%231%3Fdraft.png)"); + }); + + test("preserves double-leading slashes in POSIX file paths", () => { + const markdown = renderImageMarkdown("//tmp/screenshot#1.png"); + + expect(markdown).toBe("![Image](file:////tmp/screenshot%231.png)"); + }); + + test.each([ + ["UNC", "\\\\server\\share\\shot#1.png", "file://server/share/shot%231.png"], + [ + "extended UNC", + "\\\\?\\UNC\\server\\share\\shot?draft.png", + "file://server/share/shot%3Fdraft.png", + ], + ])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => { + expect(renderImageMarkdown(imagePath)).toBe(`![Image](${expectedSource})`); + }); + test("rejects user-authored markdown that is not a materialized attachment", () => { // No content hash — a hand-written path, not something the writer produced. expect(isProviderImageMarkdown("![diagram](./paseo-attachments/notes.png)")).toBe(false); diff --git a/packages/server/src/server/agent/providers/provider-image-output.ts b/packages/server/src/server/agent/providers/provider-image-output.ts index 649e5bdfe..387158b32 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.ts @@ -128,9 +128,39 @@ function escapeMarkdownImageAlt(value: string): string { return value.replace(/\\/g, "\\\\").replace(/\]/g, "\\]"); } +function encodeFilePath(value: string): string { + return value + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function windowsFileUri(value: string): string | null { + const isWindowsNetworkPath = value.startsWith("\\\\"); + let normalizedPath = value.replace(/\\/g, "/"); + if (/^\/\/\?\/UNC\//i.test(normalizedPath)) { + normalizedPath = `//${normalizedPath.slice(8)}`; + } else if (/^\/\/\?\/[A-Za-z]:\//.test(normalizedPath)) { + normalizedPath = normalizedPath.slice(4); + } + + if (/^[A-Za-z]:\//.test(normalizedPath)) { + const drive = normalizedPath.slice(0, 2); + return `file:///${drive}${encodeFilePath(normalizedPath.slice(2))}`; + } + if (isWindowsNetworkPath && normalizedPath.startsWith("//")) { + return `file:${encodeFilePath(normalizedPath)}`; + } + return null; +} + function markdownImageSource(value: string): string { - if (/^[A-Za-z]:[\\/]/.test(value)) { - return `file:///${value.replace(/\\/g, "/")}`; + const windowsUri = windowsFileUri(value); + if (windowsUri) { + return windowsUri; + } + if (value.startsWith("/")) { + return `file://${encodeFilePath(value)}`; } return value; } From 77f6069ec142f58f3ccb85ed016adc90d076cfa1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 09:30:57 +0200 Subject: [PATCH 07/20] Keep desktop sidebar controls stable across panel changes (#2078) * fix(app): stabilize desktop sidebar controls Keep the left-window-controls toggle mounted across sidebar state changes so its position and tooltip state remain stable. Preserve browser placement and show an explicit Explorer close control only where native right-side controls do not provide one. * fix(app): suppress inactive sidebar chrome --- packages/app/e2e/sidebar-workspace.spec.ts | 45 ++++++++++++ packages/app/src/app/_layout.tsx | 50 +++++++++++-- .../components/desktop-sidebar-layout.test.ts | 47 ++++++++++++ .../src/components/desktop-sidebar-layout.ts | 17 +++++ .../app/src/components/explorer-sidebar.tsx | 29 +++++--- .../src/components/headers/menu-header.tsx | 72 +++++++++++++++---- .../src/components/headers/screen-header.tsx | 2 +- packages/app/src/components/left-sidebar.tsx | 31 ++++++-- packages/app/src/utils/desktop-window.test.ts | 30 ++++++++ packages/app/src/utils/desktop-window.tsx | 36 ++++++++++ 10 files changed, 326 insertions(+), 33 deletions(-) diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index 0a7ec1a7f..2fd0f5868 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -175,6 +175,24 @@ test.describe("Half-screen desktop layout", () => { await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible(); }); + test("keeps the left toggle center-owned without left window controls", async ({ page }) => { + await gotoAppShell(page); + + const openToggle = page.getByTestId("menu-button"); + const openBounds = await openToggle.locator("svg").first().boundingBox(); + expect(openBounds).not.toBeNull(); + expect(openBounds?.x).toBeGreaterThan(12); + + await openToggle.click(); + await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); + + const closedToggle = page.getByTestId("menu-button"); + const closedBounds = await closedToggle.locator("svg").first().boundingBox(); + expect(closedBounds).not.toBeNull(); + expect(closedBounds?.x).toBeCloseTo(12, 0); + expect(closedBounds?.y).toBe(openBounds?.y); + }); + test("yields app navigation to the settings split", async ({ page }) => { await gotoAppShell(page); await page.getByTestId("sidebar-settings").click(); @@ -196,13 +214,40 @@ test.describe("Half-screen desktop layout", () => { await expect( page.getByTestId("explorer-tab-files").filter({ visible: true }).first(), ).toBeVisible(); + await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible(); + await expect(page.getByTestId("explorer-close")).toBeVisible(); await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); + + const centerBounds = await page.getByTestId("workspace-tabs-row").first().boundingBox(); + const headerGlyphBounds = await page + .getByTestId("menu-button") + .locator("svg") + .first() + .boundingBox(); + const tabGlyphBounds = await page + .locator('[data-testid^="workspace-tab-"]') + .first() + .locator("svg") + .first() + .boundingBox(); + expect(centerBounds).not.toBeNull(); + expect(headerGlyphBounds).not.toBeNull(); + expect(tabGlyphBounds).not.toBeNull(); + expect((headerGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0)).toBeCloseTo( + (tabGlyphBounds?.x ?? 0) - (centerBounds?.x ?? 0), + 0, + ); + await expect .poll( async () => (await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0, ) .toBeGreaterThanOrEqual(400); + + await page.getByTestId("explorer-close").click(); + await expect(page.getByTestId("explorer-tab-files")).not.toBeVisible(); + await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible(); } finally { await workspace.cleanup(); } diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a61a603fa..606d4b530 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -28,6 +28,7 @@ import { QuittingOverlay } from "@/components/quitting-overlay"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { LeftSidebar } from "@/components/left-sidebar"; +import { WindowSidebarMenuToggle } from "@/components/headers/menu-header"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host"; import { ProjectPickerModal } from "@/components/project-picker-modal"; @@ -37,9 +38,14 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog"; import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber"; import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal"; import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; -import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; +import { + getIsElectronRuntime, + HEADER_INNER_HEIGHT, + useIsCompactFormFactor, +} from "@/constants/layout"; import { canDesktopAppSidebarShare, + resolveDesktopAppChromeLayout, resolveDesktopAppContentMinimum, } from "@/components/desktop-sidebar-layout"; import { isNative, isWeb } from "@/constants/platform"; @@ -95,7 +101,12 @@ import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; -import { WindowChromeProvider, WindowChromeRegion } from "@/utils/desktop-window"; +import { + useHasWindowChromeObstruction, + WindowChromeProvider, + WindowChromeRegion, + WindowChromeSafeArea, +} from "@/utils/desktop-window"; import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes"; import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -403,6 +414,7 @@ interface AppContainerProps { } const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"]; +const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12; function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) { const daemons = useHosts(); @@ -480,7 +492,12 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon requestedSidebarWidth: sidebarWidth, viewportWidth, }); - const contentWindowChromeCorners = desktopSidebarRendered ? "top-right" : "both"; + const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); + const appChromeLayout = resolveDesktopAppChromeLayout({ + desktopSidebarRendered, + hasTopLeftWindowControls, + sidebarControlsEnabled: chromeEnabled && !isFocusModeEnabled, + }); const sidebarChrome = ( {!isCompactLayout ? ( - + {sidebarChrome} ) : null} @@ -501,7 +518,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon ) : ( - + {children} )} @@ -511,6 +528,18 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const surface = ( {workspaceChrome} + {!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? ( + + + + + + ) : null} {isCompactLayout ? sidebarChrome : null} @@ -943,4 +972,15 @@ const layoutStyles = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.surface0, }, + windowSidebarToggle: { + position: "absolute", + top: 0, + left: 0, + zIndex: 20, + height: HEADER_INNER_HEIGHT, + flexDirection: "row", + alignItems: "center", + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: "transparent", + }, })); diff --git a/packages/app/src/components/desktop-sidebar-layout.test.ts b/packages/app/src/components/desktop-sidebar-layout.test.ts index ca9d8a97f..98f226005 100644 --- a/packages/app/src/components/desktop-sidebar-layout.test.ts +++ b/packages/app/src/components/desktop-sidebar-layout.test.ts @@ -1,12 +1,59 @@ import { describe, expect, it } from "vitest"; import { canDesktopAppSidebarShare, + resolveDesktopAppChromeLayout, resolveDesktopAppContentMinimum, resolveDesktopExplorerWidth, resolveDesktopSidebarWidth, } from "@/components/desktop-sidebar-layout"; describe("desktop sidebar layout", () => { + it("keeps the sidebar toggle window-owned beside left window controls", () => { + expect( + resolveDesktopAppChromeLayout({ + desktopSidebarRendered: true, + hasTopLeftWindowControls: true, + sidebarControlsEnabled: true, + }), + ).toEqual({ + sidebarCorners: "top-left", + contentCorners: "top-right", + sidebarToggleOwner: "window", + }); + expect( + resolveDesktopAppChromeLayout({ + desktopSidebarRendered: true, + hasTopLeftWindowControls: false, + sidebarControlsEnabled: true, + }), + ).toEqual({ + sidebarCorners: "none", + contentCorners: "both", + sidebarToggleOwner: "content", + }); + expect( + resolveDesktopAppChromeLayout({ + desktopSidebarRendered: false, + hasTopLeftWindowControls: true, + sidebarControlsEnabled: true, + }), + ).toEqual({ + sidebarCorners: "none", + contentCorners: "both", + sidebarToggleOwner: "window", + }); + }); + + it("hides the window-owned sidebar toggle when app chrome is suppressed", () => { + expect( + resolveDesktopAppChromeLayout({ + desktopSidebarRendered: false, + hasTopLeftWindowControls: true, + sidebarControlsEnabled: false, + }).sidebarToggleOwner, + ).toBe("none"); + }); + it("clamps a persisted wide sidebar to preserve the center pane", () => { const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 }); expect(atHalfScreen).toBe(351); diff --git a/packages/app/src/components/desktop-sidebar-layout.ts b/packages/app/src/components/desktop-sidebar-layout.ts index e71b42273..fc0a07d82 100644 --- a/packages/app/src/components/desktop-sidebar-layout.ts +++ b/packages/app/src/components/desktop-sidebar-layout.ts @@ -8,6 +8,23 @@ import { export const MIN_DESKTOP_CENTER_WIDTH = 400; +export function resolveDesktopAppChromeLayout(input: { + desktopSidebarRendered: boolean; + hasTopLeftWindowControls: boolean; + sidebarControlsEnabled: boolean; +}) { + const sidebarOwnsTopLeft = input.desktopSidebarRendered && input.hasTopLeftWindowControls; + let sidebarToggleOwner: "none" | "window" | "content" = "none"; + if (input.sidebarControlsEnabled) { + sidebarToggleOwner = input.hasTopLeftWindowControls ? "window" : "content"; + } + return { + sidebarCorners: sidebarOwnsTopLeft ? ("top-left" as const) : ("none" as const), + contentCorners: sidebarOwnsTopLeft ? ("top-right" as const) : ("both" as const), + sidebarToggleOwner, + }; +} + function resolveDesktopPanelWidth(input: { requestedWidth: number; viewportWidth: number; diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index c691332e0..942818b97 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -30,7 +30,7 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { GitDiffPane } from "@/git/diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { WindowChromeSafeArea } from "@/utils/desktop-window"; +import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { RetainedPanelActivity } from "@/components/retained-panel"; import { isWeb } from "@/constants/platform"; @@ -130,7 +130,6 @@ export function CompactExplorerSidebar({ workspaceId={workspaceId} workspaceRoot={workspaceRoot} isGit={isGit} - isMobile isOpen={isOpen} onOpenFile={onOpenFile} /> @@ -225,7 +224,6 @@ export function ExplorerSidebar({ workspaceId={workspaceId} workspaceRoot={workspaceRoot} isGit={isGit} - isMobile={false} isOpen={isOpen} onOpenFile={onOpenFile} /> @@ -270,7 +268,6 @@ interface SidebarContentProps { workspaceId?: string | null; workspaceRoot: string; isGit: boolean; - isMobile: boolean; isOpen: boolean; onOpenFile?: (filePath: string) => void; } @@ -283,13 +280,13 @@ function ExplorerSidebarContent({ workspaceId, workspaceRoot, isGit, - isMobile, isOpen, onOpenFile, }: SidebarContentProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const toast = useToast(); + const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right"); const canQueryPullRequest = isGit && Boolean(workspaceRoot); const prPane = usePrPaneData({ serverId, @@ -359,9 +356,25 @@ function ExplorerSidebarContent({ )} - {isMobile && ( - - + {!hasRightWindowControls && ( + + {({ hovered, pressed }) => ( + + )} )} diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index aea0abf2e..40feafb15 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -5,10 +5,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { PanelLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; import { ScreenTitle } from "./screen-title"; -import { HeaderToggleButton } from "./header-toggle-button"; +import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getShortcutOs } from "@/utils/shortcut-platform"; +import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window"; interface MenuHeaderProps { title?: string; @@ -42,15 +43,18 @@ function MobileMenuIcon({ color }: { color: string }) { ); } -export function SidebarMenuToggle({ - style, +function SidebarMenuToggleButton({ + isMobile, + resolvedStyle, tooltipSide = "right", testID = "menu-button", nativeID = "menu-button", -}: SidebarMenuToggleProps = {}) { +}: Omit & { + isMobile: boolean; + resolvedStyle: StyleProp; +}) { const { theme } = useUnistyles(); const { t } = useTranslation(); - const isMobile = useIsCompactFormFactor(); const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile })); const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); const toggleShortcutKeys = useMemo( @@ -58,9 +62,6 @@ export function SidebarMenuToggle({ [], ); - const menuIconColor = - !isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted; - const handlePress = useCallback(() => { toggleAgentListForLayout({ isCompact: isMobile }); }, [toggleAgentListForLayout, isMobile]); @@ -75,21 +76,54 @@ export function SidebarMenuToggle({ tooltipSide={tooltipSide} testID={testID} nativeID={nativeID} - style={style} + style={resolvedStyle} accessible accessibilityRole="button" accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")} accessibilityState={accessibilityState} > - {isMobile ? ( - - ) : ( - - )} + {({ hovered, pressed }) => { + const color = hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted; + return isMobile ? ( + + ) : ( + + ); + }} ); } +export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { + const isMobile = useIsCompactFormFactor(); + const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); + const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); + const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); + const placeholderStyle = useMemo( + () => [headerIconSlotStyle.slot, resolvedStyle], + [resolvedStyle], + ); + + if (!isMobile && !ownsTopLeft) { + return null; + } + + if (!isMobile && hasTopLeftWindowControls) { + return ( + + + + ); + } + + return ; +} + +export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { + const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); + return ; +} + export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) { return ( ({ + leadingToggle: { + marginLeft: { + xs: 0, + md: -theme.spacing[2], + }, + }, left: { gap: theme.spacing[2], }, @@ -116,6 +156,10 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "space-between", alignItems: "flex-start", }, + desktopMenuIconSpace: { + width: theme.iconSize.md, + height: theme.iconSize.md, + }, mobileMenuLine: { width: MOBILE_MENU_LINE_WIDTH, height: MOBILE_MENU_LINE_HEIGHT, diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index b8f078e15..c2bd2f5d3 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -38,7 +38,7 @@ export function ScreenHeader({ const isMobile = useIsCompactFormFactor(); // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; - const baseHorizontalPadding = theme.spacing[2]; + const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3]; const innerStyle = useMemo( () => [styles.inner, { paddingTop: insets.top + topPadding }], diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 25b957ed8..e1e493142 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -32,7 +32,7 @@ import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-disp import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useIsCompactFormFactor } from "@/constants/layout"; +import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; @@ -52,7 +52,7 @@ import { useHosts } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspace } from "@/stores/session-store-hooks"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; -import { WindowChromeSafeArea } from "@/utils/desktop-window"; +import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { @@ -706,6 +706,7 @@ function DesktopSidebar({ handleViewMore, handleViewSchedules, }: DesktopSidebarProps) { + const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); const pathname = usePathname(); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const isSessionsActive = pathname.includes("/sessions"); @@ -759,6 +760,10 @@ function DesktopSidebar({ () => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }], [insetsTop], ); + const sidebarHeaderGroupStyle = useMemo( + () => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome], + [ownsTopLeft], + ); const resizeHandleStyle = useMemo( () => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)], [], @@ -772,9 +777,14 @@ function DesktopSidebar({ - - - + {ownsTopLeft ? ( + + + + ) : ( + + )} + ({ borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, + sidebarHeaderGroupBelowChrome: { + paddingTop: 0, + }, workspacesSectionHeader: { flexDirection: "row", alignItems: "center", @@ -991,6 +1004,14 @@ const styles = StyleSheet.create((theme) => ({ sidebarDragArea: { position: "relative", }, + desktopChromeRow: { + position: "relative", + height: HEADER_INNER_HEIGHT, + flexDirection: "row", + alignItems: "center", + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: "transparent", + }, sidebarFooter: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/utils/desktop-window.test.ts b/packages/app/src/utils/desktop-window.test.ts index 27de3f1ab..c9f915905 100644 --- a/packages/app/src/utils/desktop-window.test.ts +++ b/packages/app/src/utils/desktop-window.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { intersectWindowChromeCorners, + resolveHasOwnedWindowChromeObstruction, resolveWindowChromeObstruction, resolveWindowChromeSafeArea, } from "@/utils/desktop-window"; @@ -49,4 +50,33 @@ describe("window chrome", () => { expect(intersectWindowChromeCorners("top-right", "both")).toBe("top-right"); expect(intersectWindowChromeCorners("top-left", "top-right")).toBe("none"); }); + + it("reports an obstruction only when the surface owns its corner", () => { + const obstruction = { + topLeft: { width: 78, height: 45 }, + topRight: { width: 140, height: 48 }, + }; + + expect( + resolveHasOwnedWindowChromeObstruction({ + obstruction, + corners: "top-left", + corner: "top-left", + }), + ).toBe(true); + expect( + resolveHasOwnedWindowChromeObstruction({ + obstruction, + corners: "top-left", + corner: "top-right", + }), + ).toBe(false); + expect( + resolveHasOwnedWindowChromeObstruction({ + obstruction: { topLeft: null, topRight: null }, + corners: "both", + corner: "top-right", + }), + ).toBe(false); + }); }); diff --git a/packages/app/src/utils/desktop-window.tsx b/packages/app/src/utils/desktop-window.tsx index 4af5e8733..d3c6a5cfd 100644 --- a/packages/app/src/utils/desktop-window.tsx +++ b/packages/app/src/utils/desktop-window.tsx @@ -24,6 +24,8 @@ interface WindowChromeObstruction { topRight: WindowChromeCornerObstruction | null; } +export type WindowChromeCorner = "top-left" | "top-right"; + type WindowChromeSafeAreaStyle = { height: number } | { paddingLeft: number; paddingRight: number }; const EMPTY_OBSTRUCTION: WindowChromeObstruction = { topLeft: null, topRight: null }; @@ -37,6 +39,29 @@ function windowChromeCornersFromFlags(topLeft: boolean, topRight: boolean): Wind return "none"; } +export function windowChromeCornersInclude( + corners: WindowChromeCorners, + corner: WindowChromeCorner, +): boolean { + return corners === "both" || corners === corner; +} + +export function resolveHasOwnedWindowChromeObstruction(input: { + obstruction: WindowChromeObstruction; + corners: WindowChromeCorners; + corner: WindowChromeCorner; +}): boolean { + if (!windowChromeCornersInclude(input.corners, input.corner)) return false; + return input.corner === "top-left" + ? input.obstruction.topLeft !== null + : input.obstruction.topRight !== null; +} + +export function useHasWindowChromeObstruction(corner: WindowChromeCorner): boolean { + const obstruction = useContext(WindowChromeContext); + return corner === "top-left" ? obstruction.topLeft !== null : obstruction.topRight !== null; +} + export function intersectWindowChromeCorners( inherited: WindowChromeCorners, declared: WindowChromeCorners, @@ -210,6 +235,17 @@ export function useWindowChromeCorners(): WindowChromeCorners { return useContext(WindowChromeCornersContext); } +export function useOwnsWindowChromeCorner(corner: WindowChromeCorner): boolean { + const corners = useContext(WindowChromeCornersContext); + return windowChromeCornersInclude(corners, corner); +} + +export function useHasOwnedWindowChromeObstruction(corner: WindowChromeCorner): boolean { + const obstruction = useContext(WindowChromeContext); + const corners = useContext(WindowChromeCornersContext); + return resolveHasOwnedWindowChromeObstruction({ obstruction, corners, corner }); +} + type WindowChromeSafeAreaProps = ViewProps & { placement: WindowChromeSafeAreaPlacement; horizontalPadding?: number; From 38e4d9ad5d2498e228bd1d6215576763b21cc719 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 11:24:05 +0200 Subject: [PATCH 08/20] feat(app): polish sidebar and tool call summaries --- packages/app/src/agent-stream/view.tsx | 2 - packages/app/src/components/left-sidebar.tsx | 2 +- .../tool-calls/detail-level/overview/model.ts | 15 ----- .../tool-calls/detail-level/overview/view.tsx | 25 ++------ .../detail-level/projection.test.ts | 59 ++++--------------- 5 files changed, 17 insertions(+), 86 deletions(-) diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 4736bd5c0..3da69787d 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -779,7 +779,6 @@ const AgentStreamViewComponent = forwardRef {expanded ? group.run.calls.map((call, index) => ( @@ -799,7 +798,6 @@ const AgentStreamViewComponent = forwardRef + - ); diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts index 31faa23f2..c0901ea63 100644 --- a/packages/app/src/tool-calls/detail-level/overview/model.ts +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -21,21 +21,6 @@ export interface OverviewToolCallGroup { isLoading: boolean; } -export type OverviewHeader = { kind: "latest"; call: ToolCallRun["latest"] } | { kind: "summary" }; - -export function resolveOverviewHeader( - group: OverviewToolCallGroup, - expanded: boolean, -): OverviewHeader { - if (group.run.calls.length === 1) { - return { kind: "latest", call: group.run.latest }; - } - if (expanded || group.run.isSealed) { - return { kind: "summary" }; - } - return { kind: "latest", call: group.run.latest }; -} - function isPaseoCall(name: string, normalizedName: string): boolean { return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX); } diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx index c2cc68fc7..75f4cf48f 100644 --- a/packages/app/src/tool-calls/detail-level/overview/view.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -9,7 +9,7 @@ import { useToolCallSheet } from "@/components/tool-call-sheet"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { describeToolCall } from "../grouping"; -import { resolveOverviewHeader, type OverviewSummary, type OverviewToolCallGroup } from "./model"; +import { type OverviewSummary, type OverviewToolCallGroup } from "./model"; interface OverviewGroupProps { group: OverviewToolCallGroup; @@ -18,7 +18,6 @@ interface OverviewGroupProps { isLastInSequence: boolean; onExpandedChange: (groupId: string, expanded: boolean) => void; cwd?: string; - onOpenFilePath?: (filePath: string) => void; children: ReactNode; } @@ -66,17 +65,14 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView isLastInSequence, onExpandedChange, cwd, - onOpenFilePath, children, }: OverviewGroupProps) { const { t } = useTranslation(); const { openToolCall } = useToolCallSheet(); const scrollRef = useRef(null); const aggregateSummary = useOverviewSummary(group.summary); - const header = resolveOverviewHeader(group, expanded); - const latestCall = header.kind === "latest" ? header.call : group.run.latest; const latest = useMemo(() => { - const descriptor = describeToolCall(latestCall); + const descriptor = describeToolCall(group.run.latest); return { detail: descriptor.detail, presentation: buildToolCallPresentation({ @@ -89,16 +85,8 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView resolveIcon: resolveToolCallIcon, }), }; - }, [cwd, latestCall]); - const showsLatest = header.kind === "latest"; + }, [cwd, group.run.latest]); const opensSingleCallSheet = isCompact && group.run.calls.length === 1; - const openLatestFile = useMemo(() => { - const path = latest.presentation.openFilePath; - if (!showsLatest || !path || !onOpenFilePath) { - return undefined; - } - return () => onOpenFilePath(path); - }, [showsLatest, latest.presentation.openFilePath, onOpenFilePath]); const failedSummary = group.failedCount > 0 ? t("toolCallGroup.failed", { count: group.failedCount }) : undefined; const scrollToLatest = useCallback(() => { @@ -147,15 +135,14 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView return ( 0} isExpanded={opensSingleCallSheet ? false : expanded} isLastInSequence={isLastInSequence} onToggle={canExpand ? toggle : undefined} - onOpenFile={openLatestFile} renderDetails={canExpand && !opensSingleCallSheet ? renderDetails : undefined} borderlessWhenExpanded /> diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index d8b56d46b..de5a8b150 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -7,7 +7,6 @@ import { type PreparedToolCallHistory, type ToolCallDetailLevel, } from "./projection"; -import { resolveOverviewHeader } from "./overview/model"; type AssistantMessageItem = Extract; @@ -111,25 +110,6 @@ describe("tool call detail-level projection", () => { }); }); - it("shows the latest call while collapsed and the summary while expanded", () => { - const calls = [ - toolCall("1", { type: "shell", command: "one" }), - toolCall("2", { type: "read", filePath: "/repo/a.ts" }, { status: "running" }), - ]; - const result = project({ - level: "overview", - head: calls, - isTurnActive: true, - }); - const group = result.groupsByHostId.get("1"); - if (!group) { - throw new Error("Expected an overview group"); - } - - expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call: calls[1] }); - expect(resolveOverviewHeader(group, true)).toEqual({ kind: "summary" }); - }); - it("keeps a parallel group loading while any call is still running", () => { const calls = [ toolCall("1", { type: "shell", command: "slow" }, { status: "running" }), @@ -140,16 +120,18 @@ describe("tool call detail-level projection", () => { expect(result.groupsByHostId.get("1")?.isLoading).toBe(true); }); - it("keeps a one-call run presented as its tool call", () => { - const call = toolCall("1", { type: "shell", command: "one" }); - const result = project({ level: "overview", head: [call] }); + it("builds a loading aggregate for a one-call run", () => { + const call = toolCall("1", { type: "shell", command: "one" }, { status: "running" }); + const result = project({ level: "overview", head: [call], isTurnActive: true }); const group = result.groupsByHostId.get(call.id); if (!group) { throw new Error("Expected an overview group"); } - expect(resolveOverviewHeader(group, false)).toEqual({ kind: "latest", call }); - expect(resolveOverviewHeader(group, true)).toEqual({ kind: "latest", call }); + expect(group).toMatchObject({ + isLoading: true, + summary: { commandCount: 1 }, + }); }); it("keeps an active overview group on its latest call until a visible boundary arrives", () => { @@ -172,11 +154,6 @@ describe("tool call detail-level projection", () => { mode: "overview", run: { id: "1", latest: calls[3], isSealed: false }, }); - expect(resolveOverviewHeader(activeGroup!, false)).toEqual({ - kind: "latest", - call: calls[3], - }); - const boundary = assistant("answer"); const sealed = project({ level: "overview", @@ -189,12 +166,9 @@ describe("tool call detail-level projection", () => { run: { latest: calls[3], isSealed: true }, summary: { editedFileCount: 1, readFileCount: 2, commandCount: 1 }, }); - expect(resolveOverviewHeader(sealed.groupsByHostId.get("1")!, false)).toEqual({ - kind: "summary", - }); }); - it("keeps a running overview header live before the agent lifecycle catches up", () => { + it("keeps a running overview group live before the agent lifecycle catches up", () => { const calls = ["1", "2", "3", "4"].map((id) => toolCall(id, { type: "shell", command: id }, { status: "running" }), ); @@ -207,10 +181,8 @@ describe("tool call detail-level projection", () => { expect(result.groupsByHostId.get("1")).toMatchObject({ run: { latest: calls[3], isSealed: false }, - }); - expect(resolveOverviewHeader(result.groupsByHostId.get("1")!, false)).toEqual({ - kind: "latest", - call: calls[3], + isLoading: true, + summary: { commandCount: 4 }, }); }); @@ -239,22 +211,11 @@ describe("tool call detail-level projection", () => { }); expect(betweenCalls.groupsByHostId.get("1")?.run.isSealed).toBe(false); - expect(resolveOverviewHeader(betweenCalls.groupsByHostId.get("1")!, false)).toEqual({ - kind: "latest", - call: calls[3], - }); expect(continued.groupsByHostId.get("1")?.run).toMatchObject({ latest: nextCall, isSealed: false, }); - expect(resolveOverviewHeader(continued.groupsByHostId.get("1")!, false)).toEqual({ - kind: "latest", - call: nextCall, - }); expect(ended.groupsByHostId.get("1")?.run.isSealed).toBe(true); - expect(resolveOverviewHeader(ended.groupsByHostId.get("1")!, false)).toEqual({ - kind: "summary", - }); }); it("builds overview summaries without category-specific presentation data", () => { From abfe955867f036e6e32d67cd9ab2d06c43c9e1b1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 11:58:31 +0200 Subject: [PATCH 09/20] Show friendly native subagent names and hide finished work (#2073) * feat(subagents): archive finished children and show native names Keep native provider sessions intact while letting users clear completed children from the track. Persist dismissed provider child IDs so history replay does not restore them. * fix(subagents): cover persistence and naming edge cases * fix(subagents): preserve active descendants and native names * fix(subagents): retain dismissed timelines and trim paths * fix(subagents): retain history across hydration * test(subagents): expect normalized native descriptions * fix(subagents): separate hidden rows from retained history * fix(subagents): preserve restored hidden tabs safely * fix(subagents): integrate archive with run state * test(codex): expect normalized child name * fix(subagents): retain dismissed tabs on reload * fix(subagents): hide finished native agents locally Keep the header action presentation-only so it does not mutate agent lifecycle or expand the runtime protocol. * fix(subagents): humanize native agent names * fix(subagents): preserve names in Codex history * fix(subagents): retain hidden rows across reloads --- docs/agent-lifecycle.md | 6 +- .../app/e2e/provider-subagents.real.spec.ts | 18 ++- packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + packages/app/src/panels/agent-panel.tsx | 12 +- packages/app/src/subagents/index.ts | 4 + .../app/src/subagents/provider-store.test.ts | 123 +++++++++++++++++- packages/app/src/subagents/provider-store.ts | 32 ++++- packages/app/src/subagents/select.test.ts | 34 ++++- packages/app/src/subagents/select.ts | 2 +- .../src/subagents/track-presentation.test.ts | 36 +++++ .../app/src/subagents/track-presentation.ts | 4 + packages/app/src/subagents/track.tsx | 77 ++++++++--- .../use-hide-finished-provider-subagents.ts | 16 +++ .../server/agent/providers/claude/agent.ts | 11 +- .../agent.voice-history-regression.test.ts | 16 ++- .../claude/sidechain-tracker.test.ts | 38 ++++++ .../providers/claude/sidechain-tracker.ts | 12 +- .../providers/codex-app-server-agent.test.ts | 80 +++++++++++- .../agent/providers/codex-app-server-agent.ts | 30 ++++- .../providers/codex/tool-call-mapper.test.ts | 60 ++++++++- .../agent/providers/codex/tool-call-mapper.ts | 22 +++- 28 files changed, 600 insertions(+), 49 deletions(-) create mode 100644 packages/app/src/subagents/use-hide-finished-provider-subagents.ts create mode 100644 packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 01e392ffa..a6778364e 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -89,7 +89,9 @@ Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal int Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. -Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no Paseo lifecycle controls and disappear only when the provider removes them or the parent session is discarded. +Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls. + +The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track. To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. @@ -109,7 +111,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway ### Subagent accumulation under long-lived parents -A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem. +A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts. ### Cross-client tab dismissal diff --git a/packages/app/e2e/provider-subagents.real.spec.ts b/packages/app/e2e/provider-subagents.real.spec.ts index 62c08694e..a4bf02fad 100644 --- a/packages/app/e2e/provider-subagents.real.spec.ts +++ b/packages/app/e2e/provider-subagents.real.spec.ts @@ -14,6 +14,7 @@ import { openSubagentsTrack } from "./helpers/subagents"; interface ProviderSubagentCase { provider: RewindFlowProvider; sentinel: string; + expectedName: string; prompt: string; providerConfig?: Parameters[0]["providerConfig"]; } @@ -22,20 +23,23 @@ const cases: ProviderSubagentCase[] = [ { provider: "claude", sentinel: "CLAUDE_CHILD_SENTINEL", + expectedName: "sentinel_child", providerConfig: { model: "opus" }, prompt: - "Use the Task tool exactly once with the Explore subagent. Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", + 'Use Claude Code\'s native Task tool exactly once. Set its subagent_type input to "Explore" and its name input to "sentinel_child". Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE. Do not use Paseo tools.', }, { provider: "codex", sentinel: "CODEX_CHILD_SENTINEL", + expectedName: "Sentinel child", providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } }, prompt: - 'Use collaboration.spawn_agent exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE.', + 'Use the native collaboration.spawn_agent tool exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE. Do not use Paseo tools.', }, { provider: "opencode", sentinel: "OPENCODE_CHILD_SENTINEL", + expectedName: "Explore", prompt: "Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", }, @@ -66,6 +70,7 @@ test.describe("real provider subagent timelines", () => { const rows = page.locator('[data-testid^="subagents-track-row-"]'); await expect(rows).toHaveCount(1, { timeout: 60_000 }); + await expect(rows.first()).toContainText(scenario.expectedName); await rows.first().click(); const panel = page.getByTestId("provider-subagent-panel"); @@ -76,6 +81,15 @@ test.describe("real provider subagent timelines", () => { await expect( panel.getByText("Start chatting with this agent...", { exact: true }), ).toHaveCount(0); + + await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click(); + await expect( + page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(), + ).toBeVisible({ timeout: 60_000 }); + const archiveFinished = page.getByTestId("subagents-track-archive-finished"); + await expect(archiveFinished).toBeVisible({ timeout: 30_000 }); + await archiveFinished.click(); + await expect(rows).toHaveCount(0, { timeout: 30_000 }); } finally { await cleanupRewindFlow({ handle, cwd }); } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 52920c2bc..b5864d682 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1370,6 +1370,8 @@ export const ar: TranslationResources = { detachTooltip: "فصل الوكيل الفرعي", archiveAction: "أرشيف{{label}}", archiveTooltip: "أرشفة الوكيل الفرعي", + archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين", + archiveFinishedTooltip: "أرشفة المكتملين", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index cfe4612cb..213961f7c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1378,6 +1378,8 @@ export const en = { detachTooltip: "Detach subagent", archiveAction: "Archive {{label}}", archiveTooltip: "Archive subagent", + archiveFinishedAction: "Archive finished subagents", + archiveFinishedTooltip: "Archive finished", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1172b96ca..e8ecb927f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1409,6 +1409,8 @@ export const es: TranslationResources = { detachTooltip: "Separar subagente", archiveAction: "Archivo{{label}}", archiveTooltip: "Subagente de archivo", + archiveFinishedAction: "Archivar subagentes finalizados", + archiveFinishedTooltip: "Archivar finalizados", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 9a8b32c4e..81cf5fddd 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1412,6 +1412,8 @@ export const fr: TranslationResources = { detachTooltip: "Detacher le sous-agent", archiveAction: "Archiver{{label}}", archiveTooltip: "Sous-agent d'archivage", + archiveFinishedAction: "Archiver les sous-agents terminés", + archiveFinishedTooltip: "Archiver les terminés", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 035970583..849aa62bd 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1387,6 +1387,8 @@ export const ja: TranslationResources = { detachTooltip: "サブエージェントを切り離す", archiveAction: "{{label}}をアーカイブ", archiveTooltip: "サブエージェントをアーカイブ", + archiveFinishedAction: "完了したサブエージェントをアーカイブ", + archiveFinishedTooltip: "完了した項目をアーカイブ", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8966fe0cc..f4d6a4d5d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1395,6 +1395,8 @@ export const ptBR: TranslationResources = { detachTooltip: "Desanexar subagente", archiveAction: "Arquivar {{label}}", archiveTooltip: "Arquivar subagente", + archiveFinishedAction: "Arquivar subagentes concluídos", + archiveFinishedTooltip: "Arquivar concluídos", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index bd1ea0172..9755b2425 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1401,6 +1401,8 @@ export const ru: TranslationResources = { detachTooltip: "Отсоединить субагент", archiveAction: "Архив{{label}}", archiveTooltip: "Архивный субагент", + archiveFinishedAction: "Архивировать завершенные субагенты", + archiveFinishedTooltip: "Архивировать завершенные", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 067b10304..fce6d1266 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1354,6 +1354,8 @@ export const zhCN: TranslationResources = { detachTooltip: "分离 subagent", archiveAction: "归档 {{label}}", archiveTooltip: "归档 subagent", + archiveFinishedAction: "归档已完成的 subagent", + archiveFinishedTooltip: "归档已完成项", }, panels: { draft: { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 73bf635f9..ba7b899a7 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -68,7 +68,12 @@ import { type Agent, useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import type { Theme } from "@/styles/theme"; -import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents"; +import { + useHideFinishedProviderSubagents, + useArchiveSubagent, + useDetachSubagent, + useSubagentsForParent, +} from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; @@ -1390,6 +1395,10 @@ function ActiveAgentComposer({ ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId }); + const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({ + serverId, + parentAgentId: agentId, + }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, cwd, @@ -1490,6 +1499,7 @@ function ActiveAgentComposer({ onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} onArchiveSubagent={handleArchiveSubagent} + onArchiveFinished={handleHideFinishedProviderSubagents} onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined} /> { - useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() }); + useProviderSubagentStore.setState({ + descriptors: new Map(), + timelines: new Map(), + hiddenFromTrack: new Set(), + }); }); describe("provider subagent client store", () => { @@ -129,6 +133,123 @@ describe("provider subagent client store", () => { ).toBe(false); }); + test("hides finished children locally without removing their timelines", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Finished child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + }); + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Finished output." }, + }); + + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + + const state = useProviderSubagentStore.getState(); + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(state.descriptors.get(key)?.title).toBe("Finished child"); + expect(state.hiddenFromTrack.has(key)).toBe(true); + expect(state.timelines.get(key)?.tail).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Finished output." }), + ]); + }); + + test("reveals a hidden child when the provider reports it running again", () => { + const store = useProviderSubagentStore.getState(); + const completed = { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex" as const, + title: "Finished child", + description: null, + status: "completed" as const, + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }; + store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.replaceList(SERVER_ID, PARENT_ID, [completed]); + + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(true); + + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { ...completed, status: "running", updatedAt: "2026-07-12T10:01:00.000Z" }, + }); + + expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(false); + }); + + test("keeps hidden state when a child temporarily disappears from the provider list", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Finished child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + }); + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + + store.replaceList(SERVER_ID, PARENT_ID, []); + + const state = useProviderSubagentStore.getState(); + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(state.descriptors.has(key)).toBe(false); + expect(state.hiddenFromTrack.has(key)).toBe(true); + }); + + test("keeps a finished child hidden across remove and history replay", () => { + const store = useProviderSubagentStore.getState(); + const completed = { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex" as const, + title: "Finished child", + description: null, + status: "completed" as const, + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }; + store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); + store.hideFinishedForParent(SERVER_ID, PARENT_ID); + store.applyUpdate(SERVER_ID, { + kind: "remove", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + }); + store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed }); + + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(true); + }); test("applies terminal list status to a timeline received before its descriptor", () => { const store = useProviderSubagentStore.getState(); store.applyUpdate(SERVER_ID, { diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index f0d76ee70..6bd8b90f2 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -32,6 +32,8 @@ export interface ProviderSubagentTimelineState { interface ProviderSubagentState { descriptors: Map; timelines: Map; + hiddenFromTrack: Set; + hideFinishedForParent(serverId: string, parentAgentId: string): void; replaceList( serverId: string, parentAgentId: string, @@ -193,14 +195,32 @@ function buildTimelineResponseRows( export const useProviderSubagentStore = create((set) => ({ descriptors: new Map(), timelines: new Map(), + hiddenFromTrack: new Set(), + hideFinishedForParent(serverId, parentAgentId) { + set((state) => { + const prefix = parentPrefix(serverId, parentAgentId); + const hiddenFromTrack = new Set(state.hiddenFromTrack); + for (const [key, subagent] of state.descriptors) { + if (key.startsWith(prefix) && subagent.status !== "running") { + hiddenFromTrack.add(key); + } + } + return { hiddenFromTrack }; + }); + }, replaceList(serverId, parentAgentId, subagents) { set((state) => { const prefix = parentPrefix(serverId, parentAgentId); const descriptors = new Map( [...state.descriptors].filter(([key]) => !key.startsWith(prefix)), ); + const hiddenFromTrack = new Set(state.hiddenFromTrack); for (const subagent of subagents) { - descriptors.set(providerSubagentKey(serverId, parentAgentId, subagent.id), subagent); + const key = providerSubagentKey(serverId, parentAgentId, subagent.id); + descriptors.set(key, subagent); + if (subagent.status === "running") { + hiddenFromTrack.delete(key); + } } const retainedKeys = new Set(descriptors.keys()); const timelines = new Map( @@ -217,7 +237,7 @@ export const useProviderSubagentStore = create((set) => ( ); } } - return { descriptors, timelines }; + return { descriptors, timelines, hiddenFromTrack }; }); }, applyUpdate(serverId, payload) { @@ -229,8 +249,12 @@ export const useProviderSubagentStore = create((set) => ( payload.subagent.id, ); const descriptors = new Map(state.descriptors); + const hiddenFromTrack = new Set(state.hiddenFromTrack); const previous = descriptors.get(key); descriptors.set(key, payload.subagent); + if (payload.subagent.status === "running") { + hiddenFromTrack.delete(key); + } let timelines = state.timelines; const current = state.timelines.get(key); if (current && previous?.status !== payload.subagent.status) { @@ -240,13 +264,13 @@ export const useProviderSubagentStore = create((set) => ( buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder), ); } - return { descriptors, timelines }; + return { descriptors, timelines, hiddenFromTrack }; } if (payload.kind === "remove") { const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); const descriptors = new Map(state.descriptors); - const timelines = new Map(state.timelines); descriptors.delete(key); + const timelines = new Map(state.timelines); timelines.delete(key); return { descriptors, timelines }; } diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 4a8599467..7545ea27c 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -59,7 +59,11 @@ function setAgents(agents: Agent[]): void { afterEach(() => { useSessionStore.getState().clearSession(SERVER_ID); - useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() }); + useProviderSubagentStore.setState({ + descriptors: new Map(), + timelines: new Map(), + hiddenFromTrack: new Set(), + }); }); describe("selectSubagentsForParent", () => { @@ -90,6 +94,34 @@ describe("selectSubagentsForParent", () => { ).toEqual(["provider-child"]); }); + it("hides locally dismissed provider children while retaining their descriptor", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: "provider-child", + parentAgentId: "parent-a", + provider: "codex", + title: "Provider child", + description: null, + status: "completed", + createdAt: "2026-03-08T10:01:00.000Z", + updatedAt: "2026-03-08T10:02:00.000Z", + toolCallId: "call-1", + }, + }); + store.hideFinishedForParent(SERVER_ID, "parent-a"); + + expect( + selectProviderSubagentsForParent( + useProviderSubagentStore.getState(), + { serverId: SERVER_ID, parentAgentId: "parent-a" }, + true, + ), + ).toEqual([]); + expect(useProviderSubagentStore.getState().descriptors.size).toBe(1); + }); + it("returns only non-archived children for the requested parent", () => { setAgents([ makeAgent({ id: "parent-a" }), diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index a235cc445..d96c1628a 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -91,7 +91,7 @@ export function selectProviderSubagentsForParent( const rows: ProviderSubagentRow[] = []; const prefix = `${params.serverId}\0${params.parentAgentId}\0`; for (const [key, subagent] of state.descriptors) { - if (!key.startsWith(prefix)) continue; + if (!key.startsWith(prefix) || state.hiddenFromTrack.has(key)) continue; rows.push({ kind: "provider", id: subagent.id, diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 2802c1707..5f74fd6bd 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { PaseoSubagentRow, SubagentRow } from "./select"; import { buildSubagentRowPresentationData, + countFinishedSubagents, formatHeaderLabel, resolveRowLabel, } from "./track-presentation"; @@ -72,6 +73,41 @@ describe("formatHeaderLabel", () => { }); }); +describe("countFinishedSubagents", () => { + it("counts only terminal provider-owned children", () => { + const providerRows: SubagentRow[] = [ + { + kind: "provider", + id: "native-running", + parentAgentId: "parent", + provider: "claude", + title: "running", + status: "running", + requiresAttention: false, + createdAt: new Date("2026-04-20T00:00:00.000Z"), + }, + { + kind: "provider", + id: "native-failed", + parentAgentId: "parent", + provider: "claude", + title: "failed", + status: "failed", + requiresAttention: true, + createdAt: new Date("2026-04-20T00:00:01.000Z"), + }, + ]; + + expect( + countFinishedSubagents([ + row({ id: "managed-running", status: "running" }), + row({ id: "managed-idle", status: "idle" }), + ...providerRows, + ]), + ).toBe(1); + }); +}); + describe("resolveRowLabel", () => { it("returns null when title is not a string", () => { expect(resolveRowLabel(null as unknown as SubagentRow["title"])).toBe(null); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index d7255cc77..c4baa42f8 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -48,6 +48,10 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string { return parts.join(" · "); } +export function countFinishedSubagents(rows: readonly SubagentRow[]): number { + return rows.filter((row) => row.kind === "provider" && row.status !== "running").length; +} + export function resolveRowLabel(title: SubagentRow["title"]): string | null { if (typeof title !== "string") { return null; diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index c0ff19ec3..9dd38c19b 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -13,7 +13,11 @@ import { } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; import type { SubagentRow } from "./select"; -import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-presentation"; +import { + buildSubagentRowPresentationData, + countFinishedSubagents, + formatHeaderLabel, +} from "./track-presentation"; const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); @@ -30,6 +34,7 @@ export interface SubagentsTrackProps { onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; onArchiveSubagent: (id: string) => void; + onArchiveFinished?: () => void; onDetachSubagent?: (id: string) => void; } @@ -47,8 +52,10 @@ export function SubagentsTrack({ onOpenSubagent, onOpenProviderSubagent, onArchiveSubagent, + onArchiveFinished, onDetachSubagent, }: SubagentsTrackProps): ReactElement | null { + const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const toggleExpanded = useCallback(() => { @@ -62,10 +69,13 @@ export function SubagentsTrack({ const headerStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType) => [ - styles.header, - expanded ? styles.headerDivider : styles.headerCollapsed, + styles.headerToggle, (hovered || pressed) && styles.headerActive, ], + [], + ); + const headerContainerStyle = useMemo( + () => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed], [expanded], ); @@ -74,27 +84,42 @@ export function SubagentsTrack({ } const headerLabel = formatHeaderLabel(rows); + const finishedCount = countFinishedSubagents(rows); return ( - - {expanded ? ( - - ) : ( - - )} - - {headerLabel} - - + + + {expanded ? ( + + ) : ( + + )} + + {headerLabel} + + + {finishedCount > 0 && onArchiveFinished ? ( + + + + ) : null} + {expanded ? ( ({ header: { flexDirection: "row", alignItems: "center", + }, + headerToggle: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], + paddingLeft: theme.spacing[3], + paddingRight: theme.spacing[1], paddingVertical: theme.spacing[2], }, + headerAction: { + paddingRight: theme.spacing[2], + }, headerCollapsed: { - paddingBottom: theme.spacing[6], + paddingBottom: theme.spacing[4], }, headerActive: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/subagents/use-hide-finished-provider-subagents.ts b/packages/app/src/subagents/use-hide-finished-provider-subagents.ts new file mode 100644 index 000000000..7058fc8f1 --- /dev/null +++ b/packages/app/src/subagents/use-hide-finished-provider-subagents.ts @@ -0,0 +1,16 @@ +import { useCallback } from "react"; +import { useProviderSubagentStore } from "./provider-store"; + +export interface UseHideFinishedProviderSubagentsInput { + serverId: string; + parentAgentId: string; +} + +export function useHideFinishedProviderSubagents({ + serverId, + parentAgentId, +}: UseHideFinishedProviderSubagentsInput): () => void { + return useCallback(() => { + useProviderSubagentStore.getState().hideFinishedForParent(serverId, parentAgentId); + }, [parentAgentId, serverId]); +} diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 60001add3..99a6acfe2 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -5036,6 +5036,7 @@ function readClaudeSidechainHistory(historyPath: string): string[] { } interface ClaudeHistoricalSubagentToolCall { + name?: string; subagentType?: string; description?: string; } @@ -5057,9 +5058,11 @@ function readClaudeHistoricalSubagentToolCalls( continue; } const input = toObjectRecord(block.input); + const name = readNonEmptyString(input?.name); const subagentType = readNonEmptyString(input?.subagent_type); const description = readNonEmptyString(input?.description); toolCalls.set(block.id, { + ...(name ? { name } : {}), ...(subagentType ? { subagentType } : {}), ...(description ? { description } : {}), }); @@ -5121,6 +5124,12 @@ function buildClaudePersistedSidechainEvents( return events; } +function resolveClaudeHistoricalSubagentTitle( + toolCall: ClaudeHistoricalSubagentToolCall | undefined, +): string { + return toolCall?.name ?? toolCall?.subagentType ?? "Claude subagent"; +} + function buildClaudePersistedSidechainAgentEvents( agentId: string, entries: ClaudeHistoryEntry[], @@ -5139,7 +5148,7 @@ function buildClaudePersistedSidechainAgentEvents( event: { type: "upsert", id, - title: toolCall?.subagentType ?? "Claude subagent", + title: resolveClaudeHistoricalSubagentTitle(toolCall), description: toolCall?.description ?? null, status: "running", toolCallId: result?.toolCallId ?? null, diff --git a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts index a9d852a45..6dab8d205 100644 --- a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts @@ -139,7 +139,11 @@ describe("ClaudeAgentSession history replay regression", () => { type: "tool_use", id: "history-task-call", name: "Agent", - input: { description: "Inspect persisted history" }, + input: { + name: "history_researcher", + subagent_type: "Explore", + description: "Inspect persisted history", + }, }, ], }, @@ -303,6 +307,16 @@ describe("ClaudeAgentSession history replay regression", () => { timestamp: "2026-07-12T10:00:01.000Z", }, }); + expect(historyEvents).toContainEqual({ + type: "provider_subagent", + provider: "claude", + event: expect.objectContaining({ + type: "upsert", + id: "history-task-call", + title: "history_researcher", + status: "running", + }), + }); expect(historyEvents).toContainEqual({ type: "provider_subagent", provider: "claude", diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts new file mode 100644 index 000000000..615ca81f3 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; + +describe("ClaudeSidechainTracker", () => { + it("uses Claude's native agent name for the provider subagent title", () => { + const tracker = new ClaudeSidechainTracker({ + getToolInput: () => ({ + name: "repo_researcher", + subagent_type: "Explore", + description: "Inspect the repository", + }), + }); + + const events = tracker.handleMessage( + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { content: [] }, + } as unknown as SDKMessage, + "task-1", + ); + + expect(events[0]).toEqual({ + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id: "task-1", + title: "repo_researcher", + description: "Inspect the repository", + status: "running", + toolCallId: "task-1", + }, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index aef9e91b6..d5257e3d1 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -22,6 +22,7 @@ interface SubAgentActionEntry { } interface SubAgentActivityState { + name?: string; subAgentType?: string; description?: string; actions: SubAgentActionEntry[]; @@ -130,7 +131,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id: parentToolUseId, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status: "running", toolCallId: parentToolUseId, @@ -163,7 +164,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status, toolCallId: id, @@ -185,7 +186,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status, toolCallId: id, @@ -267,10 +268,15 @@ export class ClaudeSidechainTracker { parentToolUseId: string, ): boolean { const taskInput = this.getToolInput(parentToolUseId); + const nextName = this.normalizeSubAgentText(taskInput?.name); const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type); const nextDescription = this.normalizeSubAgentText(taskInput?.description); let changed = false; + if (nextName && nextName !== state.name) { + state.name = nextName; + changed = true; + } if (nextSubAgentType && nextSubAgentType !== state.subAgentType) { state.subAgentType = nextSubAgentType; changed = true; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 228f67124..569370cae 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1847,6 +1847,60 @@ describe("Codex app-server provider", () => { }); }); + test("updates a registered child with its later native activity name", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "collabAgentToolCall", + id: "call-native-name-later", + tool: "spawnAgent", + status: "completed", + prompt: "Inspect the repository.", + receiverThreadIds: ["child-native-name-later"], + agentsStates: { + "child-native-name-later": { status: "pendingInit", message: null }, + }, + }, + }); + asInternals(session).handleNotification("item/started", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: "activity-native-name-later", + kind: "started", + agentThreadId: "child-native-name-later", + agentPath: "/root/research/investigator", + }, + }); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + provider: "codex", + event: expect.objectContaining({ + type: "upsert", + id: "child-native-name-later", + title: "Research / Investigator", + }), + }), + ); + expect(events.at(-1)).toMatchObject({ + type: "timeline", + item: { + callId: "call-native-name-later", + detail: { + type: "sub_agent", + subAgentType: "Research / Investigator", + description: "Inspect the repository.", + }, + }, + }); + }); + test("renders child MCP image results in the provider subagent timeline", () => { const session = createSession(); const events: AgentStreamEvent[] = []; @@ -2500,7 +2554,7 @@ describe("Codex app-server provider", () => { status: "running", detail: { type: "sub_agent", - description: "/root/legacy-only-child", + description: "legacy-only-child", }, }, }); @@ -2879,6 +2933,13 @@ describe("Codex app-server provider", () => { receiverThreadIds: ["legacy-child-thread"], agentsStates: { "legacy-child-thread": { status: "completed" } }, }, + { + type: "subAgentActivity", + id: "legacy-native-name-history", + kind: "started", + agentThreadId: "legacy-child-thread", + agentPath: "/root/sentinel_child", + }, { type: "subAgentActivity", id: "v2-spawn-history", @@ -2905,7 +2966,12 @@ describe("Codex app-server provider", () => { event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], ), ).toMatchObject([ - { type: "upsert", id: "legacy-child-thread", status: "completed" }, + { + type: "upsert", + id: "legacy-child-thread", + status: "completed", + title: "Sentinel child", + }, { type: "upsert", id: "v2-child-thread", status: "completed" }, ]); expect( @@ -2940,12 +3006,16 @@ describe("Codex app-server provider", () => { { callId: "legacy-spawn-history", status: "completed", - detail: { type: "sub_agent", description: "Legacy child" }, + detail: { + type: "sub_agent", + description: "Legacy child", + subAgentType: "Sentinel child", + }, }, { callId: "v2-spawn-history", status: "completed", - detail: { type: "sub_agent", description: "/root/v2-child" }, + detail: { type: "sub_agent", description: "v2-child" }, }, ]); @@ -3067,7 +3137,7 @@ describe("Codex app-server provider", () => { status: "canceled", detail: expect.objectContaining({ type: "sub_agent", - description: "/root/history-child", + description: "history-child", }), }), }, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 48aeddb90..fd6b7b68e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1668,14 +1668,22 @@ function updateHistoricalSubAgentActivity( timeline: PersistedTimelineEntry[], index: number, kind: CodexSubAgentActivity["kind"], + subAgentType?: string, ): void { const existing = timeline[index]; if (existing?.item.type !== "tool_call") { return; } + const settledItem = settleHistoricalSubAgentActivity(existing.item, kind); timeline[index] = { ...existing, - item: settleHistoricalSubAgentActivity(existing.item, kind), + item: + subAgentType && settledItem.detail.type === "sub_agent" + ? { + ...settledItem, + detail: { ...settledItem.detail, subAgentType }, + } + : settledItem, }; } @@ -1869,10 +1877,15 @@ async function loadCodexThreadHistoryTimeline(params: { historicalSubAgentActivity.agentThreadId, ); if (existingIndex !== undefined) { + const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd }); updateHistoricalSubAgentActivity( timeline, existingIndex, historicalSubAgentActivity.kind, + activityTimelineItem?.type === "tool_call" && + activityTimelineItem.detail.type === "sub_agent" + ? activityTimelineItem.detail.subAgentType + : undefined, ); continue; } @@ -4843,6 +4856,21 @@ export class CodexAppServerAgentSession implements AgentSession { if (activity.id) { state.activityItemIds.add(activity.id); } + const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, { + cwd: this.config.cwd ?? null, + }); + if ( + activityToolCall?.detail.type === "sub_agent" && + state.toolCall.detail.type === "sub_agent" + ) { + state.toolCall = { + ...state.toolCall, + detail: { + ...state.toolCall.detail, + subAgentType: activityToolCall.detail.subAgentType, + }, + }; + } this.emitSubAgentActivityUpdate( callId, activity.kind === "interrupted" ? "canceled" : "running", diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 6c8919e52..f9df7ee62 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -234,7 +234,7 @@ describe("codex tool-call mapper", () => { id: `activity-${kind}`, kind, agentThreadId: "child-thread-1", - agentPath: "/root/investigator", + agentPath: "/root/research/investigator", }); expect(item).toEqual({ @@ -245,8 +245,8 @@ describe("codex tool-call mapper", () => { error: null, detail: { type: "sub_agent", - subAgentType: "Sub-agent", - description: "/root/investigator", + subAgentType: "Research / Investigator", + description: "research/investigator", log: "", actions: [], }, @@ -267,6 +267,60 @@ describe("codex tool-call mapper", () => { }); }); + it("humanizes a subagent task name for display", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "subAgentActivity", + id: "activity-human-name", + kind: "started", + agentThreadId: "child-thread-human-name", + agentPath: "/root/hello_one", + }); + + expect(item).toMatchObject({ + detail: { + type: "sub_agent", + subAgentType: "Hello one", + description: "hello_one", + }, + }); + }); + + it("uses only the final segment of a subAgentActivity path outside the root namespace", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "subAgentActivity", + id: "activity-external-path", + kind: "started", + agentThreadId: "child-thread-external-path", + agentPath: "/tmp/native/investigator", + }); + + expect(item).toMatchObject({ + detail: { + type: "sub_agent", + subAgentType: "Investigator", + description: "investigator", + }, + }); + }); + + it("uses only the final segment of a Windows subAgentActivity path", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "subAgentActivity", + id: "activity-windows-path", + kind: "started", + agentThreadId: "child-thread-windows-path", + agentPath: "C:\\Users\\dev\\agents\\investigator", + }); + + expect(item).toMatchObject({ + detail: { + type: "sub_agent", + subAgentType: "Investigator", + description: "investigator", + }, + }); + }); + it("does not fail a collabAgentToolCall from child error state alone", () => { const item = mapCodexToolCallFromThreadItem({ type: "collabAgentToolCall", diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 967b6b43a..b3775e42c 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -993,6 +993,24 @@ function mapCollabAgentToolCallItem( function mapSubAgentActivityItem( item: z.infer, ): ToolCallTimelineItem { + let nativeName = item.agentPath; + if (nativeName === "/root") { + nativeName = ""; + } else if (nativeName.startsWith("/root/")) { + nativeName = nativeName.slice("/root/".length); + } else if (/[\\/]/.test(nativeName)) { + nativeName = nativeName.slice( + Math.max(nativeName.lastIndexOf("/"), nativeName.lastIndexOf("\\")) + 1, + ); + } + const description = nativeName; + nativeName = nativeName + .split("/") + .map((segment) => segment.replace(/[_-]+/g, " ").trim()) + .filter(Boolean) + .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) + .join(" / "); + nativeName ||= "Sub-agent"; return { type: "tool_call", callId: item.id, @@ -1001,8 +1019,8 @@ function mapSubAgentActivityItem( error: null, detail: { type: "sub_agent", - subAgentType: "Sub-agent", - description: item.agentPath, + subAgentType: nativeName, + description, log: "", actions: [], }, From 36738464339ff6d39c983294acfd2b82db762b3d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 11:50:10 +0200 Subject: [PATCH 10/20] fix(app): keep single tool calls in overview list --- packages/app/src/agent-stream/view.tsx | 4 -- .../tool-calls/detail-level/overview/view.tsx | 65 +++---------------- 2 files changed, 9 insertions(+), 60 deletions(-) diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 3da69787d..29caafc42 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -775,10 +775,8 @@ const AgentStreamViewComponent = forwardRef {expanded ? group.run.calls.map((call, index) => ( @@ -796,9 +794,7 @@ const AgentStreamViewComponent = forwardRef void; - cwd?: string; children: ReactNode; } @@ -61,63 +54,23 @@ function useOverviewSummary(summary: OverviewSummary): string { export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({ group, expanded, - isCompact, isLastInSequence, onExpandedChange, - cwd, children, }: OverviewGroupProps) { const { t } = useTranslation(); - const { openToolCall } = useToolCallSheet(); const scrollRef = useRef(null); const aggregateSummary = useOverviewSummary(group.summary); - const latest = useMemo(() => { - const descriptor = describeToolCall(group.run.latest); - return { - detail: descriptor.detail, - presentation: buildToolCallPresentation({ - toolName: descriptor.name, - status: descriptor.status, - error: descriptor.error, - detail: descriptor.detail, - metadata: descriptor.metadata, - cwd, - resolveIcon: resolveToolCallIcon, - }), - }; - }, [cwd, group.run.latest]); - const opensSingleCallSheet = isCompact && group.run.calls.length === 1; const failedSummary = group.failedCount > 0 ? t("toolCallGroup.failed", { count: group.failedCount }) : undefined; const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); const toggle = useCallback(() => { - if (opensSingleCallSheet) { - openToolCall({ - displayName: latest.presentation.displayName, - summary: latest.presentation.summary, - detail: latest.detail, - errorText: latest.presentation.errorText, - icon: latest.presentation.icon, - showLoadingSkeleton: latest.presentation.isLoadingDetails, - }); - return; - } onExpandedChange(group.run.id, !expanded); - }, [expanded, group.run.id, latest, onExpandedChange, openToolCall, opensSingleCallSheet]); - const renderDetails = useCallback(() => { - if (group.run.calls.length === 1) { - return ( - - ); - } - return ( + }, [expanded, group.run.id, onExpandedChange]); + const renderDetails = useCallback( + () => ( {children} - ); - }, [children, group.run.calls.length, latest, scrollToLatest]); - const canExpand = group.run.calls.length > 1 || latest.presentation.canOpenDetails; + ), + [children, scrollToLatest], + ); return ( 0} - isExpanded={opensSingleCallSheet ? false : expanded} + isExpanded={expanded} isLastInSequence={isLastInSequence} - onToggle={canExpand ? toggle : undefined} - renderDetails={canExpand && !opensSingleCallSheet ? renderDetails : undefined} + onToggle={toggle} + renderDetails={renderDetails} borderlessWhenExpanded /> ); From 5ddd5f3726782ef6793b6cfd304aab0097b089c0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 11:54:59 +0200 Subject: [PATCH 11/20] perf(app): keep desktop sidebar ready to reopen Retain the hidden sidebar tree so common toggles preserve local UI state while existing activity gates pause expensive hidden work. --- docs/mobile-panels.md | 3 ++ packages/app/e2e/sidebar-workspace.spec.ts | 41 ++++++++++++++++++++ packages/app/src/app/_layout.tsx | 22 ++++++----- packages/app/src/components/left-sidebar.tsx | 39 +++++++++++-------- 4 files changed, 79 insertions(+), 26 deletions(-) diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 46c385d20..28ee87f1d 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -75,6 +75,9 @@ definition, no longer eligible to begin. so its injected `collapsable={false}` reaches Android/Fabric. - Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion styles in sidebar components. +- The desktop left sidebar is retained too. App chrome owns separate mounted and visible decisions: + closing it or yielding its width marks it inactive and applies `display: none` without conditionally + removing the sidebar tree. - Animated panel nodes use React Native static styles plus inline theme values. Do not attach Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node has caused native crashes. diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index 2fd0f5868..d23085852 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -169,6 +169,47 @@ test.describe("Mobile sidebar panelState transition", () => { test.describe("Half-screen desktop layout", () => { test.use({ viewport: { width: 751, height: 982 } }); + test("keeps the sidebar scroll position across close and reopen", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-scroll-" }); + + try { + let lastWorkspaceId = workspace.workspaceId; + for (let index = 0; index < 24; index += 1) { + const created = await workspace.client.createWorkspace({ + source: { + kind: "directory", + path: workspace.repoPath, + projectId: workspace.projectId, + }, + title: `Retained sidebar ${index + 1}`, + }); + if (!created.workspace) { + throw new Error(created.error ?? "Failed to fill the retained sidebar"); + } + lastWorkspaceId = created.workspace.id; + } + + await gotoAppShell(page); + await waitForSidebarWorkspace(page, lastWorkspaceId); + + const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll"); + const scrollTop = await sidebarScroll.evaluate((element) => { + element.scrollTop = 160; + return element.scrollTop; + }); + expect(scrollTop).toBe(160); + + await page.getByTestId("menu-button").click(); + await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible(); + + await page.getByTestId("menu-button").click(); + await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); + await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop); + } finally { + await workspace.cleanup(); + } + }); + test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => { await gotoAppShell(page); await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible(); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 606d4b530..bbd751d4b 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -482,10 +482,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon requestedExplorerWidth: explorerWidth, viewportWidth, }); - const desktopSidebarRendered = + const desktopSidebarMounted = chromeEnabled && !isFocusModeEnabled; + const desktopSidebarVisible = !isCompactLayout && - chromeEnabled && - !isFocusModeEnabled && + desktopSidebarMounted && isDesktopAgentListOpen && canDesktopAppSidebarShare({ contentMinimumWidth: appContentMinimumWidth, @@ -494,13 +494,14 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon }); const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); const appChromeLayout = resolveDesktopAppChromeLayout({ - desktopSidebarRendered, + desktopSidebarRendered: desktopSidebarVisible, hasTopLeftWindowControls, sidebarControlsEnabled: chromeEnabled && !isFocusModeEnabled, }); const sidebarChrome = ( ); @@ -567,19 +568,22 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon } function SidebarChrome({ - showSidebar, + mounted, + visible, keyboardShortcutsEnabled, }: { - showSidebar: boolean; + mounted: boolean; + visible: boolean; keyboardShortcutsEnabled: boolean; }) { const isCompactLayout = useIsCompactFormFactor(); const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isCompactLayout }), ); + const active = visible && isOpen; return ( - - {showSidebar ? : null} + + {mounted ? : null} ); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 11adaa60f..cd5be3247 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -51,7 +51,7 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHosts } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspace } from "@/stores/session-store-hooks"; -import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; +import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; @@ -117,19 +117,16 @@ interface MobileSidebarProps extends SidebarSharedProps { interface DesktopSidebarProps extends SidebarSharedProps { insetsTop: number; - isOpen: boolean; + active: boolean; handleViewMore: () => void; handleViewSchedules: () => void; } -export const LeftSidebar = memo(function LeftSidebar() { +export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) { const { theme } = useUnistyles(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const isCompactLayout = useIsCompactFormFactor(); - const isOpen = usePanelStore((state) => - selectIsAgentListOpen(state, { isCompact: isCompactLayout }), - ); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const { @@ -256,7 +253,7 @@ export const LeftSidebar = memo(function LeftSidebar() { if (isCompactLayout) { return ( - + + [staticStyles.desktopSidebar, resizeAnimatedStyle], - [resizeAnimatedStyle], + () => [ + staticStyles.desktopSidebar, + !active && staticStyles.desktopSidebarHidden, + resizeAnimatedStyle, + ], + [active, resizeAnimatedStyle], ); const desktopSidebarBorderStyle = useMemo( () => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }], @@ -769,12 +770,13 @@ function DesktopSidebar({ [], ); - if (!isOpen) { - return null; - } - return ( - + {ownsTopLeft ? ( @@ -917,6 +919,9 @@ const staticStyles = RNStyleSheet.create({ desktopSidebar: { position: "relative" as const, }, + desktopSidebarHidden: { + display: "none", + }, }); const styles = StyleSheet.create((theme) => ({ From 4a5630bfcefc728b57cd8fcc5efc4923e786fe17 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 12:04:53 +0200 Subject: [PATCH 12/20] fix(app): stop composer height flicker Native scrollbar appearance changed the textarea's usable width, feeding alternating measurements back into the composer height mirror. Reserve the gutter so wrapping and height remain stable. --- packages/app/src/composer/input/input.tsx | 2 ++ packages/app/src/styles/install-web-scrollbar-styles.web.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index b35a2811d..6d57619ea 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -74,6 +74,7 @@ import { } from "./state"; const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; +const COMPOSER_INPUT_DATASET = { composerInput: "" } as const; export interface AttachmentMenuItem { id: string; @@ -1807,6 +1808,7 @@ export const MessageInput = forwardRef( void { scrollbar-width: ${WEB_SCROLLBAR_WIDTH}; } +[data-composer-input] { + scrollbar-gutter: stable; +} + *::-webkit-scrollbar { width: ${WEB_SCROLLBAR_SIZE_PX}px; height: ${WEB_SCROLLBAR_SIZE_PX}px; From 319d9017c225a7b2d38027959f7de7a71d320ee9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 15:38:52 +0200 Subject: [PATCH 13/20] Publish the latest Android version code (#2085) * feat(website): publish latest Android version code * fix(website): select latest Android APK release --- packages/website/src/android-version.test.ts | 14 ++ packages/website/src/android-version.ts | 22 +++ packages/website/src/cloudflare-cache.ts | 9 ++ packages/website/src/github-cache.ts | 30 ++-- packages/website/src/latest-release.test.ts | 32 ++++ packages/website/src/latest-release.ts | 160 +++++++++++++++++++ packages/website/src/release.ts | 120 +------------- packages/website/src/server-entry.ts | 28 +++- packages/website/src/stars.ts | 2 + 9 files changed, 283 insertions(+), 134 deletions(-) create mode 100644 packages/website/src/android-version.test.ts create mode 100644 packages/website/src/android-version.ts create mode 100644 packages/website/src/cloudflare-cache.ts create mode 100644 packages/website/src/latest-release.test.ts create mode 100644 packages/website/src/latest-release.ts diff --git a/packages/website/src/android-version.test.ts b/packages/website/src/android-version.test.ts new file mode 100644 index 000000000..fa8efba09 --- /dev/null +++ b/packages/website/src/android-version.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getAndroidVersionCode } from "./android-version"; + +describe("getAndroidVersionCode", () => { + it("matches the Android build version code for a stable release", () => { + expect(getAndroidVersionCode("0.1.107")).toBe(1107); + }); + + it("rejects versions that cannot map to a unique Android version code", () => { + expect(() => getAndroidVersionCode("0.1000.0")).toThrow( + "Cannot derive collision-free Android versionCode from version: 0.1000.0", + ); + }); +}); diff --git a/packages/website/src/android-version.ts b/packages/website/src/android-version.ts new file mode 100644 index 000000000..250c0df32 --- /dev/null +++ b/packages/website/src/android-version.ts @@ -0,0 +1,22 @@ +export function getAndroidVersionCode(version: string): number { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version); + if (!match) { + throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`); + } + + const [, majorText, minorText, patchText] = match; + const major = Number(majorText); + const minor = Number(minorText); + const patch = Number(patchText); + + if (minor > 999 || patch > 999) { + throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`); + } + + const versionCode = major * 1_000_000 + minor * 1_000 + patch; + if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) { + throw new Error(`Derived Android versionCode is out of range: ${versionCode}`); + } + + return versionCode; +} diff --git a/packages/website/src/cloudflare-cache.ts b/packages/website/src/cloudflare-cache.ts new file mode 100644 index 000000000..2c61baaf7 --- /dev/null +++ b/packages/website/src/cloudflare-cache.ts @@ -0,0 +1,9 @@ +import { env, waitUntil } from "cloudflare:workers"; +import type { WebsiteCacheContext } from "./github-cache"; + +export function getWebsiteCacheContext(): WebsiteCacheContext { + return { + cache: (env as { WEBSITE_CACHE?: KVNamespace }).WEBSITE_CACHE ?? null, + waitUntil, + }; +} diff --git a/packages/website/src/github-cache.ts b/packages/website/src/github-cache.ts index 9e9c9daf6..8ee08457e 100644 --- a/packages/website/src/github-cache.ts +++ b/packages/website/src/github-cache.ts @@ -1,7 +1,10 @@ -import { env, waitUntil } from "cloudflare:workers"; - export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000; +export interface WebsiteCacheContext { + cache: KVNamespace | null; + waitUntil: (promise: Promise) => void; +} + interface CachedValue { fetchedAt: number; value: T; @@ -9,10 +12,6 @@ interface CachedValue { type Validator = (value: unknown) => value is T; -function getWebsiteCache(): KVNamespace | null { - return (env as { WEBSITE_CACHE?: KVNamespace }).WEBSITE_CACHE ?? null; -} - function isCachedValue(value: unknown, isValue: Validator): value is CachedValue { if (typeof value !== "object" || value === null) return false; const record = value as Record; @@ -20,17 +19,20 @@ function isCachedValue(value: unknown, isValue: Validator): value is Cache } async function readCachedValue( + cache: KVNamespace | null, key: string, isValue: Validator, ): Promise | null> { - const cache = getWebsiteCache(); if (!cache) return null; const cached = await cache.get(key, { cacheTtl: 60, type: "json" }); return isCachedValue(cached, isValue) ? cached : null; } -async function writeCachedValue(key: string, value: T): Promise { - const cache = getWebsiteCache(); +async function writeCachedValue( + cache: KVNamespace | null, + key: string, + value: T, +): Promise { if (!cache) return; await cache.put( key, @@ -42,20 +44,22 @@ async function writeCachedValue(key: string, value: T): Promise { } export async function getBlockingColdCache({ + context, key, isValue, fetchFresh, }: { + context: WebsiteCacheContext; key: string; isValue: Validator; fetchFresh: () => Promise; }): Promise { - const cached = await readCachedValue(key, isValue); + const cached = await readCachedValue(context.cache, key, isValue); if (cached) { if (Date.now() - cached.fetchedAt > GITHUB_CACHE_TTL_MS) { - waitUntil( + context.waitUntil( fetchFresh() - .then((fresh) => writeCachedValue(key, fresh)) + .then((fresh) => writeCachedValue(context.cache, key, fresh)) .catch(() => undefined), ); } @@ -63,6 +67,6 @@ export async function getBlockingColdCache({ } const fresh = await fetchFresh(); - await writeCachedValue(key, fresh); + await writeCachedValue(context.cache, key, fresh); return fresh; } diff --git a/packages/website/src/latest-release.test.ts b/packages/website/src/latest-release.test.ts new file mode 100644 index 000000000..e10c26446 --- /dev/null +++ b/packages/website/src/latest-release.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { getLatestAndroidVersionFromReleases, type GitHubRelease } from "./latest-release"; + +function release({ + version, + hasApk, + prerelease = false, +}: { + version: string; + hasApk: boolean; + prerelease?: boolean; +}): GitHubRelease { + const tag = `v${version}`; + return { + tag_name: tag, + assets: hasApk ? [{ name: `paseo-${tag}-android.apk` }] : [], + prerelease, + draft: false, + }; +} + +describe("getLatestAndroidVersionFromReleases", () => { + it("selects the latest stable release that contains an Android APK", () => { + const releases = [ + release({ version: "0.1.109", hasApk: true, prerelease: true }), + release({ version: "0.1.108", hasApk: false }), + release({ version: "0.1.107", hasApk: true }), + ]; + + expect(getLatestAndroidVersionFromReleases(releases)).toBe("0.1.107"); + }); +}); diff --git a/packages/website/src/latest-release.ts b/packages/website/src/latest-release.ts new file mode 100644 index 000000000..afdbf6b56 --- /dev/null +++ b/packages/website/src/latest-release.ts @@ -0,0 +1,160 @@ +import { getBlockingColdCache, type WebsiteCacheContext } from "./github-cache"; + +interface GitHubAsset { + name: string; +} + +export interface GitHubRelease { + tag_name: string; + assets: GitHubAsset[]; + prerelease: boolean; + draft: boolean; +} + +export interface ReleaseInfo { + version: string; + linuxAppImageAsset: string; + windowsX64Asset: string | null; + windowsArm64Asset: string | null; +} + +const LINUX_APPIMAGE_ASSET_PATTERN = + /^Paseo-(?:\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)-)?x86_64\.AppImage$/; + +const REQUIRED_ASSET_PATTERNS = [ + /Paseo-.*-arm64\.dmg$/, + LINUX_APPIMAGE_ASSET_PATTERN, + /Paseo-Setup-.*\.exe$/, +]; + +const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; +const RELEASE_CACHE_KEY = "github-release:v1"; +const ANDROID_RELEASE_CACHE_KEY = "github-android-release:v1"; + +function hasRequiredAssets(release: GitHubRelease): boolean { + return REQUIRED_ASSET_PATTERNS.every((pattern) => + release.assets.some((asset) => pattern.test(asset.name)), + ); +} + +function pickWindowsAssets(assets: GitHubAsset[]) { + const x64Suffixed = assets.find((asset) => /Paseo-Setup-.*-x64\.exe$/.test(asset.name)); + const arm64 = assets.find((asset) => /Paseo-Setup-.*-arm64\.exe$/.test(asset.name)); + const legacy = assets.find( + (asset) => + /Paseo-Setup-.*\.exe$/.test(asset.name) && + !asset.name.endsWith("-x64.exe") && + !asset.name.endsWith("-arm64.exe"), + ); + return { + x64: (x64Suffixed ?? legacy)?.name ?? null, + arm64: arm64?.name ?? null, + }; +} + +function pickLinuxAppImageAsset(assets: GitHubAsset[]) { + return assets.find((asset) => LINUX_APPIMAGE_ASSET_PATTERN.test(asset.name))?.name ?? null; +} + +function versionFromTag(tag: string): string { + return tag.replace(/^v/, ""); +} + +async function fetchGitHubReleases(): Promise { + const response = await fetch(GITHUB_RELEASES_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "paseo-website", + }, + cf: { + cacheEverything: true, + cacheTtl: 60, + cacheKey: "github-releases-latest", + }, + } as RequestInit); + if (!response.ok) throw new Error(`github releases ${response.status}`); + + return (await response.json()) as GitHubRelease[]; +} + +async function fetchLatestReadyRelease(): Promise { + const releases = await fetchGitHubReleases(); + const ready = releases.find( + (release) => !release.prerelease && !release.draft && hasRequiredAssets(release), + ); + if (!ready) throw new Error("no ready GitHub release found"); + + const windowsAssets = pickWindowsAssets(ready.assets); + const linuxAppImageAsset = pickLinuxAppImageAsset(ready.assets); + if (!linuxAppImageAsset) throw new Error("ready release missing Linux AppImage asset"); + + return { + version: versionFromTag(ready.tag_name), + linuxAppImageAsset, + windowsX64Asset: windowsAssets.x64, + windowsArm64Asset: windowsAssets.arm64, + }; +} + +export function getLatestAndroidVersionFromReleases(releases: GitHubRelease[]): string { + const release = releases.find((candidate) => { + if (candidate.prerelease || candidate.draft) return false; + const version = versionFromTag(candidate.tag_name); + if (!/^\d+\.\d+\.\d+$/.test(version)) return false; + return candidate.assets.some( + (asset) => asset.name === `paseo-${candidate.tag_name}-android.apk`, + ); + }); + if (!release) throw new Error("no stable GitHub release with an Android APK found"); + return versionFromTag(release.tag_name); +} + +async function fetchLatestAndroidVersion(): Promise { + return getLatestAndroidVersionFromReleases(await fetchGitHubReleases()); +} + +function isAndroidVersion(value: unknown): value is string { + return typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value); +} + +function isReleaseInfo(value: unknown): value is ReleaseInfo { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ( + typeof record.version === "string" && + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(record.version) && + typeof record.linuxAppImageAsset === "string" && + (record.linuxAppImageAsset === "Paseo-x86_64.AppImage" || + new RegExp(`^Paseo-${record.version.replaceAll(".", "\\.")}-x86_64\\.AppImage$`).test( + record.linuxAppImageAsset, + )) && + (typeof record.windowsX64Asset === "string" || record.windowsX64Asset === null) && + (typeof record.windowsArm64Asset === "string" || record.windowsArm64Asset === null) && + (record.windowsX64Asset === null || + new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}(?:-x64)?\\.exe$`).test( + record.windowsX64Asset, + )) && + (record.windowsArm64Asset === null || + new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}-arm64\\.exe$`).test( + record.windowsArm64Asset, + )) + ); +} + +export async function getLatestReleaseInfo(context: WebsiteCacheContext): Promise { + return getBlockingColdCache({ + context, + key: RELEASE_CACHE_KEY, + isValue: isReleaseInfo, + fetchFresh: fetchLatestReadyRelease, + }); +} + +export async function getLatestAndroidVersion(context: WebsiteCacheContext): Promise { + return getBlockingColdCache({ + context, + key: ANDROID_RELEASE_CACHE_KEY, + isValue: isAndroidVersion, + fetchFresh: fetchLatestAndroidVersion, + }); +} diff --git a/packages/website/src/release.ts b/packages/website/src/release.ts index 815e54ed1..09a759cbf 100644 --- a/packages/website/src/release.ts +++ b/packages/website/src/release.ts @@ -1,121 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; -import { getBlockingColdCache } from "./github-cache"; - -interface GitHubAsset { - name: string; -} - -interface GitHubRelease { - tag_name: string; - assets: GitHubAsset[]; - prerelease: boolean; - draft: boolean; -} - -const LINUX_APPIMAGE_ASSET_PATTERN = - /^Paseo-(?:\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)-)?x86_64\.AppImage$/; - -const REQUIRED_ASSET_PATTERNS = [ - /Paseo-.*-arm64\.dmg$/, // Mac Apple Silicon - LINUX_APPIMAGE_ASSET_PATTERN, // Linux AppImage - /Paseo-Setup-.*\.exe$/, // Windows (any arch) -]; - -function hasRequiredAssets(release: GitHubRelease): boolean { - return REQUIRED_ASSET_PATTERNS.every((pattern) => - release.assets.some((asset) => pattern.test(asset.name)), - ); -} - -function pickWindowsAssets(assets: GitHubAsset[]) { - const x64Suffixed = assets.find((a) => /Paseo-Setup-.*-x64\.exe$/.test(a.name)); - const arm64 = assets.find((a) => /Paseo-Setup-.*-arm64\.exe$/.test(a.name)); - const legacy = assets.find( - (a) => - /Paseo-Setup-.*\.exe$/.test(a.name) && - !a.name.endsWith("-x64.exe") && - !a.name.endsWith("-arm64.exe"), - ); - return { - x64: (x64Suffixed ?? legacy)?.name ?? null, - arm64: arm64?.name ?? null, - }; -} - -function pickLinuxAppImageAsset(assets: GitHubAsset[]) { - return assets.find((a) => LINUX_APPIMAGE_ASSET_PATTERN.test(a.name))?.name ?? null; -} - -function versionFromTag(tag: string): string { - return tag.replace(/^v/, ""); -} - -interface ReleaseInfo { - version: string; - linuxAppImageAsset: string; - windowsX64Asset: string | null; - windowsArm64Asset: string | null; -} - -const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; -const RELEASE_CACHE_KEY = "github-release:v1"; - -async function fetchLatestReadyRelease(): Promise { - const res = await fetch(GITHUB_RELEASES_URL, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "paseo-website", - }, - cf: { - cacheEverything: true, - cacheTtl: 60, - cacheKey: "github-releases-latest", - }, - } as RequestInit); - if (!res.ok) throw new Error(`github releases ${res.status}`); - - const releases = (await res.json()) as GitHubRelease[]; - const ready = releases.find((r) => !r.prerelease && !r.draft && hasRequiredAssets(r)); - if (!ready) throw new Error("no ready GitHub release found"); - const win = pickWindowsAssets(ready.assets); - const linuxAppImageAsset = pickLinuxAppImageAsset(ready.assets); - if (!linuxAppImageAsset) throw new Error("ready release missing Linux AppImage asset"); - return { - version: versionFromTag(ready.tag_name), - linuxAppImageAsset, - windowsX64Asset: win.x64, - windowsArm64Asset: win.arm64, - }; -} - -function isReleaseInfo(value: unknown): value is ReleaseInfo { - if (typeof value !== "object" || value === null) return false; - const record = value as Record; - return ( - typeof record.version === "string" && - /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(record.version) && - typeof record.linuxAppImageAsset === "string" && - (record.linuxAppImageAsset === "Paseo-x86_64.AppImage" || - new RegExp(`^Paseo-${record.version.replaceAll(".", "\\.")}-x86_64\\.AppImage$`).test( - record.linuxAppImageAsset, - )) && - (typeof record.windowsX64Asset === "string" || record.windowsX64Asset === null) && - (typeof record.windowsArm64Asset === "string" || record.windowsArm64Asset === null) && - (record.windowsX64Asset === null || - new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}(?:-x64)?\\.exe$`).test( - record.windowsX64Asset, - )) && - (record.windowsArm64Asset === null || - new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}-arm64\\.exe$`).test( - record.windowsArm64Asset, - )) - ); -} +import { getWebsiteCacheContext } from "./cloudflare-cache"; +import { getLatestReleaseInfo } from "./latest-release"; export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => { - return getBlockingColdCache({ - key: RELEASE_CACHE_KEY, - isValue: isReleaseInfo, - fetchFresh: fetchLatestReadyRelease, - }); + return getLatestReleaseInfo(getWebsiteCacheContext()); }); diff --git a/packages/website/src/server-entry.ts b/packages/website/src/server-entry.ts index 1f48e9854..18763f6df 100644 --- a/packages/website/src/server-entry.ts +++ b/packages/website/src/server-entry.ts @@ -1,10 +1,14 @@ import startEntry from "@tanstack/react-start/server-entry"; +import { getAndroidVersionCode } from "~/android-version"; import { getDoc } from "~/docs"; +import { getLatestAndroidVersion } from "~/latest-release"; import { buildLlmsTxt } from "~/llms"; const CANONICAL_HOST = "paseo.sh"; -type FetchArgs = Parameters; +interface WebsiteEnv { + WEBSITE_CACHE?: KVNamespace; +} function markdownResponse(body: string): Response { return new Response(body, { @@ -15,6 +19,15 @@ function markdownResponse(body: string): Response { }); } +function plainTextResponse(body: string): Response { + return new Response(body, { + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, max-age=300, s-maxage=300", + }, + }); +} + function docSlugFromMarkdownPath(pathname: string): string | null { if (pathname === "/docs.md") return ""; const match = pathname.match(/^\/docs\/(.+)\.md$/); @@ -22,8 +35,7 @@ function docSlugFromMarkdownPath(pathname: string): string | null { } export default { - async fetch(...args: FetchArgs): Promise { - const [request] = args; + async fetch(request: Request, env: WebsiteEnv, context: ExecutionContext): Promise { const url = new URL(request.url); const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1"; @@ -43,6 +55,14 @@ export default { return markdownResponse(buildLlmsTxt()); } + if (url.pathname === "/android-version.txt") { + const version = await getLatestAndroidVersion({ + cache: env.WEBSITE_CACHE ?? null, + waitUntil: (promise) => context.waitUntil(promise), + }); + return plainTextResponse(`${getAndroidVersionCode(version)}\n`); + } + const slug = docSlugFromMarkdownPath(url.pathname); if (slug !== null) { const doc = getDoc(slug); @@ -50,6 +70,6 @@ export default { return markdownResponse(doc.content); } - return startEntry.fetch(...args); + return startEntry.fetch(request); }, }; diff --git a/packages/website/src/stars.ts b/packages/website/src/stars.ts index 003c363bd..d90128640 100644 --- a/packages/website/src/stars.ts +++ b/packages/website/src/stars.ts @@ -1,4 +1,5 @@ import { createServerFn } from "@tanstack/react-start"; +import { getWebsiteCacheContext } from "./cloudflare-cache"; import { getBlockingColdCache } from "./github-cache"; interface GitHubRepo { @@ -38,6 +39,7 @@ function isStars(value: unknown): value is string { export const getStarCount = createServerFn({ method: "GET" }).handler(async () => { const stars = await getBlockingColdCache({ + context: getWebsiteCacheContext(), key: STARS_CACHE_KEY, isValue: isStars, fetchFresh: fetchStarCount, From 7f011d16c5785f02581d53f943a44cc811b8cb78 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 15:40:11 +0200 Subject: [PATCH 14/20] Reduce composer overhead while typing (#2086) * perf(app): reduce composer draft writes Persist draft checkpoints every 200 ms during sustained typing while keeping the latest in-memory draft current. A trailing write stores the newest checkpoint when typing stops. * test(app): cover draft persistence checkpoints Keep the throttle policy deterministic at its storage boundary, including latest-value coalescing and pending-write cancellation when drafts are cleared. * fix(app): flush pending drafts on background Flush the latest throttled checkpoint when the app leaves the foreground and report asynchronous persistence failures. Extend the storage-boundary tests across consecutive intervals and explicit lifecycle flushing. --- packages/app/src/app/_layout.tsx | 11 +- packages/app/src/stores/draft-store/index.ts | 10 +- .../stores/draft-store/persistence.test.ts | 109 ++++++++++++++++++ .../app/src/stores/draft-store/persistence.ts | 82 +++++++++++++ 4 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/stores/draft-store/persistence.test.ts create mode 100644 packages/app/src/stores/draft-store/persistence.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index bbd751d4b..812231b9a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -16,7 +16,7 @@ import { useState, useSyncExternalStore, } from "react"; -import { useWindowDimensions, View } from "react-native"; +import { AppState, useWindowDimensions, View } from "react-native"; import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; @@ -97,6 +97,7 @@ import { import { getDaemonStartService } from "@/runtime/daemon-start-service"; import { applyAppearance } from "@/screens/settings/appearance/apply-appearance"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; +import { flushDraftPersistStorage } from "@/stores/draft-store"; import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; @@ -959,6 +960,14 @@ function RootAppTree() { export default function RootLayout() { useEffect(() => installWebScrollbarStyles(), []); + useEffect(() => { + const subscription = AppState.addEventListener("change", (nextState) => { + if (nextState !== "active") { + void flushDraftPersistStorage(); + } + }); + return () => subscription.remove(); + }, []); return ( diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 5c116a8c5..71a8bf347 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -26,6 +26,7 @@ import { type DraftStoreState, } from "./state"; import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration"; +import { createDraftPersistStorage } from "./persistence"; export type { DraftInput, DraftLifecycleState } from "./state"; @@ -49,6 +50,13 @@ type DraftStore = DraftStoreState & DraftStoreActions; const draftGenerations = new Map(); let gcScheduled = false; +const draftPersistStorage = createDraftPersistStorage( + createJSONStorage(() => AsyncStorage), +); + +export function flushDraftPersistStorage(): Promise { + return draftPersistStorage?.flush() ?? Promise.resolve(); +} function createDraftRecord(input: { draft: DraftInput; @@ -378,7 +386,7 @@ export const useDraftStore = create()( { name: "paseo-drafts", version: DRAFT_STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), + storage: draftPersistStorage, migrate: (persistedState) => { return migratePersistedState(persistedState, { migrateLegacyImages, diff --git a/packages/app/src/stores/draft-store/persistence.test.ts b/packages/app/src/stores/draft-store/persistence.test.ts new file mode 100644 index 000000000..bae660abc --- /dev/null +++ b/packages/app/src/stores/draft-store/persistence.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { PersistStorage, StorageValue } from "zustand/middleware"; +import { + createDraftPersistStorage, + DRAFT_PERSIST_INTERVAL_MS, + type PersistenceScheduler, +} from "./persistence"; + +interface DraftState { + text: string; +} + +function createDraftPersistence() { + let nowMs = 0; + let saved: StorageValue | null = null; + let scheduled: { callback: () => void; dueAt: number } | null = null; + const storage: PersistStorage = { + getItem: () => saved, + setItem: (_name, value) => { + saved = value; + }, + removeItem: () => { + saved = null; + }, + }; + const scheduler: PersistenceScheduler = { + now: () => nowMs, + schedule: (callback, delayMs) => (scheduled = { callback, dueAt: nowMs + delayMs }), + cancel: () => { + scheduled = null; + }, + }; + const drafts = createDraftPersistStorage(storage, scheduler); + + return { + save(text: string) { + drafts.setItem("drafts", { state: { text } }); + }, + remove() { + drafts.removeItem("drafts"); + }, + flush() { + return drafts.flush(); + }, + advance(ms: number) { + nowMs += ms; + if (scheduled && scheduled.dueAt <= nowMs) { + const { callback } = scheduled; + scheduled = null; + callback(); + } + }, + text() { + return saved?.state.text ?? null; + }, + }; +} + +describe("draft persistence", () => { + it("checkpoints the first change and the latest change in each interval", () => { + const drafts = createDraftPersistence(); + + drafts.save("a"); + drafts.save("ab"); + drafts.save("abc"); + expect(drafts.text()).toBe("a"); + + drafts.advance(DRAFT_PERSIST_INTERVAL_MS - 1); + expect(drafts.text()).toBe("a"); + + drafts.advance(1); + expect(drafts.text()).toBe("abc"); + }); + + it("does not restore a pending draft after storage is cleared", () => { + const drafts = createDraftPersistence(); + + drafts.save("first checkpoint"); + drafts.save("pending checkpoint"); + drafts.remove(); + drafts.advance(DRAFT_PERSIST_INTERVAL_MS); + + expect(drafts.text()).toBeNull(); + }); + + it("continues checkpointing the latest change across consecutive intervals", () => { + const drafts = createDraftPersistence(); + + drafts.save("first"); + drafts.save("first interval"); + drafts.advance(DRAFT_PERSIST_INTERVAL_MS); + expect(drafts.text()).toBe("first interval"); + + drafts.save("second"); + drafts.save("second interval"); + drafts.advance(DRAFT_PERSIST_INTERVAL_MS); + expect(drafts.text()).toBe("second interval"); + }); + + it("flushes the latest pending change before the interval ends", async () => { + const drafts = createDraftPersistence(); + + drafts.save("first checkpoint"); + drafts.save("pending checkpoint"); + await drafts.flush(); + + expect(drafts.text()).toBe("pending checkpoint"); + }); +}); diff --git a/packages/app/src/stores/draft-store/persistence.ts b/packages/app/src/stores/draft-store/persistence.ts new file mode 100644 index 000000000..ab7c374c5 --- /dev/null +++ b/packages/app/src/stores/draft-store/persistence.ts @@ -0,0 +1,82 @@ +import type { PersistStorage } from "zustand/middleware"; + +export const DRAFT_PERSIST_INTERVAL_MS = 200; + +export interface PersistenceScheduler { + now: () => number; + schedule: (callback: () => void, delayMs: number) => unknown; + cancel: (handle: unknown) => void; +} + +export interface DraftPersistStorage extends PersistStorage { + flush: () => Promise; +} + +const systemScheduler: PersistenceScheduler = { + now: Date.now, + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + cancel: (handle) => clearTimeout(handle as ReturnType), +}; + +export function createDraftPersistStorage( + storage: PersistStorage, + scheduler?: PersistenceScheduler, +): DraftPersistStorage; +export function createDraftPersistStorage( + storage: PersistStorage | undefined, + scheduler?: PersistenceScheduler, +): DraftPersistStorage | undefined; +export function createDraftPersistStorage( + storage: PersistStorage | undefined, + scheduler: PersistenceScheduler = systemScheduler, +): DraftPersistStorage | undefined { + if (!storage) { + return undefined; + } + + let pending: { name: string; value: Parameters[1] } | null = null; + let timer: unknown = null; + let lastWriteAt = -Infinity; + + const cancelTimer = () => { + if (timer !== null) { + scheduler.cancel(timer); + timer = null; + } + }; + const flush = async (): Promise => { + cancelTimer(); + const write = pending; + pending = null; + if (!write) { + return; + } + lastWriteAt = scheduler.now(); + try { + await storage.setItem(write.name, write.value); + } catch (error) { + console.warn("[DraftStore] Failed to persist draft checkpoint", error); + } + }; + + return { + getItem: (name) => storage.getItem(name), + setItem: (name, value) => { + pending = { name, value }; + const delay = DRAFT_PERSIST_INTERVAL_MS - (scheduler.now() - lastWriteAt); + if (delay <= 0) { + return flush(); + } + timer ??= scheduler.schedule(() => { + void flush(); + }, delay); + }, + removeItem: (name) => { + cancelTimer(); + pending = null; + lastWriteAt = scheduler.now(); + return storage.removeItem(name); + }, + flush, + }; +} From f06792ae89d6be21457b025a98d336929730e287 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 15:48:09 +0200 Subject: [PATCH 15/20] Recover desktop startup from stale daemon locks Refs #1950 Refs #1904 Refs #769 --- .../desktop/src/daemon/daemon-manager.test.ts | 57 +++- packages/desktop/src/daemon/daemon-manager.ts | 4 +- .../server/scripts/supervisor-entrypoint.ts | 25 +- packages/server/scripts/supervisor.ts | 8 +- packages/server/src/server/pid-lock.test.ts | 209 ++++++++++++- packages/server/src/server/pid-lock.ts | 288 ++++++++++++++---- 6 files changed, 516 insertions(+), 75 deletions(-) diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index c5bc67926..805899e94 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -16,6 +16,11 @@ const mocks = vi.hoisted(() => ({ }, runExternalCliJsonCommand: vi.fn(), runExternalCliTextCommand: vi.fn(), + createNodeEntrypointInvocation: vi.fn(() => ({ + command: "node", + args: [], + env: {}, + })), spawnProcess: vi.fn(), logInfo: vi.fn(), logError: vi.fn(), @@ -59,11 +64,7 @@ vi.mock("../settings/desktop-settings-electron.js", () => ({ })); vi.mock("./runtime-paths.js", () => ({ - createNodeEntrypointInvocation: vi.fn(() => ({ - command: "node", - args: [], - env: {}, - })), + createNodeEntrypointInvocation: mocks.createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint: vi.fn(() => ({ entryPath: "/tmp/daemon.js", execArgv: [], @@ -112,6 +113,8 @@ describe("daemon-manager commands", () => { mocks.settings = DEFAULT_DESKTOP_SETTINGS; mocks.runExternalCliJsonCommand.mockReset(); mocks.runExternalCliTextCommand.mockReset(); + mocks.createNodeEntrypointInvocation.mockReset(); + mocks.createNodeEntrypointInvocation.mockReturnValue({ command: "node", args: [], env: {} }); mocks.spawnProcess.mockReset(); mocks.logInfo.mockReset(); mocks.logError.mockReset(); @@ -439,6 +442,9 @@ describe("daemon-manager commands", () => { expect(message).toContain("Daemon failed to start: exit code 1"); expect(recentLogsLabel?.split(/[\\/]/).at(-1)).toBe("daemon.log"); expect(message).toContain("recent daemon failure"); + expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( + expect.objectContaining({ args: [] }), + ); expect(mocks.spawnProcess).toHaveBeenCalledWith( "node", [], @@ -450,6 +456,47 @@ describe("daemon-manager commands", () => { ); }); + it("passes stale lock reclaim only after a live desktop daemon is confirmed unresponsive", async () => { + mocks.runExternalCliJsonCommand.mockResolvedValue({ + localDaemon: "unresponsive", + connectedDaemon: "unreachable", + serverId: "", + pid: 7675, + listen: "127.0.0.1:6767", + desktopManaged: true, + }); + mocks.spawnProcess.mockImplementation(() => { + const child = createMockChildProcess(); + scheduleFailedStartup(child); + return child; + }); + + await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( + "Daemon failed to start: exit code 1", + ); + + expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( + expect.objectContaining({ args: ["--reclaim-stale-pid-lock"] }), + ); + }); + + it("does not pass stale lock reclaim when the status command fails", async () => { + mocks.runExternalCliJsonCommand.mockRejectedValue(new Error("status command failed")); + mocks.spawnProcess.mockImplementation(() => { + const child = createMockChildProcess(); + scheduleFailedStartup(child); + return child; + }); + + await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( + "Daemon failed to start: exit code 1", + ); + + expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( + expect.objectContaining({ args: [] }), + ); + }); + it("returns the Electron main-process log tail from electron-log", () => { writeFileSync( mocks.appLogPath, diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 331454e45..6393a5bb5 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -385,10 +385,12 @@ async function startDaemon(): Promise { } const daemonRunner = resolveDaemonRunnerEntrypoint(); + const reclaimStalePidLock = + current.status === "errored" && current.desktopManaged && current.error === null; const invocation = createNodeEntrypointInvocation({ entrypoint: daemonRunner, argvMode: "node-script", - args: [], + args: reclaimStalePidLock ? ["--reclaim-stale-pid-lock"] : [], baseEnv: process.env, }); diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index d7d23d41b..b379e6179 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -5,6 +5,7 @@ import { acquirePidLock, PidLockError, releasePidLock, + startPidLockHeartbeat, updatePidLock, } from "../src/server/pid-lock.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js"; @@ -17,11 +18,13 @@ process.title = "Paseo Supervisor"; interface DaemonRunnerConfig { devMode: boolean; + reclaimStalePidLock: boolean; workerArgs: string[]; } function parseConfig(argv: string[]): DaemonRunnerConfig { let devMode = false; + let reclaimStalePidLock = false; const workerArgs: string[] = []; for (const arg of argv) { @@ -29,10 +32,14 @@ function parseConfig(argv: string[]): DaemonRunnerConfig { devMode = true; continue; } + if (arg === "--reclaim-stale-pid-lock") { + reclaimStalePidLock = true; + continue; + } workerArgs.push(arg); } - return { devMode, workerArgs }; + return { devMode, reclaimStalePidLock, workerArgs }; } function resolveWorkerEntry(): string { @@ -109,6 +116,7 @@ async function main(): Promise { try { await acquirePidLock(paseoHome, null, { ownerPid: process.pid, + reclaimStaleDesktopLock: config.reclaimStalePidLock, }); } catch (error) { if (error instanceof PidLockError) { @@ -120,17 +128,29 @@ async function main(): Promise { } let lockReleased = false; + let requestSupervisorShutdown: ((reason: string) => void) | null = null; + const stopLockHeartbeat = startPidLockHeartbeat(paseoHome, { + ownerPid: process.pid, + onError: (error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`PID lock heartbeat failed: ${message}\n`); + if (error instanceof PidLockError) { + requestSupervisorShutdown?.("pid_lock_ownership_lost"); + } + }, + }); const releaseLock = async (): Promise => { if (lockReleased) { return; } lockReleased = true; + stopLockHeartbeat(); await releasePidLock(paseoHome, { ownerPid: process.pid, }); }; - runSupervisor({ + const supervisor = runSupervisor({ name: "DaemonRunner", startupMessage: "Starting daemon worker (IPC restart and crash restart enabled)", resolveWorkerEntry: () => workerEntry, @@ -159,6 +179,7 @@ async function main(): Promise { }, onSupervisorExit: releaseLock, }); + requestSupervisorShutdown = supervisor.requestShutdown; } void main().catch((error) => { diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 6e7c1b032..4e56c9612 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -47,6 +47,10 @@ interface SupervisorOptions { logFile?: SupervisorLogFileOptions; } +export interface SupervisorController { + requestShutdown(reason: string): void; +} + function describeExit(code: number | null, signal: NodeJS.Signals | null): string { return signal ?? (typeof code === "number" ? `code ${code}` : "unknown"); } @@ -105,7 +109,7 @@ function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined }); } -export function runSupervisor(options: SupervisorOptions): void { +export function runSupervisor(options: SupervisorOptions): SupervisorController { const restartOnCrash = options.restartOnCrash ?? false; const workerArgs = options.workerArgs ?? process.argv.slice(2); const workerEnv = options.workerEnv ?? process.env; @@ -331,4 +335,6 @@ export function runSupervisor(options: SupervisorOptions): void { process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); writeLifecycleLog(options.startupMessage); spawnWorker(); + + return { requestShutdown }; } diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index 527655e1b..1dc6c260b 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -1,9 +1,17 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, open, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { acquirePidLock, getPidLockInfo, releasePidLock, updatePidLock } from "./pid-lock.js"; +import { + acquirePidLock, + getPidLockInfo, + isLocked, + PidLockError, + refreshPidLock, + releasePidLock, + updatePidLock, +} from "./pid-lock.js"; describe("pid-lock ownership", () => { test("writes and releases lock for explicit owner pid", async () => { @@ -22,6 +30,7 @@ describe("pid-lock ownership", () => { const lock = await getPidLockInfo(paseoHome); expect(lock?.pid).toBe(ownerPid); expect(lock?.listen).toBeNull(); + expect(lock?.heartbeat).toBe(true); await ( updatePidLock as unknown as ( @@ -49,4 +58,200 @@ describe("pid-lock ownership", () => { await rm(paseoHome, { recursive: true, force: true }); } }); + + test("keeps a stale heartbeat lock when the recorded pid is alive without a reachability check", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-stale-heartbeat-")); + const replacementOwnerPid = process.pid + 10_000; + + try { + const pidPath = join(paseoHome, "paseo.pid"); + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await expect(isLocked(paseoHome)).resolves.toMatchObject({ locked: true }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid }), + ).rejects.toThrow("Another Paseo daemon is already running"); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(process.pid); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("reclaims a stale desktop heartbeat lock after desktop confirms the daemon is unreachable", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-stale-desktop-heartbeat-")); + const replacementOwnerPid = process.pid + 10_000; + + try { + const pidPath = join(paseoHome, "paseo.pid"); + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await acquirePidLock(paseoHome, null, { + ownerPid: replacementOwnerPid, + reclaimStaleDesktopLock: true, + }); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(replacementOwnerPid); + expect(lock?.listen).toBeNull(); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("keeps a stale live lock written by a pre-heartbeat daemon", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-legacy-live-")); + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(process.pid); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("reclaims a stale legacy desktop lock after desktop confirms the daemon is unreachable", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-legacy-desktop-")); + const replacementOwnerPid = process.pid + 10_000; + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await writeFile( + pidPath, + JSON.stringify({ + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + }), + ); + const staleTime = new Date(Date.now() - 10 * 60_000); + await utimes(pidPath, staleTime, staleTime); + + await acquirePidLock(paseoHome, null, { + ownerPid: replacementOwnerPid, + reclaimStaleDesktopLock: true, + }); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(replacementOwnerPid); + expect(lock?.heartbeat).toBe(true); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("rejects a heartbeat refresh after another supervisor takes ownership", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-refresh-owner-")); + + try { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }); + + await expect(refreshPidLock(paseoHome, { ownerPid: process.pid })).rejects.toBeInstanceOf( + PidLockError, + ); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("retries a heartbeat refresh while its owner is rewriting the lock", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-refresh-rewrite-")); + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid }); + const lock = await getPidLockInfo(paseoHome); + expect(lock).not.toBeNull(); + + const rewriteHandle = await open(pidPath, "r+"); + await rewriteHandle.truncate(0); + + const refresh = refreshPidLock(paseoHome, { ownerPid: process.pid }); + await new Promise((resolve) => setTimeout(resolve, 250)); + await rewriteHandle.writeFile(JSON.stringify(lock)); + await rewriteHandle.close(); + + await expect(refresh).resolves.toBeUndefined(); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("keeps a fresh lock when the recorded pid is alive", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-fresh-heartbeat-")); + + try { + await writeFile( + join(paseoHome, "paseo.pid"), + JSON.stringify({ + pid: process.pid, + startedAt: new Date().toISOString(), + hostname: "current-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }), + ); + + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(process.pid); + expect(lock?.listen).toBe("127.0.0.1:6767"); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); }); diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 96d663e89..9202f49df 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,4 +1,5 @@ -import { open, readFile, unlink, mkdir } from "node:fs/promises"; +import { open, readFile, stat, unlink, mkdir, utimes } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { hostname } from "node:os"; @@ -11,6 +12,7 @@ export const pidLockInfoSchema = z.object({ uid: z.number(), listen: z.string().nullable(), desktopManaged: z.boolean().optional(), + heartbeat: z.literal(true).optional(), }); export interface PidLockInfo extends z.infer {} @@ -34,6 +36,12 @@ export class PidLockError extends Error { } } +// Stale recovery is for abandoned locks, so keep this well above ordinary event-loop stalls. +const PID_LOCK_STALE_MS = 5 * 60_000; +const PID_LOCK_HEARTBEAT_INTERVAL_MS = 30_000; +const PID_LOCK_READ_RETRY_ATTEMPTS = 10; +const PID_LOCK_READ_RETRY_DELAY_MS = 50; + function isPidRunning(pid: number): boolean { try { process.kill(pid, 0); @@ -47,6 +55,29 @@ function getPidFilePath(paseoHome: string): string { return join(paseoHome, "paseo.pid"); } +async function isPidLockFresh(pidPath: string): Promise { + try { + const lockStat = await stat(pidPath); + return lockStat.mtimeMs >= Date.now() - PID_LOCK_STALE_MS; + } catch { + return false; + } +} + +async function touchPidLockFile(pidPath: string): Promise { + const now = new Date(); + await utimes(pidPath, now, now); +} + +async function readPidLock(pidPath: string): Promise { + try { + const content = await readFile(pidPath, "utf-8"); + return parsePidLockInfo(JSON.parse(content)); + } catch { + return null; + } +} + function resolveOwnerPid(ownerPid?: number): number { if (typeof ownerPid === "number" && Number.isInteger(ownerPid) && ownerPid > 0) { return ownerPid; @@ -54,10 +85,91 @@ function resolveOwnerPid(ownerPid?: number): number { return process.pid; } +interface AcquirePidLockOptions { + ownerPid?: number; + reclaimStaleDesktopLock?: boolean; +} + +function canReclaimLiveLock( + lock: PidLockInfo, + options: AcquirePidLockOptions | undefined, +): boolean { + // COMPAT(pidLockHeartbeat): v0.1.108 desktop startup has already confirmed the old daemon is + // unreachable before it launches the supervisor. Remove after 2027-01-15. + return options?.reclaimStaleDesktopLock === true && lock.desktopManaged === true; +} + +function isSamePidLock(left: PidLockInfo, right: PidLockInfo): boolean { + return left.pid === right.pid && left.startedAt === right.startedAt; +} + +function createLockHeldError(lock: PidLockInfo): PidLockError { + return new PidLockError( + `Another Paseo daemon is already running (PID ${lock.pid}, started ${lock.startedAt})`, + lock, + ); +} + +async function clearExistingPidLock( + pidPath: string, + existingLock: PidLockInfo, + lockOwnerPid: number, + options: AcquirePidLockOptions | undefined, +): Promise<"already_owned" | "cleared"> { + const lockOwnerRunning = isPidRunning(existingLock.pid); + if (existingLock.pid === lockOwnerPid && lockOwnerRunning) { + await touchPidLockFile(pidPath); + return "already_owned"; + } + + if (lockOwnerRunning) { + const reclaimable = canReclaimLiveLock(existingLock, options); + if (!reclaimable || (await isPidLockFresh(pidPath))) { + throw createLockHeldError(existingLock); + } + + // Re-read immediately before unlinking so a heartbeat at the stale boundary wins. + const confirmedLock = await readPidLock(pidPath); + if ( + !confirmedLock || + !isSamePidLock(existingLock, confirmedLock) || + (await isPidLockFresh(pidPath)) + ) { + throw new PidLockError("PID lock changed while checking whether it was abandoned"); + } + } + + await unlink(pidPath).catch(() => {}); + return "cleared"; +} + +async function writeNewPidLock(pidPath: string, lockInfo: PidLockInfo): Promise { + let fd; + try { + fd = await open(pidPath, "wx"); + await fd.write(JSON.stringify(lockInfo)); + } catch (error) { + if (!isErrnoException(error) || error.code !== "EEXIST") { + throw error; + } + + const raceLock = await readPidLock(pidPath); + if (raceLock) { + throw new PidLockError( + `Another Paseo daemon is already running (PID ${raceLock.pid})`, + raceLock, + ); + } + throw new PidLockError("Failed to acquire PID lock due to race condition"); + } finally { + await fd?.close(); + } +} + export async function acquirePidLock( paseoHome: string, listen: string | null, - options?: { ownerPid?: number }, + options?: AcquirePidLockOptions, ): Promise { const pidPath = getPidFilePath(paseoHome); @@ -67,29 +179,15 @@ export async function acquirePidLock( } // Try to read existing lock - let existingLock: PidLockInfo | null = null; - try { - const content = await readFile(pidPath, "utf-8"); - existingLock = parsePidLockInfo(JSON.parse(content)); - } catch { - // No existing lock or invalid JSON - that's fine - } + const existingLock = await readPidLock(pidPath); // Check if existing lock is stale const lockOwnerPid = resolveOwnerPid(options?.ownerPid); if (existingLock) { - if (isPidRunning(existingLock.pid)) { - if (existingLock.pid === lockOwnerPid) { - return; - } - - throw new PidLockError( - `Another Paseo daemon is already running (PID ${existingLock.pid}, started ${existingLock.startedAt})`, - existingLock, - ); + const result = await clearExistingPidLock(pidPath, existingLock, lockOwnerPid, options); + if (result === "already_owned") { + return; } - // Stale lock - remove it - await unlink(pidPath).catch(() => {}); } // Create new lock with exclusive flag @@ -99,36 +197,103 @@ export async function acquirePidLock( hostname: hostname(), uid: process.getuid?.() ?? 0, listen, + heartbeat: true, ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; + await writeNewPidLock(pidPath, lockInfo); +} + +export async function refreshPidLock( + paseoHome: string, + options?: { ownerPid?: number }, +): Promise { + const pidPath = getPidFilePath(paseoHome); + const lockOwnerPid = resolveOwnerPid(options?.ownerPid); let fd; try { - fd = await open(pidPath, "wx"); - await fd.write(JSON.stringify(lockInfo)); - } catch (err) { - if (isErrnoException(err) && err.code === "EEXIST") { - // Race condition - another process created the file - // Re-read and check - try { - const content = await readFile(pidPath, "utf-8"); - const raceLock = parsePidLockInfo(JSON.parse(content)); - if (raceLock) { - throw new PidLockError( - `Another Paseo daemon is already running (PID ${raceLock.pid})`, - raceLock, - ); - } - throw new PidLockError("Failed to acquire PID lock due to race condition"); - } catch (innerErr) { - if (innerErr instanceof PidLockError) throw innerErr; - throw new PidLockError("Failed to acquire PID lock due to race condition"); - } + fd = await open(pidPath, "r+"); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + throw new PidLockError("Cannot refresh PID lock: lock file is missing"); } - throw err; - } finally { - await fd?.close(); + throw error; } + + try { + const lock = await readPidLockFromHandleWithRetry(fd); + if (!lock) { + throw new PidLockError("Cannot refresh PID lock: invalid lock file"); + } + if (lock.pid !== lockOwnerPid) { + throw new PidLockError(`Cannot refresh PID lock owned by PID ${lock.pid}`, lock); + } + const now = new Date(); + await fd.utimes(now, now); + } finally { + await fd.close(); + } +} + +async function readPidLockFromHandle(fd: FileHandle): Promise { + try { + const { size } = await fd.stat(); + if (size === 0) { + return null; + } + const content = Buffer.alloc(size); + const { bytesRead } = await fd.read(content, 0, size, 0); + return parsePidLockInfo(JSON.parse(content.subarray(0, bytesRead).toString("utf-8"))); + } catch { + return null; + } +} + +async function readPidLockFromHandleWithRetry(fd: FileHandle): Promise { + for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt += 1) { + const lock = await readPidLockFromHandle(fd); + if (lock) { + return lock; + } + if (attempt < PID_LOCK_READ_RETRY_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS)); + } + } + return null; +} + +export function startPidLockHeartbeat( + paseoHome: string, + options?: { + ownerPid?: number; + intervalMs?: number; + onError?: (error: unknown) => void; + }, +): () => void { + const intervalMs = options?.intervalMs ?? PID_LOCK_HEARTBEAT_INTERVAL_MS; + let refreshing = false; + + const timer = setInterval(() => { + if (refreshing) { + return; + } + refreshing = true; + refreshPidLock(paseoHome, { ownerPid: options?.ownerPid }) + .catch((error) => { + if (options?.onError) { + options.onError(error); + return; + } + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`PID lock heartbeat failed: ${message}\n`); + }) + .finally(() => { + refreshing = false; + }); + }, intervalMs); + timer.unref(); + + return () => clearInterval(timer); } export async function updatePidLock( @@ -138,23 +303,23 @@ export async function updatePidLock( ): Promise { const pidPath = getPidFilePath(paseoHome); const lockOwnerPid = resolveOwnerPid(options?.ownerPid); - const content = await readFile(pidPath, "utf-8"); - const existingLock = parsePidLockInfo(JSON.parse(content)); - if (!existingLock) { - throw new PidLockError("Cannot update PID lock: invalid lock file"); - } - - if (existingLock.pid !== lockOwnerPid) { - throw new PidLockError(`Cannot update PID lock owned by PID ${existingLock.pid}`, existingLock); - } - - const updatedLock: PidLockInfo = { - ...existingLock, - ...patch, - }; - const fd = await open(pidPath, "r+"); try { + const existingLock = await readPidLockFromHandleWithRetry(fd); + if (!existingLock) { + throw new PidLockError("Cannot update PID lock: invalid lock file"); + } + if (existingLock.pid !== lockOwnerPid) { + throw new PidLockError( + `Cannot update PID lock owned by PID ${existingLock.pid}`, + existingLock, + ); + } + + const updatedLock: PidLockInfo = { + ...existingLock, + ...patch, + }; await fd.truncate(0); await fd.writeFile(JSON.stringify(updatedLock)); } finally { @@ -182,12 +347,7 @@ export async function releasePidLock( export async function getPidLockInfo(paseoHome: string): Promise { const pidPath = getPidFilePath(paseoHome); - try { - const content = await readFile(pidPath, "utf-8"); - return parsePidLockInfo(JSON.parse(content)); - } catch { - return null; - } + return readPidLock(pidPath); } export async function isLocked( From 04e893417e2e95c77a25a413edb675557813d727 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 16:30:42 +0200 Subject: [PATCH 16/20] Allow failed agent turns to be forked (#2063) * fix(chat): allow failed turns to be forked Failed turns can end in Paseo-generated timeline items without provider message IDs. Anchor forks to canonical timeline positions while retaining legacy boundaries for compatibility. Place the fork summary before the new prompt and delimit it as structured chat history. * fix(chat): tighten fork boundary handling --- docs/timeline-sync.md | 8 +++ packages/app/e2e/assistant-fork-menu.spec.ts | 22 ++++++ .../src/agent-stream/turn-boundary.test.ts | 67 ++++++++++++++++++- .../app/src/agent-stream/turn-boundary.ts | 24 ++++++- packages/app/src/agent-stream/turn-footer.tsx | 27 ++++++-- packages/app/src/agent-stream/view.tsx | 25 +++++-- packages/app/src/attachments/types.ts | 1 + packages/app/src/components/message.tsx | 13 ++-- .../timeline/session-stream-reducers.test.ts | 46 ++++++++++++- .../src/timeline/session-stream-reducers.ts | 33 +++++++-- packages/app/src/types/stream.ts | 39 +++++++++-- packages/client/src/daemon-client.ts | 2 + .../protocol/src/messages.attachments.test.ts | 43 ++++++++++++ packages/protocol/src/messages.ts | 4 ++ .../src/server/agent/activity-curator.test.ts | 37 ++++++++++ .../src/server/agent/activity-curator.ts | 49 +++++++++++--- .../src/server/agent/create-agent/create.ts | 32 +-------- .../server/agent/prompt-attachments.test.ts | 36 +++++++++- .../src/server/agent/prompt-attachments.ts | 34 ++++++++++ .../providers/mock-load-test-agent.test.ts | 32 +++++++++ .../agent/providers/mock-load-test-agent.ts | 36 +++++++++- packages/server/src/server/session.ts | 45 +++---------- .../server/src/server/websocket-server.ts | 2 + 23 files changed, 549 insertions(+), 108 deletions(-) diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 86414257a..020ee0ed4 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -37,6 +37,14 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward. +## Durable item anchors + +Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows. + +Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position. + +The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context. + ## Resume behavior When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run. diff --git a/packages/app/e2e/assistant-fork-menu.spec.ts b/packages/app/e2e/assistant-fork-menu.spec.ts index 47a26f531..965c87b94 100644 --- a/packages/app/e2e/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/assistant-fork-menu.spec.ts @@ -54,6 +54,28 @@ async function expectChatHistoryPill(page: Page): Promise { test.describe("Assistant fork menu", () => { test.describe.configure({ timeout: 180_000 }); + test("forks a failed assistant turn that has no provider message id", async ({ + page, + seedForkWorkspace, + }) => { + const session = await seedForkWorkspace({ + repoPrefix: "assistant-fork-failed-turn-", + title: "Assistant fork failed turn", + model: "ten-second-stream", + }); + + await openAgentRoute(page, session); + await expectComposerVisible(page); + await submitMessage(page, "Emit a synthetic turn failure."); + await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({ + timeout: 30_000, + }); + + await openAssistantForkMenu(page); + await page.getByTestId("assistant-fork-menu-new-tab").click(); + await expectChatHistoryPill(page); + }); + test("focuses a forked assistant turn in a new workspace draft tab", async ({ page, seedForkWorkspace, diff --git a/packages/app/src/agent-stream/turn-boundary.test.ts b/packages/app/src/agent-stream/turn-boundary.test.ts index c18fad104..33e56901d 100644 --- a/packages/app/src/agent-stream/turn-boundary.test.ts +++ b/packages/app/src/agent-stream/turn-boundary.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { StreamItem } from "@/types/stream"; -import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary"; +import { + resolveAssistantTurnBoundaryMessageId, + resolveAssistantTurnForkBoundary, +} from "./turn-boundary"; function timestamp(seed: number): Date { return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`); @@ -62,3 +65,65 @@ describe("resolveAssistantTurnBoundaryMessageId", () => { ).toBeUndefined(); }); }); + +describe("resolveAssistantTurnForkBoundary", () => { + it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => { + const failedTurn = { + ...assistantMessage("assistant-error", 2), + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [userMessage("user-1", 1), failedTurn], + startIndex: 1, + supportsTimelineCursor: true, + }), + ).toEqual({ + boundaryCursor: { epoch: "timeline-1", seq: 42 }, + }); + }); + + it("includes the provider message id with a supported timeline cursor", () => { + const selected = { + ...assistantMessage("assistant-1", 2, "msg-assistant-1"), + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [selected], + startIndex: 0, + supportsTimelineCursor: true, + }), + ).toEqual({ + boundaryCursor: { epoch: "timeline-1", seq: 42 }, + boundaryMessageId: "msg-assistant-1", + }); + }); + + it("falls back to the provider message id when timeline cursors are unsupported", () => { + const selected = { + ...assistantMessage("assistant-1", 2, "msg-assistant-1"), + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [selected], + startIndex: 0, + supportsTimelineCursor: false, + }), + ).toEqual({ boundaryMessageId: "msg-assistant-1" }); + }); + + it("does not offer an unavailable boundary", () => { + expect( + resolveAssistantTurnForkBoundary({ + items: [assistantMessage("assistant-1", 2)], + startIndex: 0, + supportsTimelineCursor: false, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/app/src/agent-stream/turn-boundary.ts b/packages/app/src/agent-stream/turn-boundary.ts index 75fa63a3e..637c83b72 100644 --- a/packages/app/src/agent-stream/turn-boundary.ts +++ b/packages/app/src/agent-stream/turn-boundary.ts @@ -1,4 +1,8 @@ -import type { StreamItem } from "@/types/stream"; +import type { StreamItem, TimelinePosition } from "@/types/stream"; + +export type AssistantTurnForkBoundary = + | { boundaryCursor: TimelinePosition; boundaryMessageId?: string } + | { boundaryCursor?: undefined; boundaryMessageId: string }; export function resolveAssistantTurnBoundaryMessageId(input: { items: readonly StreamItem[]; @@ -11,3 +15,21 @@ export function resolveAssistantTurnBoundaryMessageId(input: { // Forking without the selected assistant's durable message id would send the wrong slice. return item.messageId || undefined; } + +export function resolveAssistantTurnForkBoundary(input: { + items: readonly StreamItem[]; + startIndex: number; + supportsTimelineCursor: boolean; +}): AssistantTurnForkBoundary | undefined { + const item = input.items[input.startIndex]; + if (item?.kind !== "assistant_message") { + return undefined; + } + if (input.supportsTimelineCursor && item.timelineCursor) { + return { + boundaryCursor: item.timelineCursor, + ...(item.messageId ? { boundaryMessageId: item.messageId } : {}), + }; + } + return item.messageId ? { boundaryMessageId: item.messageId } : undefined; +} diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 2f7bf3c8b..5b7e01e8d 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -9,7 +9,7 @@ import { collectAssistantTurnContentForStreamRenderStrategy, type StreamStrategy, } from "./strategy"; -import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary"; +import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary"; import { AssistantTurnFooter, LiveElapsed, @@ -31,7 +31,7 @@ const workingIndicatorColorMapping = (theme: Theme) => ({ export type TurnContentStrategy = StreamStrategy; export type AssistantTurnForkHandler = (input: { target: AssistantForkTarget; - boundaryMessageId?: string; + boundary: AssistantTurnForkBoundary; }) => Promise | void; export const TurnFooter = memo(function TurnFooter({ @@ -39,12 +39,14 @@ export const TurnFooter = memo(function TurnFooter({ inFlightTurnStartedAt, host, strategy, + supportsTimelineCursor, onForkAssistantTurn, }: { isRunning: boolean; inFlightTurnStartedAt: Date | null; host: TurnFooterHost | null; strategy: TurnContentStrategy; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { if (isRunning) { @@ -63,6 +65,7 @@ export const TurnFooter = memo(function TurnFooter({ items={host.items} timing={host.timing} startIndex={host.startIndex} + supportsTimelineCursor={supportsTimelineCursor} onForkAssistantTurn={onForkAssistantTurn} /> ); @@ -73,12 +76,14 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items, timing, startIndex, + supportsTimelineCursor, onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { return ( @@ -88,6 +93,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items={items} timing={timing} startIndex={startIndex} + supportsTimelineCursor={supportsTimelineCursor} onForkAssistantTurn={onForkAssistantTurn} /> @@ -130,12 +136,14 @@ function CompletedTurnFooter({ items, timing, startIndex, + supportsTimelineCursor, onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { const getContent = useCallback( @@ -147,18 +155,27 @@ function CompletedTurnFooter({ }), [strategy, items, startIndex], ); - const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({ + const boundary = resolveAssistantTurnForkBoundary({ items, startIndex, + supportsTimelineCursor, }); + const handleFork = useCallback( + (target: AssistantForkTarget) => { + if (!boundary) { + return; + } + return onForkAssistantTurn?.({ target, boundary }); + }, + [boundary, onForkAssistantTurn], + ); return ( ); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 29caafc42..a1354787f 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -142,6 +142,7 @@ function renderStreamItemWithTurnFooter(input: { content: ReactNode; layoutItem: StreamLayoutItem; strategy: TurnContentStrategy; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }): ReactNode { if (!input.content) { @@ -155,6 +156,7 @@ function renderStreamItemWithTurnFooter(input: { items={footerHost.items} timing={footerHost.timing} startIndex={footerHost.startIndex} + supportsTimelineCursor={input.supportsTimelineCursor} onForkAssistantTurn={input.onForkAssistantTurn} /> ) : null; @@ -282,6 +284,7 @@ function buildChatHistoryAttachment(input: { serverId: input.serverId, agentId: input.agentId, boundaryMessageId: input.payload.boundaryMessageId, + boundaryCursor: input.payload.boundaryCursor, itemCount: input.payload.itemCount, }, }; @@ -369,6 +372,10 @@ const AgentStreamViewComponent = forwardRef + state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true, + ); const workspaceRoot = context.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ @@ -466,7 +473,7 @@ const AgentStreamViewComponent = forwardRef { + async ({ target, boundary }) => { try { if (!supportsAgentForkContext) { toast?.error(t("message.actions.forkUnavailable")); @@ -478,10 +485,7 @@ const AgentStreamViewComponent = forwardRef { const draftId = generateDraftId(); - const payload = await client.buildAgentForkContext( - agentId, - boundaryMessageId ? { boundaryMessageId } : {}, - ); + const payload = await client.buildAgentForkContext(agentId, boundary); const attachment = buildChatHistoryAttachment({ draftId, serverId: resolvedServerId, @@ -854,10 +858,17 @@ const AgentStreamViewComponent = forwardRef ) : null, @@ -892,6 +904,7 @@ const AgentStreamViewComponent = forwardRef(() => { diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 82e447f92..2199ce29f 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -79,6 +79,7 @@ export interface ChatHistoryContextAttachment { serverId: string; agentId: string; boundaryMessageId?: string | null; + boundaryCursor?: { epoch: string; seq: number } | null; itemCount?: number; }; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 249ba4e56..08792bfd5 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -564,11 +564,7 @@ interface AssistantTurnFooterProps { getContent: () => string; completedAt?: Date; durationMs?: number; - forkBoundaryMessageId?: string; - onFork?: (input: { - target: AssistantForkTarget; - boundaryMessageId?: string; - }) => Promise | void; + onFork?: (target: AssistantForkTarget) => Promise | void; } const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ @@ -613,7 +609,6 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, completedAt, durationMs, - forkBoundaryMessageId, onFork, }: AssistantTurnFooterProps) { const [hovered, setHovered] = useState(false); @@ -656,11 +651,11 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ }, [canSwap]); const handleFork = useCallback( (target: AssistantForkTarget) => { - return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId }); + return onFork?.(target); }, - [forkBoundaryMessageId, onFork], + [onFork], ); - const canFork = Boolean(onFork && forkBoundaryMessageId); + const canFork = Boolean(onFork); return ( diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index a3e6acf5e..e0026371c 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -187,6 +187,25 @@ const baseStreamInput: ProcessAgentStreamEventInput = { // --------------------------------------------------------------------------- describe("processTimelineResponse", () => { + it("preserves the canonical end cursor on a projected assistant message", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + payload: { + ...baseTimelineInput.payload, + epoch: "timeline-1", + entries: [makeTimelineEntry(40, "[System Error] failed", "assistant_message", 42)], + }, + }); + + expect(result.tail).toEqual([ + expect.objectContaining({ + kind: "assistant_message", + text: "[System Error] failed", + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }), + ]); + }); + it("returns error path when payload.error is set", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -892,7 +911,12 @@ describe("processTimelineResponse", () => { }); it("merges assistant chunks across the older-page prepend boundary", () => { - const currentTail = [makeAssistantItem("newer chunk", "assistant-newer")]; + const currentTail = [ + { + ...makeAssistantItem("newer chunk", "assistant-newer"), + timelineCursor: { epoch: "epoch-1", seq: 3 }, + }, + ]; const existingCursor: TimelineCursor = { epoch: "epoch-1", startSeq: 3, @@ -914,6 +938,9 @@ describe("processTimelineResponse", () => { }); expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]); + expect(result.tail[0]).toEqual( + expect.objectContaining({ timelineCursor: { epoch: "epoch-1", seq: 3 } }), + ); expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, @@ -1222,6 +1249,23 @@ describe("processTimelineResponse", () => { // --------------------------------------------------------------------------- describe("processAgentStreamEvent", () => { + it("preserves the live timeline cursor on an assistant error", () => { + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: makeAssistantTimelineEvent("[System Error] failed"), + epoch: "timeline-1", + seq: 42, + }); + + expect(result.head).toEqual([ + expect.objectContaining({ + kind: "assistant_message", + text: "[System Error] failed", + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }), + ]); + }); + it("passes through non-timeline events without cursor changes", () => { const turnEvent: AgentStreamEventPayload = { type: "turn_completed", diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 22b0062f2..aaa0e21c0 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -510,6 +510,7 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea ...olderLast, text: `${olderLast.text}${currentFirst.text}`, timestamp: currentFirst.timestamp, + ...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}), }, ...currentTail.slice(1), ]; @@ -519,8 +520,9 @@ function replaceLiveAssistantWithProjectedText(params: { head: StreamItem[]; event: AgentStreamEventPayload; timestamp: Date; + timelineCursor: { epoch: string; seq: number }; }): StreamItem[] | null { - const { head, event, timestamp } = params; + const { head, event, timestamp, timelineCursor } = params; if (event.type !== "timeline" || event.item.type !== "assistant_message") { return null; } @@ -537,6 +539,7 @@ function replaceLiveAssistantWithProjectedText(params: { ...current, text: event.item.text, timestamp, + timelineCursor, }; return next; } @@ -575,19 +578,22 @@ function applyTimelineIncrementalPath(args: { if (acceptedUnits.length > 0) { if (payload.direction === "before") { const olderTail = hydrateStreamState( - acceptedUnits.map(({ event, timestamp }) => ({ + acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ event, timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, })), { source: "canonical" }, ); nextTail = mergePrependedCanonicalTail(olderTail, currentTail); } else if (currentHead.length > 0) { - for (const { event, timestamp } of acceptedUnits) { + for (const { event, timestamp, seqEnd } of acceptedUnits) { + const timelineCursor = { epoch: payload.epoch, seq: seqEnd }; const replacedHead = replaceLiveAssistantWithProjectedText({ head: nextHead, event, timestamp, + timelineCursor, }); if (replacedHead) { nextHead = replacedHead; @@ -599,15 +605,17 @@ function applyTimelineIncrementalPath(args: { event, timestamp, source: "canonical", + timelineCursor, }); nextTail = applied.tail; nextHead = applied.head; } } else { nextTail = acceptedUnits.reduce( - (state, { event, timestamp }) => + (state, { event, timestamp, seqEnd }) => reduceStreamUpdate(state, event, timestamp, { source: "canonical", + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, }), currentTail, ); @@ -681,8 +689,16 @@ export function processTimelineResponse( const toHydratedEvents = ( units: TimelineUnit[], - ): Array<{ event: AgentStreamEventPayload; timestamp: Date }> => - units.map(({ event, timestamp }) => ({ event, timestamp })); + ): Array<{ + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; + }> => + units.map(({ event, timestamp, seqEnd }) => ({ + event, + timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, + })); // ------------------------------------------------------------------ // Derive bootstrap policy (replace vs incremental) @@ -934,6 +950,10 @@ export function processAgentStreamEvent( input; const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor }); + const timelineCursor = + event.type === "timeline" && seq !== undefined && epoch !== undefined + ? { epoch, seq } + : undefined; // ------------------------------------------------------------------ // Apply stream event to tail/head @@ -945,6 +965,7 @@ export function processAgentStreamEvent( event, timestamp, source: "live", + timelineCursor, }) : { tail: currentTail, diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index e059c0e98..13728a70e 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -107,12 +107,18 @@ export interface AssistantMessageItem { kind: "assistant_message"; id: string; messageId?: string; + timelineCursor?: TimelinePosition; text: string; timestamp: Date; blockGroupId?: string; blockIndex?: number; } +export interface TimelinePosition { + epoch: string; + seq: number; +} + export type ThoughtStatus = "loading" | "ready"; export interface ThoughtItem { @@ -202,6 +208,7 @@ export type StreamUpdateSource = "live" | "canonical"; interface StreamUpdateOptions { source?: StreamUpdateSource; reservedItemIds?: ReadonlySet; + timelineCursor?: TimelinePosition; } function isRecord(value: unknown): value is Record { @@ -385,6 +392,7 @@ function appendAssistantMessage( source: StreamUpdateSource, messageId?: string, reservedItemIds?: ReadonlySet, + timelineCursor?: TimelinePosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -401,6 +409,7 @@ function appendAssistantMessage( ...last, text: `${last.text}${chunk}`, timestamp, + ...(timelineCursor ? { timelineCursor } : {}), }; return [...state.slice(0, -1), updated]; } @@ -418,6 +427,7 @@ function appendAssistantMessage( ...secondLast, text: `${secondLast.text}${chunk}`, timestamp, + ...(timelineCursor ? { timelineCursor } : {}), }; return [...state.slice(0, -2), updated, last]; } @@ -432,6 +442,7 @@ function appendAssistantMessage( kind: "assistant_message", id: entryId, ...(messageId ? { messageId } : {}), + ...(timelineCursor ? { timelineCursor } : {}), text: chunk, timestamp, }; @@ -817,6 +828,7 @@ function reduceTimelineEvent( timestamp: Date, source: StreamUpdateSource, reservedItemIds?: ReadonlySet, + timelineCursor?: TimelinePosition, ): StreamItem[] { const item = event.item; switch (item.type) { @@ -831,6 +843,7 @@ function reduceTimelineEvent( source, item.messageId, reservedItemIds, + timelineCursor, ), ); case "reasoning": @@ -876,7 +889,14 @@ export function reduceStreamUpdate( const source = options?.source ?? "live"; switch (event.type) { case "timeline": - return reduceTimelineEvent(state, event, timestamp, source, options?.reservedItemIds); + return reduceTimelineEvent( + state, + event, + timestamp, + source, + options?.reservedItemIds, + options?.timelineCursor, + ); case "thread_started": case "turn_started": case "turn_completed": @@ -898,11 +918,12 @@ export function hydrateStreamState( events: Array<{ event: AgentStreamEventPayload; timestamp: Date; + timelineCursor?: TimelinePosition; }>, options?: { source?: StreamUpdateSource }, ): StreamItem[] { - const hydrated = events.reduce((state, { event, timestamp }) => { - return reduceStreamUpdate(state, event, timestamp, options); + const hydrated = events.reduce((state, { event, timestamp, timelineCursor }) => { + return reduceStreamUpdate(state, event, timestamp, { ...options, timelineCursor }); }, []); return finalizeActiveThoughts(hydrated); @@ -1186,6 +1207,7 @@ export function applyStreamEvent(params: { event: AgentStreamEventPayload; timestamp: Date; source?: StreamUpdateSource; + timelineCursor?: TimelinePosition; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; const source = params.source ?? "live"; @@ -1256,7 +1278,11 @@ export function applyStreamEvent(params: { ), ) : undefined; - const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, reservedItemIds }); + const reduced = reduceStreamUpdate(nextHead, event, timestamp, { + source, + reservedItemIds, + timelineCursor: params.timelineCursor, + }); if (reduced !== nextHead) { nextHead = reduced; changedHead = true; @@ -1275,7 +1301,10 @@ export function applyStreamEvent(params: { } // For non-streamable kinds or non-timeline events, apply to tail - const reduced = reduceStreamUpdate(nextTail, event, timestamp, { source }); + const reduced = reduceStreamUpdate(nextTail, event, timestamp, { + source, + timelineCursor: params.timelineCursor, + }); if (reduced !== nextTail) { nextTail = reduced; changedTail = true; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9f5f3d7c3..fdb905b13 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -538,6 +538,7 @@ function normalizeListCommandsOptions( return { agentId: input, ...legacyOptions }; } export interface AgentForkContextOptions { + boundaryCursor?: FetchAgentTimelineCursor; boundaryMessageId?: string; requestId?: string; } @@ -2555,6 +2556,7 @@ export class DaemonClient { type: "agent.fork_context.request", agentId, requestId: resolvedRequestId, + ...(options.boundaryCursor ? { boundaryCursor: options.boundaryCursor } : {}), ...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}), }); diff --git a/packages/protocol/src/messages.attachments.test.ts b/packages/protocol/src/messages.attachments.test.ts index 57f52d240..2a037d913 100644 --- a/packages/protocol/src/messages.attachments.test.ts +++ b/packages/protocol/src/messages.attachments.test.ts @@ -1,12 +1,55 @@ import { describe, expect, it } from "vitest"; import { + AgentForkContextRequestMessageSchema, + AgentForkContextResponseMessageSchema, CreateAgentRequestMessageSchema, CreatePaseoWorktreeRequestSchema, SendAgentMessageRequestSchema, } from "./messages.js"; describe("shared messages attachments", () => { + it("preserves an optional timeline cursor on fork-context messages", () => { + const boundaryCursor = { epoch: "timeline-1", seq: 42 }; + const request = AgentForkContextRequestMessageSchema.parse({ + type: "agent.fork_context.request", + agentId: "agent-1", + requestId: "fork-1", + boundaryCursor, + }); + const response = AgentForkContextResponseMessageSchema.parse({ + type: "agent.fork_context.response", + payload: { + requestId: "fork-1", + agentId: "agent-1", + attachment: null, + itemCount: 2, + boundaryMessageId: null, + boundaryCursor, + error: null, + }, + }); + + expect(request.boundaryCursor).toEqual(boundaryCursor); + expect(response.payload.boundaryCursor).toEqual(boundaryCursor); + }); + + it("accepts a legacy fork-context response without a timeline cursor", () => { + expect( + AgentForkContextResponseMessageSchema.parse({ + type: "agent.fork_context.response", + payload: { + requestId: "fork-1", + agentId: "agent-1", + attachment: null, + itemCount: 2, + boundaryMessageId: "assistant-1", + error: null, + }, + }).payload.boundaryCursor, + ).toBeUndefined(); + }); + it("keeps valid review attachments", () => { const parsed = SendAgentMessageRequestSchema.parse({ type: "send_agent_message_request", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1718e1117..965b95aec 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1321,6 +1321,7 @@ export const ProviderSubagentTimelineRequestMessageSchema = z.object({ export const AgentForkContextRequestMessageSchema = z.object({ type: z.literal("agent.fork_context.request"), agentId: z.string(), + boundaryCursor: AgentTimelineCursorSchema.optional(), boundaryMessageId: z.string().optional(), requestId: z.string(), }); @@ -2429,6 +2430,8 @@ export const ServerInfoStatusPayloadSchema = z daemonSelfUpdate: z.boolean().optional(), // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: z.boolean().optional(), + // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. + agentForkContextCursor: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. @@ -3129,6 +3132,7 @@ export const AgentForkContextResponseMessageSchema = z.object({ attachment: TextAttachmentSchema.nullable(), itemCount: z.number().int().nonnegative(), boundaryMessageId: z.string().nullable(), + boundaryCursor: AgentTimelineCursorSchema.nullable().optional(), error: z.string().nullable(), }), }); diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 75ae75fe7..2f76fe635 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -315,6 +315,8 @@ second line'`, contextKind: "chat_history", title: "Chat history", }); + expect(result.attachment.text).toMatch(/^\n/); + expect(result.attachment.text).toMatch(/\n<\/chat-history-summary>$/); expect(result.attachment.text).toContain("Source agent: Source Agent"); expect(result.attachment.text).toContain("Source directory: /repo"); expect(result.attachment.text).toContain("[User] Ship the thing"); @@ -394,6 +396,41 @@ second line'`, expect(result.attachment.text).not.toContain("after boundary"); }); + it("selects a synthetic assistant error by its timeline cursor", () => { + const result = buildAgentForkContextAttachment({ + cursorBoundary: { + timelineEpoch: "timeline-1", + cursor: { epoch: "timeline-1", seq: 2 }, + }, + rows: [ + row(1, { type: "user_message", text: "Try the task", messageId: "user-1" }), + row(2, { type: "assistant_message", text: "[System Error] provider failed" }), + row(3, { + type: "assistant_message", + text: "This belongs to a later turn.", + messageId: "assistant-2", + }), + ], + }); + + expect(result.boundaryCursor).toEqual({ epoch: "timeline-1", seq: 2 }); + expect(result.boundaryMessageId).toBeNull(); + expect(result.attachment.text).toContain("[System Error] provider failed"); + expect(result.attachment.text).not.toContain("This belongs to a later turn."); + }); + + it("rejects a cursor from a previous timeline epoch", () => { + expect(() => + buildAgentForkContextAttachment({ + cursorBoundary: { + timelineEpoch: "timeline-2", + cursor: { epoch: "timeline-1", seq: 2 }, + }, + rows: [row(2, { type: "assistant_message", text: "Stale result." })], + }), + ).toThrow("Selected timeline position is no longer available."); + }); + it("rejects missing assistant boundaries instead of silently using the wrong context", () => { expect(() => buildAgentForkContextAttachment({ diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 359fb4280..da3318c1a 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -216,30 +216,55 @@ export function curateAgentActivity( : "No activity to display."; } +interface ForkCursorBoundary { + timelineEpoch: string; + cursor: { epoch: string; seq: number }; +} + function selectForkContextRows(input: { rows: readonly AgentTimelineRow[]; + cursorBoundary?: ForkCursorBoundary | null; boundaryMessageId?: string | null; -}): { items: AgentTimelineItem[]; boundaryMessageId: string | null } { +}): { + items: AgentTimelineItem[]; + boundaryCursor: { epoch: string; seq: number } | null; + boundaryMessageId: string | null; +} { + const boundaryCursor = input.cursorBoundary?.cursor ?? null; const boundaryMessageId = input.boundaryMessageId?.trim() || null; - if (!boundaryMessageId) { + if (!boundaryCursor && !boundaryMessageId) { const projected = projectTimelineRows({ rows: input.rows, mode: "projected" }); return { items: projected.map((entry) => entry.item), + boundaryCursor: null, boundaryMessageId: null, }; } - const boundaryIndex = input.rows.findLastIndex( - (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, - ); + if ( + input.cursorBoundary && + input.cursorBoundary.cursor.epoch !== input.cursorBoundary.timelineEpoch + ) { + throw new Error("Selected timeline position is no longer available."); + } + const boundaryIndex = boundaryCursor + ? input.rows.findIndex((row) => row.seq === boundaryCursor.seq) + : input.rows.findLastIndex( + (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, + ); if (boundaryIndex < 0) { - throw new Error("Selected assistant message is no longer available."); + throw new Error( + boundaryCursor + ? "Selected timeline position is no longer available." + : "Selected assistant message is no longer available.", + ); } const selectedRows = input.rows.slice(0, boundaryIndex + 1); const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" }); return { items: projected.map((entry) => entry.item), + boundaryCursor, boundaryMessageId, }; } @@ -263,17 +288,24 @@ function buildForkContextText(input: { if (cwd) { header.push(`Source directory: ${cwd}`); } - return `${header.join("\n")}\n\n${input.body}`; + return `\n${header.join("\n")}\n\n${input.body}\n`; } export function buildAgentForkContextAttachment(input: { rows: readonly AgentTimelineRow[]; + cursorBoundary?: ForkCursorBoundary | null; boundaryMessageId?: string | null; agentTitle?: string | null; cwd?: string | null; -}): { attachment: TextAgentAttachment; itemCount: number; boundaryMessageId: string | null } { +}): { + attachment: TextAgentAttachment; + itemCount: number; + boundaryCursor: { epoch: string; seq: number } | null; + boundaryMessageId: string | null; +} { const selected = selectForkContextRows({ rows: input.rows, + cursorBoundary: input.cursorBoundary, boundaryMessageId: input.boundaryMessageId, }); const entries = curateProjectedActivityEntries(selected.items, { @@ -299,6 +331,7 @@ export function buildAgentForkContextAttachment(input: { }), }, itemCount: selected.items.length, + boundaryCursor: selected.boundaryCursor, boundaryMessageId: selected.boundaryMessageId, }; } diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 60c4d2df6..7954ec1d4 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -13,16 +13,12 @@ import type { } from "../../worktree-session.js"; import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js"; import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js"; -import type { - AgentPromptContentBlock, - AgentPromptInput, - AgentRunOptions, - AgentSessionConfig, -} from "../agent-sdk-types.js"; +import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentStorage } from "../agent-storage.js"; import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js"; import { resolveCreateAgentTitles } from "../create-agent-title.js"; +import { buildAgentPrompt } from "../prompt-attachments.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js"; import { @@ -486,30 +482,6 @@ async function sendInitialPrompt( } } -function buildAgentPrompt( - text: string, - images?: Array<{ data: string; mimeType: string }>, - attachments?: AgentAttachment[], -): AgentPromptInput { - const normalized = text.trim(); - const hasImages = (images?.length ?? 0) > 0; - const hasAttachments = (attachments?.length ?? 0) > 0; - if (!hasImages && !hasAttachments) { - return normalized; - } - const blocks: AgentPromptContentBlock[] = []; - if (normalized.length > 0) { - blocks.push({ type: "text", text: normalized }); - } - for (const image of images ?? []) { - blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); - } - for (const attachment of attachments ?? []) { - blocks.push(attachment); - } - return blocks; -} - function requireParentAgent(agentManager: AgentManager, parentAgentId: string): ManagedAgent { const parentAgent = agentManager.getAgent(parentAgentId); if (!parentAgent) { diff --git a/packages/server/src/server/agent/prompt-attachments.test.ts b/packages/server/src/server/agent/prompt-attachments.test.ts index d187415c0..42820c9ea 100644 --- a/packages/server/src/server/agent/prompt-attachments.test.ts +++ b/packages/server/src/server/agent/prompt-attachments.test.ts @@ -1,8 +1,42 @@ import { describe, expect, it } from "vitest"; -import { buildAgentBranchNameSeed, renderPromptAttachmentAsText } from "./prompt-attachments.js"; +import { + buildAgentBranchNameSeed, + buildAgentPrompt, + renderPromptAttachmentAsText, +} from "./prompt-attachments.js"; describe("prompt attachments", () => { + it("places fork history before the new user prompt", () => { + const chatHistory = { + type: "text" as const, + mimeType: "text/plain", + contextKind: "chat_history" as const, + title: "Chat history", + text: "\nPrevious work\n", + }; + const issue = { + type: "github_issue" as const, + mimeType: "application/github-issue", + number: 55, + title: "Issue", + url: "https://github.com/getpaseo/paseo/issues/55", + }; + + expect( + buildAgentPrompt( + " Take a different approach ", + [{ data: "image-data", mimeType: "image/png" }], + [issue, chatHistory], + ), + ).toEqual([ + chatHistory, + { type: "text", text: "Take a different approach" }, + { type: "image", data: "image-data", mimeType: "image/png" }, + issue, + ]); + }); + it("renders github_pr attachments as readable text", () => { expect( renderPromptAttachmentAsText({ diff --git a/packages/server/src/server/agent/prompt-attachments.ts b/packages/server/src/server/agent/prompt-attachments.ts index 129018f2f..c1dba8276 100644 --- a/packages/server/src/server/agent/prompt-attachments.ts +++ b/packages/server/src/server/agent/prompt-attachments.ts @@ -1,7 +1,41 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { AgentPromptContentBlock, AgentPromptInput } from "./agent-sdk-types.js"; const REVIEW_LINE_MARKERS = { add: "+", remove: "-", context: " " } as const; +export function buildAgentPrompt( + text: string, + images?: Array<{ data: string; mimeType: string }>, + attachments?: AgentAttachment[], +): AgentPromptInput { + const normalized = text.trim(); + const hasImages = (images?.length ?? 0) > 0; + const hasAttachments = (attachments?.length ?? 0) > 0; + if (!hasImages && !hasAttachments) { + return normalized; + } + + const chatHistoryAttachments: AgentAttachment[] = []; + const otherAttachments: AgentAttachment[] = []; + for (const attachment of attachments ?? []) { + if (attachment.type === "text" && attachment.contextKind === "chat_history") { + chatHistoryAttachments.push(attachment); + } else { + otherAttachments.push(attachment); + } + } + + const blocks: AgentPromptContentBlock[] = [...chatHistoryAttachments]; + if (normalized.length > 0) { + blocks.push({ type: "text", text: normalized }); + } + for (const image of images ?? []) { + blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); + } + blocks.push(...otherAttachments); + return blocks; +} + export function renderPromptAttachmentAsText(attachment: AgentAttachment): string { switch (attachment.type) { case "github_pr": { diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 51763d336..98d904076 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -179,6 +179,38 @@ describe("MockLoadTestAgentClient", () => { expect(events).toHaveLength(eventCountAfterInterrupt); }); + test("emits a terminal failure without an assistant provider message", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + + await session.startTurn("Emit a synthetic turn failure."); + await vi.advanceTimersByTimeAsync(0); + unsubscribe(); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "user_message" }), + }), + ); + expect( + events.filter( + (event) => event.type === "timeline" && event.item.type === "assistant_message", + ), + ).toHaveLength(0); + expect(events.at(-1)).toMatchObject({ + type: "turn_failed", + error: "Requested mock provider failure", + }); + }); + test("emits the free-write question scenario selected by prompt", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 1609fe4a2..d371ef1ac 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -152,6 +152,10 @@ function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean { return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt)); } +function shouldEmitTurnFailure(prompt: AgentPromptInput): boolean { + return /emit\s+(?:a\s+)?synthetic\s+turn\s+failure/i.test(promptToText(prompt)); +} + function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null { const text = promptToText(prompt); if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) { @@ -652,7 +656,9 @@ export class MockLoadTestAgentSession implements AgentSession { const stress = parseAgentStreamStressPrompt(prompt); const questionPrompt = parseMockQuestionPrompt(prompt); const structuredBranchName = parseStructuredBranchNamePrompt(prompt); - if (structuredBranchName) { + if (shouldEmitTurnFailure(prompt)) { + this.scheduleFailedTurn(turn); + } else if (structuredBranchName) { this.scheduleStructuredJsonTurn(turn, structuredBranchName); } else if (shouldEmitPlanApprovalPrompt(prompt)) { this.schedulePlanApprovalTurn(turn); @@ -816,6 +822,34 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private scheduleFailedTurn(turn: ActiveTurn): void { + turn.timer = setTimeout(() => { + if (this.activeTurn !== turn) { + return; + } + this.clearTurnTimer(turn); + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + this.activeTurn = null; + this.emit({ + type: "turn_failed", + provider: this.provider, + turnId: turn.turnId, + error: "Requested mock provider failure", + }); + turn.resolve({ + sessionId: this.id, + finalText: "", + timeline: [], + canceled: false, + }); + }, 0); + turn.timer.unref?.(); + } + private scheduleStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void { turn.timer = setTimeout(() => { this.emitStressTurn(turn, stress); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 69df4cd15..48e44ab66 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -100,13 +100,12 @@ import { type TimelineProjectionMode, } from "./agent/timeline-projection.js"; import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; +import { buildAgentPrompt } from "./agent/prompt-attachments.js"; import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; import { getAgentStreamEventTurnId, type AgentPersistenceHandle, type AgentPermissionResponse, - type AgentPromptContentBlock, - type AgentPromptInput, type AgentRunOptions, type AgentSessionConfig, } from "./agent/agent-sdk-types.js"; @@ -1027,33 +1026,6 @@ export class Session { // No unsolicited agent list hydration. Callers must use fetch_agents_request. } - /** - * Normalize a user prompt (with optional image metadata) for AgentManager - */ - private buildAgentPrompt( - text: string, - images?: Array<{ data: string; mimeType: string }>, - attachments?: AgentAttachment[], - ): AgentPromptInput { - const normalized = text?.trim() ?? ""; - const hasImages = Boolean(images && images.length > 0); - const hasAttachments = Boolean(attachments && attachments.length > 0); - if (!hasImages && !hasAttachments) { - return normalized; - } - const blocks: AgentPromptContentBlock[] = []; - if (normalized.length > 0) { - blocks.push({ type: "text", text: normalized }); - } - for (const image of images ?? []) { - blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); - } - for (const attachment of attachments ?? []) { - blocks.push(attachment); - } - return blocks; - } - /** * Interrupt the agent's active run so the next prompt starts a fresh turn. * Returns once the manager confirms the stream has been cancelled. @@ -2439,7 +2411,7 @@ export class Session { ); const promptText = options?.spokenInput ? wrapSpokenInput(text) : text; - const prompt = this.buildAgentPrompt(promptText, images, attachments); + const prompt = buildAgentPrompt(promptText, images, attachments); try { await sendPromptToAgent({ @@ -5522,12 +5494,15 @@ export class Session { logger: this.sessionLogger, }); const agentPayload = await this.buildAgentPayload(snapshot); - const rows = this.agentManager.fetchTimeline(msg.agentId, { + const timeline = this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0, - }).rows; + }); const forkContext = buildAgentForkContextAttachment({ - rows, + rows: timeline.rows, + cursorBoundary: msg.boundaryCursor + ? { timelineEpoch: timeline.epoch, cursor: msg.boundaryCursor } + : null, boundaryMessageId: msg.boundaryMessageId, agentTitle: agentPayload.title, cwd: snapshot.cwd, @@ -5540,6 +5515,7 @@ export class Session { agentId: msg.agentId, attachment: forkContext.attachment, itemCount: forkContext.itemCount, + boundaryCursor: forkContext.boundaryCursor, boundaryMessageId: forkContext.boundaryMessageId, error: null, }, @@ -5556,6 +5532,7 @@ export class Session { agentId: msg.agentId, attachment: null, itemCount: 0, + boundaryCursor: msg.boundaryCursor ?? null, boundaryMessageId: msg.boundaryMessageId ?? null, error: error instanceof Error ? error.message : String(error), }, @@ -5583,7 +5560,7 @@ export class Session { try { const agentId = resolved.agentId; - const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments); + const prompt = buildAgentPrompt(msg.text, msg.images, msg.attachments); this.sessionLogger.trace( { agentId, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 05ce7e886..ff371979a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1260,6 +1260,8 @@ export class VoiceAssistantWebSocketServer { daemonSelfUpdate: true, // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: true, + // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. + agentForkContextCursor: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. From 9423b091b9ebe944dced8e1a28f698e27aa7c784 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 18:34:30 +0200 Subject: [PATCH 17/20] fix(app): keep tool call summaries neutral (#2090) Individual tool outcomes remain visible in the expanded list. The aggregate header no longer treats a handled tool result as a failure of the overall work. --- packages/app/src/i18n/resources/ar.ts | 1 - packages/app/src/i18n/resources/en.ts | 1 - packages/app/src/i18n/resources/es.ts | 1 - packages/app/src/i18n/resources/fr.ts | 1 - packages/app/src/i18n/resources/ja.ts | 1 - packages/app/src/i18n/resources/pt-BR.ts | 1 - packages/app/src/i18n/resources/ru.ts | 1 - packages/app/src/i18n/resources/zh-CN.ts | 1 - packages/app/src/tool-calls/detail-level/overview/model.ts | 4 ---- packages/app/src/tool-calls/detail-level/overview/view.tsx | 5 ----- packages/app/src/tool-calls/detail-level/projection.test.ts | 2 -- 11 files changed, 19 deletions(-) diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b5864d682..253a43a00 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1420,7 +1420,6 @@ export const ar: TranslationResources = { other: "استدعى Paseo {{count}} مرات", }, and: "و", - failed: "فشل {{count}}", }, renameModal: { rename: "إعادة تسمية", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 213961f7c..46d55b50f 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1428,7 +1428,6 @@ export const en = { other: "called Paseo {{count}} times", }, and: "and", - failed: "{{count}} failed", }, renameModal: { rename: "Rename", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index e8ecb927f..b5a9e1d3a 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1459,7 +1459,6 @@ export const es: TranslationResources = { other: "llamó a Paseo {{count}} veces", }, and: "y", - failed: "{{count}} con error", }, renameModal: { rename: "Rebautizar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 81cf5fddd..c2e5144b1 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1462,7 +1462,6 @@ export const fr: TranslationResources = { other: "a appelé Paseo {{count}} fois", }, and: "et", - failed: "{{count}} en échec", }, renameModal: { rename: "Rebaptiser", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 849aa62bd..6a74a8a2c 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1437,7 +1437,6 @@ export const ja: TranslationResources = { other: "Paseoを{{count}}回呼び出し", }, and: "および", - failed: "{{count}}件失敗", }, renameModal: { rename: "名前を変更", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f4d6a4d5d..8832b1ff2 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1445,7 +1445,6 @@ export const ptBR: TranslationResources = { other: "chamou o Paseo {{count}} vezes", }, and: "e", - failed: "{{count}} com falha", }, renameModal: { rename: "Renomear", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9755b2425..e895100d7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1451,7 +1451,6 @@ export const ru: TranslationResources = { other: "Paseo вызван {{count}} раз", }, and: "и", - failed: "С ошибкой: {{count}}", }, renameModal: { rename: "Переименовать", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index fce6d1266..585049ca3 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1404,7 +1404,6 @@ export const zhCN: TranslationResources = { other: "调用了 Paseo {{count}} 次", }, and: "并", - failed: "{{count}} 次失败", }, renameModal: { rename: "重命名", diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts index c0901ea63..f41790461 100644 --- a/packages/app/src/tool-calls/detail-level/overview/model.ts +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -17,7 +17,6 @@ export interface OverviewToolCallGroup { mode: "overview"; run: ToolCallRun; summary: OverviewSummary; - failedCount: number; isLoading: boolean; } @@ -32,7 +31,6 @@ function isSearchCall(name: string): boolean { export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { const editedFiles = new Set(); const readFiles = new Set(); - let failedCount = 0; let isLoading = false; let commandCount = 0; let searchCount = 0; @@ -42,7 +40,6 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { for (const call of run.calls) { const descriptor = describeToolCall(call); const normalizedName = descriptor.name.trim().toLowerCase(); - failedCount += descriptor.status === "failed" ? 1 : 0; isLoading ||= descriptor.status === "running" || descriptor.status === "executing"; if (isPaseoCall(descriptor.name, normalizedName)) { paseoCallCount += 1; @@ -70,7 +67,6 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { return { mode: "overview", run, - failedCount, isLoading, summary, }; diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx index 48358c468..a878163a9 100644 --- a/packages/app/src/tool-calls/detail-level/overview/view.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -58,11 +58,8 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView onExpandedChange, children, }: OverviewGroupProps) { - const { t } = useTranslation(); const scrollRef = useRef(null); const aggregateSummary = useOverviewSummary(group.summary); - const failedSummary = - group.failedCount > 0 ? t("toolCallGroup.failed", { count: group.failedCount }) : undefined; const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); @@ -89,10 +86,8 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView 0} isExpanded={expanded} isLastInSequence={isLastInSequence} onToggle={toggle} diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index de5a8b150..c0e32c312 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -231,7 +231,6 @@ describe("tool call detail-level projection", () => { expect(overview.groupsByHostId.get("1")).toEqual({ mode: "overview", run: expect.any(Object), - failedCount: 1, isLoading: false, summary: { editedFileCount: 1, @@ -260,7 +259,6 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: calls }); expect(result.groupsByHostId.get("1")).toMatchObject({ - failedCount: 1, summary: { editedFileCount: 0, commandCount: 0, From 328361667f20a74c791f18454e6a7de8576b3279 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 18:40:24 +0200 Subject: [PATCH 18/20] Keep browser sign-ins across tabs and restarts (#2089) * feat(desktop): share persistent browser data across tabs Keep browser sign-ins and site storage across tabs and app restarts, with a Settings action to clear the shared profile. * fix(settings): prevent duplicate browser data clears * fix(desktop): complete browser data cleanup Clear legacy per-tab profiles after upgrades and localize the browser data settings in every supported language. * fix(desktop): close browser profile cleanup races --- docs/architecture.md | 4 +- docs/browser-capture-harness.md | 11 + .../src/browser-automation/handler.test.ts | 43 ++- .../app/src/browser-automation/handler.ts | 5 +- .../src/components/browser-pane.electron.tsx | 2 +- .../browser-webview-resident.browser.test.ts | 93 ++++++- .../components/browser-webview-resident.ts | 82 +++++- .../components/browser-data-section.tsx | 80 ++++++ packages/app/src/desktop/host.ts | 11 +- packages/app/src/i18n/resources/ar.ts | 11 + packages/app/src/i18n/resources/en.ts | 11 + packages/app/src/i18n/resources/es.ts | 13 + packages/app/src/i18n/resources/fr.ts | 12 + packages/app/src/i18n/resources/ja.ts | 11 + packages/app/src/i18n/resources/pt-BR.ts | 12 + packages/app/src/i18n/resources/ru.ts | 12 + packages/app/src/i18n/resources/zh-CN.ts | 11 + packages/app/src/screens/settings-screen.tsx | 20 +- .../screens/workspace/workspace-screen.tsx | 2 +- packages/desktop/capture-harness/index.html | 55 +++- packages/desktop/capture-harness/main.js | 250 +++++++++++++++++- packages/desktop/capture-harness/run.sh | 11 +- .../src/features/browser-profile.test.ts | 208 +++++++++++++++ .../desktop/src/features/browser-profile.ts | 123 +++++++++ .../features/browser-webviews/index.test.ts | 158 ++++++++++- .../src/features/browser-webviews/index.ts | 74 ++++-- .../browser-webviews/registry.test.ts | 14 + .../src/features/browser-webviews/registry.ts | 11 + .../browser-webviews/window-open.test.ts | 22 +- .../features/browser-webviews/window-open.ts | 28 ++ packages/desktop/src/main.ts | 145 +++++++--- packages/desktop/src/preload.ts | 16 +- 32 files changed, 1413 insertions(+), 148 deletions(-) create mode 100644 packages/app/src/desktop/components/browser-data-section.tsx create mode 100644 packages/desktop/src/features/browser-profile.test.ts create mode 100644 packages/desktop/src/features/browser-profile.ts diff --git a/docs/architecture.md b/docs/architecture.md index 331b0ecb7..8fa5d4dfa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,7 +138,9 @@ Electron wrapper for macOS, Linux, and Windows. > **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys. > -> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus still records the workspace-active browser for UI state and `list_tabs` reporting, but agent automation targets only explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up. +> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs. + +> **In-app browser targets are not yet per-window.** Browser webviews are still tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus records the workspace-active browser for UI state and `list_tabs` reporting, while agent automation targets explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. Explicit attached-guest registration prevents concurrent windows from swapping different browser ids, but rendering the same saved browser tab in multiple windows can still make menu actions target the most recently registered guest. Making the registry window-scoped remains a follow-up. ### `packages/website` — Marketing site diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index 24da98263..5907b1a76 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -27,6 +27,17 @@ Run the browser automation fixture with: PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop ``` +Run the shared browser profile fixture with: + +```bash +PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop +``` + +The browser profile group runs two Electron processes in sequence. It verifies that each +renderer-side `did-attach` identity maps to the correct main-process guest, that two live +tabs share cookies and local storage through one persistent session, and that the data is +still present after the first Electron process exits and the second starts. + The automation group uses a real guest webview to verify the page-side ref contract: ARIA-like snapshot text includes headings, static text, and controls; refs survive `pushState` when the element still matches; same-URL rerenders stale old refs; and a diff --git a/packages/app/src/browser-automation/handler.test.ts b/packages/app/src/browser-automation/handler.test.ts index a40fb7699..6a7d3573a 100644 --- a/packages/app/src/browser-automation/handler.test.ts +++ b/packages/app/src/browser-automation/handler.test.ts @@ -73,10 +73,7 @@ class FakeDaemonClient { class FakeBrowserBridge { public readonly executedRequests: BrowserAutomationExecuteRequest[] = []; - public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> = - []; public readonly unregisteredWorkspaceBrowsers: string[] = []; - public readonly clearedPartitions: string[] = []; public readonly activeWorkspaceBrowsers: Array<{ browserId: string | null; workspaceId: string; @@ -94,21 +91,10 @@ class FakeBrowserBridge { return this.response ?? currentListTabsPayload(request.requestId); }; - public registerWorkspaceBrowser = async (input: { - browserId: string; - workspaceId: string; - }): Promise => { - this.registeredWorkspaceBrowsers.push(input); - }; - public unregisterWorkspaceBrowser = async (browserId: string): Promise => { this.unregisteredWorkspaceBrowsers.push(browserId); }; - public clearPartition = async (browserId: string): Promise => { - this.clearedPartitions.push(browserId); - }; - public setWorkspaceActiveBrowser = async (input: { browserId: string | null; workspaceId: string; @@ -118,9 +104,17 @@ class FakeBrowserBridge { } class FakeResidentBrowser { - public readonly ensuredWebviews: Array<{ browserId: string; url: string }> = []; + public readonly ensuredWebviews: Array<{ + browserId: string; + workspaceId: string; + url: string; + }> = []; - public ensure = (input: { browserId: string; url: string }): HTMLElement | null => { + public ensure = (input: { + browserId: string; + workspaceId: string; + url: string; + }): HTMLElement | null => { this.ensuredWebviews.push(input); return null; }; @@ -321,12 +315,13 @@ describe("mountBrowserAutomationHandler", () => { }), ); expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId); - expect(browser.browser.registeredWorkspaceBrowsers).toEqual([ - { browserId: result.browserId, workspaceId: "wks_workspace_a" }, - ]); expect(browser.browser.activeWorkspaceBrowsers).toEqual([]); expect(browser.resident.ensuredWebviews).toEqual([ - { browserId: result.browserId, url: "https://example.com" }, + { + browserId: result.browserId, + workspaceId: "wks_workspace_a", + url: "https://example.com", + }, ]); expect(browser.browser.executedRequests).toEqual([ { @@ -366,7 +361,10 @@ describe("mountBrowserAutomationHandler", () => { }, ]); expect(browser.resident.ensuredWebviews).toEqual([ - expect.objectContaining({ url: "https://example.com" }), + expect.objectContaining({ + workspaceId: "wks_workspace_a", + url: "https://example.com", + }), ]); }); @@ -440,7 +438,7 @@ describe("mountBrowserAutomationHandler", () => { }); }); - test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => { + test("browser_close_tab removes the workspace tab, browser record, resident webview, and registry entry", async () => { const browser = new BrowserAutomationHandlerHarness(); const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId: "server-1", @@ -466,7 +464,6 @@ describe("mountBrowserAutomationHandler", () => { expect(workspaceBrowserTabs(workspaceKey, result.browserId)).toEqual([]); expect(useBrowserStore.getState().browsersById[result.browserId]).toBeUndefined(); expect(browser.browser.unregisteredWorkspaceBrowsers).toEqual([result.browserId]); - expect(browser.browser.clearedPartitions).toEqual([result.browserId]); expect(currentBrowserTabs()).toEqual([]); }); diff --git a/packages/app/src/browser-automation/handler.ts b/packages/app/src/browser-automation/handler.ts index d728248ea..6f8ab8ad8 100644 --- a/packages/app/src/browser-automation/handler.ts +++ b/packages/app/src/browser-automation/handler.ts @@ -258,7 +258,6 @@ async function closeBrowserTabForRequest(params: { useBrowserStore.getState().removeBrowser(browserId); removeResidentBrowserWebview(browserId); await browserHost?.unregisterWorkspaceBrowser?.(browserId); - await browserHost?.clearPartition?.(browserId); return { requestId: request.requestId, @@ -337,10 +336,8 @@ async function openBrowserTabForRequest(params: { browserId, }); - await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId }); - if (browserHost?.executeAutomationCommand) { - ensureResidentBrowserWebview({ browserId, url: normalizedUrl }); + ensureResidentBrowserWebview({ browserId, workspaceId, url: normalizedUrl }); const registered = await waitForBrowserRegistration({ request, browserId, diff --git a/packages/app/src/components/browser-pane.electron.tsx b/packages/app/src/components/browser-pane.electron.tsx index 95485c043..f3b9141aa 100644 --- a/packages/app/src/components/browser-pane.electron.tsx +++ b/packages/app/src/components/browser-pane.electron.tsx @@ -740,10 +740,10 @@ export function BrowserPane({ const residentWebview = takeResidentBrowserWebview(browserId) as ElectronWebview | null; const webview = residentWebview ?? (document.createElement("webview") as ElectronWebview); webviewRef.current = webview; - void getDesktopHost()?.browser?.registerWorkspaceBrowser?.({ browserId, workspaceId }); if (!residentWebview) { prepareBrowserWebview(webview, { browserId, + workspaceId, initialUrl: initialUnsafeNavigationMessage ? "about:blank" : initialUrlRef.current, }); } diff --git a/packages/app/src/components/browser-webview-resident.browser.test.ts b/packages/app/src/components/browser-webview-resident.browser.test.ts index d5be14d43..cca532a44 100644 --- a/packages/app/src/components/browser-webview-resident.browser.test.ts +++ b/packages/app/src/components/browser-webview-resident.browser.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + type BrowserWebviewProfileHost, clearResidentBrowserWebviewsForTests, ensureResidentBrowserWebview, prepareBrowserWebview, @@ -9,6 +10,32 @@ import { } from "./browser-webview-resident"; const RESIDENT_HOST_ID = "paseo-browser-resident-webviews"; +const attachedBrowsers: Array<{ + browserId: string; + workspaceId: string; + webContentsId: number; +}> = []; +const profileHost: BrowserWebviewProfileHost = { + profilePartition: "persist:paseo-browser", + registerAttachedBrowser: async (input) => { + attachedBrowsers.push(input); + }, +}; + +function ensureTestBrowser(input: { + browserId: string; + workspaceId: string; + url: string; +}): HTMLElement | null { + return ensureResidentBrowserWebview({ ...input, profileHost }); +} + +function prepareTestBrowser( + webview: HTMLElement, + input: { browserId: string; workspaceId: string; initialUrl?: string | null }, +): void { + prepareBrowserWebview(webview, { ...input, profileHost }); +} function residentHost(): HTMLElement { const host = document.getElementById(RESIDENT_HOST_ID); @@ -44,6 +71,10 @@ function expectResidentWebviewParking(webview: HTMLElement): void { } describe("resident browser webviews", () => { + beforeEach(() => { + attachedBrowsers.length = 0; + }); + afterEach(() => { clearResidentBrowserWebviewsForTests(); }); @@ -65,15 +96,16 @@ describe("resident browser webviews", () => { }); it("creates a resident webview for an agent-created unfocused tab", () => { - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-agent", + workspaceId: "workspace-agent", url: "https://example.com", }); expect(webview).not.toBeNull(); expect(webview?.isConnected).toBe(true); expect(webview?.getAttribute("data-paseo-browser-id")).toBe("browser-agent"); - expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser-browser-agent"); + expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser"); expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain( "https://example.com", ); @@ -81,6 +113,34 @@ describe("resident browser webviews", () => { expectResidentWebviewParking(webview as HTMLElement); }); + it("shares one profile and registers attached guests with explicit identity", () => { + const firstWebview = ensureTestBrowser({ + browserId: "browser-first", + workspaceId: "workspace-a", + url: "https://example.com/first", + }); + const secondWebview = ensureTestBrowser({ + browserId: "browser-second", + workspaceId: "workspace-b", + url: "https://example.com/second", + }); + if (!firstWebview || !secondWebview) { + throw new Error("Expected resident webviews"); + } + Object.assign(firstWebview, { getWebContentsId: () => 101 }); + Object.assign(secondWebview, { getWebContentsId: () => 202 }); + + firstWebview.dispatchEvent(new Event("did-attach")); + secondWebview.dispatchEvent(new Event("did-attach")); + + expect(firstWebview.getAttribute("partition")).toBe("persist:paseo-browser"); + expect(secondWebview.getAttribute("partition")).toBe("persist:paseo-browser"); + expect(attachedBrowsers).toEqual([ + { browserId: "browser-first", workspaceId: "workspace-a", webContentsId: 101 }, + { browserId: "browser-second", workspaceId: "workspace-b", webContentsId: 202 }, + ]); + }); + it("normalizes an existing resident host back to permanent parking", () => { const staleHost = document.createElement("div"); staleHost.id = RESIDENT_HOST_ID; @@ -91,8 +151,9 @@ describe("resident browser webviews", () => { staleHost.style.display = "none"; document.body.appendChild(staleHost); - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-stale-host", + workspaceId: "workspace-stale-host", url: "https://example.com", }); @@ -111,8 +172,9 @@ describe("resident browser webviews", () => { staleHost.style.display = "none"; const staleWebview = document.createElement("webview"); - prepareBrowserWebview(staleWebview, { + prepareTestBrowser(staleWebview, { browserId: "browser-stale-child", + workspaceId: "workspace-stale-child", initialUrl: "https://example.com", }); staleWebview.style.display = "none"; @@ -123,8 +185,9 @@ describe("resident browser webviews", () => { staleHost.appendChild(staleWebview); document.body.appendChild(staleHost); - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-stale-child", + workspaceId: "workspace-stale-child", url: "https://example.com/agent", }); @@ -135,12 +198,14 @@ describe("resident browser webviews", () => { }); it("parks resident webviews as an overlapping stack", () => { - const firstWebview = ensureResidentBrowserWebview({ + const firstWebview = ensureTestBrowser({ browserId: "browser-first", + workspaceId: "workspace-stack", url: "https://example.com/first", }); - const secondWebview = ensureResidentBrowserWebview({ + const secondWebview = ensureTestBrowser({ browserId: "browser-second", + workspaceId: "workspace-stack", url: "https://example.com/second", }); @@ -152,8 +217,9 @@ describe("resident browser webviews", () => { }); it("moves a resident webview into a visible pane without recreating the node", () => { - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-visible", + workspaceId: "workspace-visible", url: "https://example.com", }); @@ -170,15 +236,17 @@ describe("resident browser webviews", () => { it("returns an existing visible pane webview instead of creating a resident duplicate", () => { const visibleHost = document.createElement("div"); const visibleWebview = document.createElement("webview"); - prepareBrowserWebview(visibleWebview, { + prepareTestBrowser(visibleWebview, { browserId: "browser-visible-pane", + workspaceId: "workspace-visible-pane", initialUrl: "https://example.com", }); visibleHost.appendChild(visibleWebview); document.body.appendChild(visibleHost); - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-visible-pane", + workspaceId: "workspace-visible-pane", url: "https://example.com/agent", }); @@ -187,8 +255,9 @@ describe("resident browser webviews", () => { }); it("removes a resident webview when its browser tab closes", () => { - const webview = ensureResidentBrowserWebview({ + const webview = ensureTestBrowser({ browserId: "browser-closed", + workspaceId: "workspace-closed", url: "https://example.com", }); diff --git a/packages/app/src/components/browser-webview-resident.ts b/packages/app/src/components/browser-webview-resident.ts index 63bbed7de..7dfd7c701 100644 --- a/packages/app/src/components/browser-webview-resident.ts +++ b/packages/app/src/components/browser-webview-resident.ts @@ -1,3 +1,9 @@ +import { + getDesktopHost, + type DesktopAttachedBrowserRegistration, + type DesktopBrowserBridge, +} from "@/desktop/host"; + const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews"; const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id"; const RESIDENT_VIEWPORT_WIDTH = 1280; @@ -8,6 +14,62 @@ const residentWebviewSizesByBrowserId = new Map; +} + +function isAttachedBrowserBridge( + browser: DesktopBrowserBridge | undefined, +): browser is BrowserWebviewProfileHost { + return ( + browser !== undefined && + typeof browser.profilePartition === "string" && + browser.profilePartition.startsWith("persist:") && + typeof browser.registerAttachedBrowser === "function" + ); +} + +function getBrowserBridge(override?: BrowserWebviewProfileHost): BrowserWebviewProfileHost { + if (override) { + return override; + } + const browser = getDesktopHost()?.browser; + if (!isAttachedBrowserBridge(browser)) { + throw new Error("Electron browser profile bridge is unavailable"); + } + return browser; +} + +function registerBrowserWhenAttached( + webview: BrowserWebviewElement, + identity: BrowserWebviewIdentity, + browser: BrowserWebviewProfileHost, +): void { + webview.addEventListener( + "did-attach", + () => { + const webContentsId = webview.getWebContentsId(); + void browser + .registerAttachedBrowser({ + browserId: identity.browserId, + workspaceId: identity.workspaceId, + webContentsId, + }) + .catch((error) => { + console.error("[browser-webview] attached registration failed", error); + }); + }, + { once: true }, + ); } function trimNonEmpty(value: string | null | undefined): string | null { @@ -104,21 +166,30 @@ function clearResidentWebviewParkingStyle(webview: HTMLElement): void { export function prepareBrowserWebview( webview: HTMLElement, - input: { browserId: string; initialUrl?: string | null }, + input: { + browserId: string; + workspaceId: string; + initialUrl?: string | null; + profileHost?: BrowserWebviewProfileHost; + }, ): void { + const browser = getBrowserBridge(input.profileHost); webview.setAttribute(BROWSER_ID_ATTRIBUTE, input.browserId); - webview.setAttribute("partition", `persist:paseo-browser-${input.browserId}`); + webview.setAttribute("partition", browser.profilePartition); webview.setAttribute("allowpopups", "true"); webview.setAttribute("spellcheck", "false"); webview.setAttribute("autosize", "on"); if (input.initialUrl) { (webview as BrowserWebviewElement).src = input.initialUrl; } + registerBrowserWhenAttached(webview as BrowserWebviewElement, input, browser); } export function ensureResidentBrowserWebview(input: { browserId: string; + workspaceId: string; url: string; + profileHost?: BrowserWebviewProfileHost; }): HTMLElement | null { const browserId = trimNonEmpty(input.browserId); if (!browserId) { @@ -144,7 +215,12 @@ export function ensureResidentBrowserWebview(input: { } const webview = ownerDocument.createElement("webview") as BrowserWebviewElement; - prepareBrowserWebview(webview, { browserId, initialUrl: input.url }); + prepareBrowserWebview(webview, { + browserId, + workspaceId: input.workspaceId, + initialUrl: input.url, + profileHost: input.profileHost, + }); releaseResidentBrowserWebview(browserId, webview); return webview; } diff --git a/packages/app/src/desktop/components/browser-data-section.tsx b/packages/app/src/desktop/components/browser-data-section.tsx new file mode 100644 index 000000000..3d61f922e --- /dev/null +++ b/packages/app/src/desktop/components/browser-data-section.tsx @@ -0,0 +1,80 @@ +import { useCallback, useRef, useState } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/contexts/toast-context"; +import { getDesktopHost } from "@/desktop/host"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { useBrowserStore } from "@/stores/browser-store"; +import { settingsStyles } from "@/styles/settings"; +import { confirmDialog } from "@/utils/confirm-dialog"; + +export function BrowserDataSection() { + const { t } = useTranslation(); + const toast = useToast(); + const clearInFlightRef = useRef(false); + const [isClearing, setIsClearing] = useState(false); + + const handleClear = useCallback(async () => { + if (clearInFlightRef.current) { + return; + } + + clearInFlightRef.current = true; + setIsClearing(true); + try { + const confirmed = await confirmDialog({ + title: t("settings.general.browserData.confirmTitle"), + message: t("settings.general.browserData.confirmMessage"), + confirmLabel: t("settings.general.browserData.clear"), + cancelLabel: t("common.actions.cancel"), + destructive: true, + }); + if (!confirmed) { + return; + } + + const clearProfile = getDesktopHost()?.browser?.clearProfile; + if (!clearProfile) { + throw new Error("Electron browser profile bridge is unavailable"); + } + + await clearProfile(Object.keys(useBrowserStore.getState().browsersById)); + toast.show(t("settings.general.browserData.success"), { variant: "success" }); + } catch { + toast.error(t("settings.general.browserData.error")); + } finally { + clearInFlightRef.current = false; + setIsClearing(false); + } + }, [t, toast]); + const clearButtonLabel = isClearing + ? t("settings.general.browserData.clearing") + : t("settings.general.browserData.clear"); + + return ( + + + + + + {t("settings.general.browserData.siteData")} + + + {t("settings.general.browserData.description")} + + + + + + + ); +} diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 768552b92..88172579c 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -128,15 +128,22 @@ export interface DesktopBrowserNewTabRequestEvent { url: string; } +export interface DesktopAttachedBrowserRegistration { + browserId: string; + workspaceId: string; + webContentsId: number; +} + export interface DesktopBrowserBridge { - registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise; + readonly profilePartition?: string; + registerAttachedBrowser?: (input: DesktopAttachedBrowserRegistration) => Promise; unregisterWorkspaceBrowser?: (browserId: string) => Promise; setWorkspaceActiveBrowser?: (input: { workspaceId: string; browserId: string | null; }) => Promise; openDevTools?: (browserId: string) => Promise; - clearPartition?: (browserId: string) => Promise; + clearProfile?: (legacyBrowserIds: string[]) => Promise; executeAutomationCommand?: ( request: BrowserAutomationExecuteRequest, ) => Promise; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 253a43a00..db9cb8e70 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1488,6 +1488,17 @@ export const ar: TranslationResources = { }, general: { title: "عام", + browserData: { + title: "بيانات المتصفح", + siteData: "ملفات تعريف الارتباط وبيانات المواقع", + description: "تتشارك علامات تبويب المتصفح تسجيلات الدخول وبيانات المواقع عبر Paseo.", + clear: "مسح بيانات المتصفح", + clearing: "جارٍ المسح...", + confirmTitle: "هل تريد مسح بيانات المتصفح؟", + confirmMessage: "سيتم تسجيل خروجك من المواقع وإعادة تحميل علامات تبويب المتصفح المفتوحة.", + success: "تم مسح بيانات المتصفح.", + error: "تعذر مسح بيانات المتصفح.", + }, defaultSend: { label: "إرسال افتراضي", descriptions: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 46d55b50f..b1099e66f 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1496,6 +1496,17 @@ export const en = { }, general: { title: "General", + browserData: { + title: "Browser data", + siteData: "Cookies and site data", + description: "Browser tabs share sign-ins and site data across Paseo.", + clear: "Clear browser data", + clearing: "Clearing...", + confirmTitle: "Clear browser data?", + confirmMessage: "Sites will be signed out and open browser tabs will reload.", + success: "Browser data cleared.", + error: "Couldn't clear browser data.", + }, defaultSend: { label: "Default send", descriptions: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b5a9e1d3a..085435438 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1527,6 +1527,19 @@ export const es: TranslationResources = { }, general: { title: "General", + browserData: { + title: "Datos del navegador", + siteData: "Cookies y datos de sitios", + description: + "Las pestañas del navegador comparten inicios de sesión y datos de sitios en Paseo.", + clear: "Borrar datos del navegador", + clearing: "Borrando...", + confirmTitle: "¿Borrar los datos del navegador?", + confirmMessage: + "Se cerrarán las sesiones de los sitios y se recargarán las pestañas abiertas del navegador.", + success: "Datos del navegador borrados.", + error: "No se pudieron borrar los datos del navegador.", + }, defaultSend: { label: "Envío predeterminado", descriptions: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c2e5144b1..302d268c0 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1530,6 +1530,18 @@ export const fr: TranslationResources = { }, general: { title: "Général", + browserData: { + title: "Données du navigateur", + siteData: "Cookies et données des sites", + description: + "Les onglets du navigateur partagent les connexions et les données des sites dans Paseo.", + clear: "Effacer les données du navigateur", + clearing: "Effacement...", + confirmTitle: "Effacer les données du navigateur ?", + confirmMessage: "Vous serez déconnecté des sites et les onglets ouverts seront rechargés.", + success: "Données du navigateur effacées.", + error: "Impossible d'effacer les données du navigateur.", + }, defaultSend: { label: "Envoi par défaut", descriptions: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6a74a8a2c..a59d26e72 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1505,6 +1505,17 @@ export const ja: TranslationResources = { }, general: { title: "一般", + browserData: { + title: "ブラウザーデータ", + siteData: "Cookie とサイトデータ", + description: "ブラウザータブ間でログイン情報とサイトデータが共有されます。", + clear: "ブラウザーデータを消去", + clearing: "消去中...", + confirmTitle: "ブラウザーデータを消去しますか?", + confirmMessage: "サイトからログアウトし、開いているブラウザータブを再読み込みします。", + success: "ブラウザーデータを消去しました。", + error: "ブラウザーデータを消去できませんでした。", + }, defaultSend: { label: "デフォルトの送信", descriptions: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8832b1ff2..b5db0d657 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1513,6 +1513,18 @@ export const ptBR: TranslationResources = { }, general: { title: "Geral", + browserData: { + title: "Dados do navegador", + siteData: "Cookies e dados de sites", + description: "As abas do navegador compartilham logins e dados de sites no Paseo.", + clear: "Limpar dados do navegador", + clearing: "Limpando...", + confirmTitle: "Limpar dados do navegador?", + confirmMessage: + "Você será desconectado dos sites e as abas abertas do navegador serão recarregadas.", + success: "Dados do navegador limpos.", + error: "Não foi possível limpar os dados do navegador.", + }, defaultSend: { label: "Envio padrão", descriptions: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e895100d7..afa6d4c0a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1519,6 +1519,18 @@ export const ru: TranslationResources = { }, general: { title: "Общий", + browserData: { + title: "Данные браузера", + siteData: "Файлы cookie и данные сайтов", + description: "Вкладки браузера используют общие данные входа и данные сайтов в Paseo.", + clear: "Очистить данные браузера", + clearing: "Очистка...", + confirmTitle: "Очистить данные браузера?", + confirmMessage: + "На сайтах будет выполнен выход, а открытые вкладки браузера перезагрузятся.", + success: "Данные браузера очищены.", + error: "Не удалось очистить данные браузера.", + }, defaultSend: { label: "Отправка по умолчанию", descriptions: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 585049ca3..e947d3622 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1472,6 +1472,17 @@ export const zhCN: TranslationResources = { }, general: { title: "通用", + browserData: { + title: "浏览器数据", + siteData: "Cookie 和网站数据", + description: "浏览器标签页在 Paseo 中共享登录状态和网站数据。", + clear: "清除浏览器数据", + clearing: "正在清除...", + confirmTitle: "清除浏览器数据?", + confirmMessage: "网站帐号将退出登录,打开的浏览器标签页将重新加载。", + success: "浏览器数据已清除。", + error: "无法清除浏览器数据。", + }, defaultSend: { label: "默认发送", descriptions: { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 6267a4fa1..5b4cd584e 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -70,6 +70,7 @@ import { CommunityLinks } from "@/components/community-links"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; +import { BrowserDataSection } from "@/desktop/components/browser-data-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; @@ -1385,14 +1386,17 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti switch (view.section) { case "general": return ( - + <> + + {isDesktopApp ? : null} + ); case "appearance": return ; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 1ae7c0778..054ae4f2c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1970,7 +1970,7 @@ function WorkspaceScreenContent({ const { browserId } = input.target; useBrowserStore.getState().removeBrowser(browserId); removeResidentBrowserWebview(browserId); - void getDesktopHost()?.browser?.clearPartition?.(browserId); + void getDesktopHost()?.browser?.unregisterWorkspaceBrowser?.(browserId); } closeWorkspaceTab(persistenceKey, normalizedTabId); }, diff --git a/packages/desktop/capture-harness/index.html b/packages/desktop/capture-harness/index.html index 0efbfafc8..f603bda64 100644 --- a/packages/desktop/capture-harness/index.html +++ b/packages/desktop/capture-harness/index.html @@ -52,29 +52,70 @@ ? Math.max(0, requestedWebviewCount) : 2; const webviews = []; + const profileAttachPromises = []; const activeTokens = new Set(); let nextTokenId = 0; let permanentParkingState = params.get("permanentParkingState") || ""; - function appendHarnessWebview(sourceUrl) { + function createHarnessWebview({ sourceUrl, browserId, partition }) { const index = webviews.length; const webview = document.createElement("webview"); webview.id = `target-webview-${index + 1}`; webview.className = "capture-harness-webview"; - webview.setAttribute("data-paseo-browser-id", `capture-harness-${index + 1}`); - webview.setAttribute("partition", `persist:paseo-capture-harness-${index + 1}`); + webview.setAttribute("data-paseo-browser-id", browserId); + webview.setAttribute("partition", partition); webview.setAttribute("allowpopups", "true"); webview.setAttribute("spellcheck", "false"); webview.setAttribute("autosize", "on"); webview.src = sourceUrl; applyStackedWebviewStyle(webview); + return webview; + } + + function appendHarnessWebview(sourceUrl) { + const index = webviews.length; + const webview = createHarnessWebview({ + sourceUrl, + browserId: `capture-harness-${index + 1}`, + partition: `persist:paseo-capture-harness-${index + 1}`, + }); host.appendChild(webview); webviews.push(webview); return index; } - for (let index = 0; index < webviewCount; index += 1) { - appendHarnessWebview(params.get("targetUrl") || "bright.html"); + function appendProfileHarnessWebview({ browserId, partition, sourceUrl }) { + const webview = createHarnessWebview({ browserId, partition, sourceUrl }); + const attached = new Promise((resolve) => { + webview.addEventListener( + "did-attach", + () => resolve({ browserId, webContentsId: webview.getWebContentsId() }), + { once: true }, + ); + }); + host.appendChild(webview); + webviews.push(webview); + profileAttachPromises.push(attached); + return attached; + } + + const profilePartition = params.get("profilePartition"); + const profileBrowserIds = (params.get("profileBrowserIds") || "").split(",").filter(Boolean); + let profileAttachSequence = Promise.resolve(); + if (profilePartition && profileBrowserIds.length > 0) { + profileAttachSequence = (async () => { + for (const browserId of profileBrowserIds) { + await appendProfileHarnessWebview({ + browserId, + partition: profilePartition, + sourceUrl: params.get("targetUrl") || "bright.html", + }); + } + })(); + } else { + for (let index = 0; index < webviewCount; index += 1) { + appendHarnessWebview(params.get("targetUrl") || "bright.html"); + } } function applyHostParking() { @@ -318,6 +359,10 @@ addWebview(sourceUrl) { return appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html"); }, + async profileIdentities() { + await profileAttachSequence; + return await Promise.all(profileAttachPromises); + }, addPermanentWebview(sourceUrl, stateName) { const index = appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html"); applyPermanentParkingState(stateName || permanentParkingState); diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index e3e9cb54f..538273618 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -1,7 +1,8 @@ const fs = require("node:fs"); const fsp = require("node:fs/promises"); +const http = require("node:http"); const path = require("node:path"); -const { app, BrowserWindow, nativeImage, screen } = require("electron"); +const { app, BrowserWindow, nativeImage, screen, session } = require("electron"); const ROOT = __dirname; const OUT_DIR = process.env.PASEO_CAPTURE_HARNESS_OUT_DIR || path.join(ROOT, "out"); @@ -9,11 +10,15 @@ const VIEWPORT_WIDTH = 1280; const VIEWPORT_HEIGHT = 800; const FULL_PAGE_HEIGHT = 1600; const CAPTURE_TIMEOUT_MS = 5000; +const BROWSER_PROFILE_TIMEOUT_MS = 15000; const CAPTURE_RETRY_INTERVAL_MS = 200; const REPEAT_COUNT = 5; const FRESH_REPEAT_COUNT = 3; const SOAK_MS = Number(process.env.PASEO_CAPTURE_HARNESS_SOAK_MS || 75000); const HARNESS_GROUP = process.env.PASEO_CAPTURE_HARNESS_GROUP || "permanent-parking"; +const BROWSER_PROFILE_PHASE = process.env.PASEO_CAPTURE_HARNESS_PHASE || ""; +const BROWSER_PROFILE_ORIGIN_FILE = path.join(OUT_DIR, "browser-profile-origin.txt"); +const BROWSER_PROFILE_VALUE_FILE = path.join(OUT_DIR, "browser-profile-value.txt"); const PERMANENT_STATE_FILTER = new Set( (process.env.PASEO_CAPTURE_HARNESS_STATES || "P1") .split(",") @@ -121,6 +126,44 @@ function fileUrl(filePath, params = {}) { return url.toString(); } +async function startBrowserProfileServer() { + let port = 0; + if (BROWSER_PROFILE_PHASE === "read") { + const previousOrigin = (await fsp.readFile(BROWSER_PROFILE_ORIGIN_FILE, "utf8")).trim(); + port = Number(new URL(previousOrigin).port); + } + + const server = http.createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end("Shared browser profile

Profile fixture

"); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("browser profile fixture server has no TCP address"); + } + const origin = `http://127.0.0.1:${address.port}`; + if (BROWSER_PROFILE_PHASE === "write") { + await fsp.writeFile(BROWSER_PROFILE_ORIGIN_FILE, `${origin}\n`); + } + return { origin, server }; +} + +async function closeServer(server) { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + function ensureDirSync(dir) { fs.mkdirSync(dir, { recursive: true }); } @@ -160,12 +203,12 @@ async function waitForInactiveReveal(handle, label) { await delay(250); } -function withTimeout(promise, label) { +function withTimeout(promise, label, timeoutMs = CAPTURE_TIMEOUT_MS) { let timeoutId; const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => { - reject(new Error(`${label} timed out after ${CAPTURE_TIMEOUT_MS}ms`)); - }, CAPTURE_TIMEOUT_MS); + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); }); return Promise.race([promise, timeout]).finally(() => { clearTimeout(timeoutId); @@ -434,6 +477,7 @@ function installHarnessWebviewGuards(win) { function trackAttachedGuests(win, input = {}) { const attachedGuests = []; const waiters = []; + const countWaiters = []; win.webContents.on("did-attach-webview", (_event, contents) => { if (input.disableGuestBackgroundThrottlingAtAttach) { contents.setBackgroundThrottling(false); @@ -443,6 +487,13 @@ function trackAttachedGuests(win, input = {}) { if (waiter) { waiter(contents); } + for (let index = countWaiters.length - 1; index >= 0; index -= 1) { + const countWaiter = countWaiters[index]; + if (attachedGuests.length >= countWaiter.count) { + countWaiters.splice(index, 1); + countWaiter.resolve(attachedGuests.slice(0, countWaiter.count)); + } + } }); return { attachedGuests, @@ -451,6 +502,14 @@ function trackAttachedGuests(win, input = {}) { waiters.push(resolve); }); }, + waitForAttachedGuests(count) { + if (attachedGuests.length >= count) { + return Promise.resolve(attachedGuests.slice(0, count)); + } + return new Promise((resolve) => { + countWaiters.push({ count, resolve }); + }); + }, }; } @@ -1889,12 +1948,193 @@ function assertAutomationSnapshot(snapshot) { } } +async function createBrowserProfileHarnessWindow(partition, sourceUrl) { + const handle = createInactiveHarnessWindow({ + width: 640, + height: 480, + backgroundColor: "#202020", + webPreferences: { + webviewTag: true, + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + const { win } = handle; + installHarnessWebviewGuards(win); + const tracker = trackAttachedGuests(win); + const guestsPromise = tracker.waitForAttachedGuests(2); + await withTimeout( + win.loadFile(path.join(ROOT, "index.html"), { + query: { + webviewCount: "2", + targetUrl: sourceUrl, + profilePartition: partition, + profileBrowserIds: "browser-first,browser-second", + }, + }), + "browser profile window loadFile", + ); + await waitForInactiveReveal(handle, "browser profile window"); + const [guests, identities] = await withTimeout( + Promise.all([guestsPromise, renderer(win, "window.captureHarness.profileIdentities()")]), + "browser profile did-attach", + BROWSER_PROFILE_TIMEOUT_MS, + ); + return { handle, guests, identities }; +} + +async function readBrowserProfileFixture(guest) { + return await guest.executeJavaScript(`({ + cookie: document.cookie, + localStorage: localStorage.getItem("paseo-browser-profile") + })`); +} + +function assertBrowserProfileFixture(state, expectedValue, label) { + if (state.localStorage !== expectedValue) { + fail(`${label} localStorage mismatch ${JSON.stringify(state)}`); + } + if (!state.cookie.split("; ").includes(`paseo-browser-profile=${expectedValue}`)) { + fail(`${label} cookie mismatch ${JSON.stringify(state)}`); + } +} + +function resolveBrowserProfileGuests(profileWindow, profileSession) { + if (profileWindow.identities.length !== 2 || profileWindow.guests.length !== 2) { + fail("browser profile harness did not attach exactly two guests"); + } + const guestsById = new Map(profileWindow.guests.map((guest) => [guest.id, guest])); + const [firstIdentity, secondIdentity] = profileWindow.identities; + const firstGuest = guestsById.get(firstIdentity.webContentsId); + const secondGuest = guestsById.get(secondIdentity.webContentsId); + if (!firstGuest || !secondGuest) { + fail("browser profile renderer identities did not map to attached main-process guests"); + } + if ( + firstIdentity.browserId !== "browser-first" || + firstIdentity.webContentsId !== firstGuest.id + ) { + fail( + `browser profile first attach mismatch ${JSON.stringify(firstIdentity)} main=${firstGuest.id}`, + ); + } + if ( + secondIdentity.browserId !== "browser-second" || + secondIdentity.webContentsId !== secondGuest.id + ) { + fail( + `browser profile second attach mismatch ${JSON.stringify(secondIdentity)} main=${secondGuest.id}`, + ); + } + if ( + firstGuest.hostWebContents !== profileWindow.handle.win.webContents || + secondGuest.hostWebContents !== profileWindow.handle.win.webContents + ) { + fail("browser profile guests were not owned by their renderer"); + } + if (firstGuest.session !== profileSession || secondGuest.session !== profileSession) { + fail("browser profile guests did not share the persistent session"); + } + return [firstGuest, secondGuest]; +} + +async function prepareBrowserProfileValue(firstGuest, profileSession) { + if (BROWSER_PROFILE_PHASE === "read") { + return (await fsp.readFile(BROWSER_PROFILE_VALUE_FILE, "utf8")).trim(); + } + + const profileValue = `profile-${Date.now()}-${process.pid}`; + await firstGuest.executeJavaScript(`(() => { + const value = ${JSON.stringify(profileValue)}; + localStorage.setItem("paseo-browser-profile", value); + document.cookie = "paseo-browser-profile=" + value + "; Max-Age=86400; SameSite=Lax"; + })()`); + if (BROWSER_PROFILE_PHASE === "write") { + await fsp.writeFile(BROWSER_PROFILE_VALUE_FILE, `${profileValue}\n`); + await profileSession.cookies.flushStore(); + } + return profileValue; +} + +async function runBrowserProfileGroup() { + if (!["write", "read"].includes(BROWSER_PROFILE_PHASE)) { + fail(`unknown browser profile phase ${BROWSER_PROFILE_PHASE}`); + } + const partition = "persist:paseo-browser-profile-harness-restart"; + const profileSession = session.fromPartition(partition); + const fixture = await startBrowserProfileServer(); + const windows = []; + try { + if (BROWSER_PROFILE_PHASE === "write") { + await profileSession.clearStorageData(); + await profileSession.clearCache(); + } + const profileWindow = await createBrowserProfileHarnessWindow(partition, fixture.origin); + windows.push(profileWindow.handle); + const [firstGuest, secondGuest] = resolveBrowserProfileGuests(profileWindow, profileSession); + await Promise.all([waitForGuestLoad(firstGuest), waitForGuestLoad(secondGuest)]); + + const profileValue = await prepareBrowserProfileValue(firstGuest, profileSession); + + const firstState = await readBrowserProfileFixture(firstGuest); + const secondState = await readBrowserProfileFixture(secondGuest); + assertBrowserProfileFixture(firstState, profileValue, "browser profile first tab"); + assertBrowserProfileFixture(secondState, profileValue, "browser profile second tab"); + + pass("browser profile renderer did-attach identities match their main-process guests"); + pass("browser profile tabs share cookies, localStorage, and one persistent session"); + if (BROWSER_PROFILE_PHASE === "read") { + pass("browser profile cookies and localStorage survived an Electron process restart"); + } + const results = [ + { group: "browser-profile", check: "renderer-main-identity", pass: true }, + { group: "browser-profile", check: "shared-profile-data", pass: true }, + ]; + if (BROWSER_PROFILE_PHASE === "read") { + results.push({ + group: "browser-profile", + check: "process-restart-persistence", + pass: true, + }); + } + return results; + } finally { + for (const handle of windows) { + await closeHarnessWindow(handle.win); + } + if (BROWSER_PROFILE_PHASE !== "write") { + await profileSession.clearStorageData(); + await profileSession.clearCache(); + } + await closeServer(fixture.server); + } +} + async function main() { ensureDirSync(OUT_DIR); - if (!["all", "existing", "permanent-parking", "automation"].includes(HARNESS_GROUP)) { + if ( + !["all", "existing", "permanent-parking", "automation", "browser-profile"].includes( + HARNESS_GROUP, + ) + ) { fail(`unknown harness group ${HARNESS_GROUP}`); } + if (HARNESS_GROUP === "browser-profile") { + const browserProfileResults = await runBrowserProfileGroup(); + await fsp.writeFile( + path.join(OUT_DIR, "results.json"), + `${JSON.stringify( + { generatedAt: new Date().toISOString(), browserProfileResults }, + null, + 2, + )}\n`, + ); + pass(`capture harness browser-profile complete output=${OUT_DIR}`); + return; + } + if (HARNESS_GROUP === "automation") { const automationResults = await runAutomationGroup(); await fsp.writeFile( diff --git a/packages/desktop/capture-harness/run.sh b/packages/desktop/capture-harness/run.sh index fc1bf139b..f668ffc15 100755 --- a/packages/desktop/capture-harness/run.sh +++ b/packages/desktop/capture-harness/run.sh @@ -3,5 +3,14 @@ set -eu SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) +ELECTRON="$REPO_ROOT/node_modules/.bin/electron" -exec "$REPO_ROOT/node_modules/.bin/electron" "$SCRIPT_DIR/main.js" +if [ "${PASEO_CAPTURE_HARNESS_GROUP:-}" = "browser-profile" ] && [ -z "${PASEO_CAPTURE_HARNESS_PHASE:-}" ]; then + PASEO_CAPTURE_HARNESS_PHASE=write "$ELECTRON" "$SCRIPT_DIR/main.js" + # Give Chromium's profile helpers time to release the persistent session before reopening it. + sleep 1 + PASEO_CAPTURE_HARNESS_PHASE=read "$ELECTRON" "$SCRIPT_DIR/main.js" + exit +fi + +exec "$ELECTRON" "$SCRIPT_DIR/main.js" diff --git a/packages/desktop/src/features/browser-profile.test.ts b/packages/desktop/src/features/browser-profile.test.ts new file mode 100644 index 000000000..677708250 --- /dev/null +++ b/packages/desktop/src/features/browser-profile.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "vitest"; +import { + clearPaseoBrowserProfile, + getLegacyPaseoBrowserProfileSession, + getPaseoBrowserProfileSessions, + listPaseoBrowserProfileGuests, + readLegacyPaseoBrowserIds, +} from "./browser-profile.js"; + +class FakeProfileSession { + public readonly storageClears: unknown[] = []; + public cacheClears = 0; + public authClears = 0; + public storageClear: Promise = Promise.resolve(); + + public clearStorageData(options: unknown): Promise { + this.storageClears.push(options); + return this.storageClear; + } + + public clearCache(): Promise { + this.cacheClears += 1; + return Promise.resolve(); + } + + public clearAuthCache(): Promise { + this.authClears += 1; + return Promise.resolve(); + } +} + +class FakeLiveGuest { + public reloads = 0; + + public constructor( + public readonly id: number, + private readonly destroyed = false, + private readonly reloadError: Error | null = null, + ) {} + + public isDestroyed(): boolean { + return this.destroyed; + } + + public reload(): void { + if (this.reloadError) { + throw this.reloadError; + } + this.reloads += 1; + } +} + +class FakeWebContents extends FakeLiveGuest { + public constructor( + id: number, + public readonly session: object, + private readonly type: string, + destroyed = false, + ) { + super(id, destroyed); + } + + public getType(): string { + return this.type; + } +} + +describe("listPaseoBrowserProfileGuests", () => { + test("returns every live webview in the shared profile without deduplicating tabs", () => { + const profileSession = {}; + const firstWindowGuest = new FakeWebContents(1, profileSession, "webview"); + const secondWindowGuest = new FakeWebContents(2, profileSession, "webview"); + const foreignProfileGuest = new FakeWebContents(3, {}, "webview"); + const mainRenderer = new FakeWebContents(4, profileSession, "window"); + const destroyedGuest = new FakeWebContents(5, profileSession, "webview", true); + + const guests = listPaseoBrowserProfileGuests({ + profileSession, + webContents: [ + firstWindowGuest, + secondWindowGuest, + foreignProfileGuest, + mainRenderer, + destroyedGuest, + ], + }); + + expect(guests).toEqual([firstWindowGuest, secondWindowGuest]); + }); +}); + +describe("legacy browser profiles", () => { + test("accepts only unique saved browser ids and resolves their old partitions", () => { + const uuid = "123e4567-e89b-42d3-a456-426614174000"; + const fallbackId = "1700000000000-abcd"; + const browserIds = readLegacyPaseoBrowserIds([uuid, fallbackId, uuid, "not-a-browser-id", 123]); + const partitions: string[] = []; + const sessions = getPaseoBrowserProfileSessions( + { + fromPartition: (partition) => { + partitions.push(partition); + return new FakeProfileSession(); + }, + }, + browserIds, + ); + + expect(partitions).toEqual([ + "persist:paseo-browser", + `persist:paseo-browser-${uuid}`, + `persist:paseo-browser-${fallbackId}`, + ]); + expect(sessions).toHaveLength(3); + }); + + test("resolves one valid legacy profile for tab-close cleanup", () => { + const partitions: string[] = []; + const sessions = { + fromPartition: (partition: string) => { + partitions.push(partition); + return new FakeProfileSession(); + }, + }; + + expect(getLegacyPaseoBrowserProfileSession(sessions, "1700000000000-abcd")).not.toBeNull(); + expect(getLegacyPaseoBrowserProfileSession(sessions, "invalid")).toBeNull(); + expect(partitions).toEqual(["persist:paseo-browser-1700000000000-abcd"]); + }); +}); + +describe("clearPaseoBrowserProfile", () => { + test("clears site data, HTTP cache, and auth before reloading live guests", async () => { + const profile = new FakeProfileSession(); + const legacyProfile = new FakeProfileSession(); + let finishStorageClear: (() => void) | null = null; + profile.storageClear = new Promise((resolve) => { + finishStorageClear = resolve; + }); + const firstGuest = new FakeLiveGuest(1); + const secondGuest = new FakeLiveGuest(2); + + const clearing = clearPaseoBrowserProfile({ + profileSessions: [profile, legacyProfile], + listGuests: () => [firstGuest, secondGuest], + logReloadError: () => {}, + }); + + expect(firstGuest.reloads).toBe(0); + expect(secondGuest.reloads).toBe(0); + finishStorageClear?.(); + await clearing; + + expect(profile.storageClears).toEqual([ + { + storages: [ + "cookies", + "filesystem", + "indexdb", + "localstorage", + "serviceworkers", + "cachestorage", + "websql", + ], + }, + ]); + expect(profile.cacheClears).toBe(1); + expect(profile.authClears).toBe(1); + expect(legacyProfile.storageClears).toEqual(profile.storageClears); + expect(legacyProfile.cacheClears).toBe(1); + expect(legacyProfile.authClears).toBe(1); + expect(firstGuest.reloads).toBe(1); + expect(secondGuest.reloads).toBe(1); + }); + + test("skips destroyed guests and logs individual reload failures", async () => { + const profile = new FakeProfileSession(); + const destroyedGuest = new FakeLiveGuest(1, true); + const reloadError = new Error("guest disappeared"); + const failedGuest = new FakeLiveGuest(2, false, reloadError); + const reloadErrors: Array<{ guestId: number; error: unknown }> = []; + + await clearPaseoBrowserProfile({ + profileSessions: [profile], + listGuests: () => [destroyedGuest, failedGuest], + logReloadError: (guestId, error) => reloadErrors.push({ guestId, error }), + }); + + expect(destroyedGuest.reloads).toBe(0); + expect(failedGuest.reloads).toBe(0); + expect(reloadErrors).toEqual([{ guestId: 2, error: reloadError }]); + }); + + test("propagates clear failures without reloading guests", async () => { + const profile = new FakeProfileSession(); + const clearError = new Error("profile locked"); + profile.storageClear = Promise.reject(clearError); + const guest = new FakeLiveGuest(1); + + await expect( + clearPaseoBrowserProfile({ + profileSessions: [profile], + listGuests: () => [guest], + logReloadError: () => {}, + }), + ).rejects.toBe(clearError); + expect(guest.reloads).toBe(0); + }); +}); diff --git a/packages/desktop/src/features/browser-profile.ts b/packages/desktop/src/features/browser-profile.ts new file mode 100644 index 000000000..1824f48aa --- /dev/null +++ b/packages/desktop/src/features/browser-profile.ts @@ -0,0 +1,123 @@ +export const PASEO_BROWSER_PROFILE_PARTITION = "persist:paseo-browser"; +const LEGACY_BROWSER_ID_PATTERN = + /^(?:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|\d{13,}-[0-9a-f]+)$/i; +const MAX_LEGACY_BROWSER_PROFILES = 1000; + +const PASEO_BROWSER_STORAGE_TYPES = [ + "cookies", + "filesystem", + "indexdb", + "localstorage", + "serviceworkers", + "cachestorage", + "websql", +] as const; + +interface BrowserProfileSession { + clearStorageData(options: { + storages: Array<(typeof PASEO_BROWSER_STORAGE_TYPES)[number]>; + }): Promise; + clearCache(): Promise; + clearAuthCache(): Promise; +} + +interface BrowserProfileGuest { + readonly id: number; + isDestroyed(): boolean; + reload(): void; +} + +interface BrowserProfileWebContents extends BrowserProfileGuest { + readonly session: object; + getType(): string; +} + +interface ListBrowserProfileGuestsInput { + profileSession: object; + webContents: BrowserProfileWebContents[]; +} + +interface ClearBrowserProfileInput { + profileSessions: BrowserProfileSession[]; + listGuests(): BrowserProfileGuest[]; + logReloadError(guestId: number, error: unknown): void; +} + +interface ElectronSessions { + fromPartition(partition: string): BrowserProfileSession; +} + +export function getPaseoBrowserProfileSession(sessions: ElectronSessions): BrowserProfileSession { + return sessions.fromPartition(PASEO_BROWSER_PROFILE_PARTITION); +} + +export function readLegacyPaseoBrowserIds(input: unknown): string[] { + if (!Array.isArray(input)) { + return []; + } + const browserIds = new Set(); + for (const value of input) { + if (typeof value === "string" && LEGACY_BROWSER_ID_PATTERN.test(value)) { + browserIds.add(value); + if (browserIds.size >= MAX_LEGACY_BROWSER_PROFILES) { + break; + } + } + } + return [...browserIds]; +} + +export function getPaseoBrowserProfileSessions( + sessions: ElectronSessions, + legacyBrowserIds: string[], +): [BrowserProfileSession, ...BrowserProfileSession[]] { + return [ + getPaseoBrowserProfileSession(sessions), + // COMPAT(browserProfile): added in v0.1.108; remove after 2027-01-15. + ...legacyBrowserIds.map((browserId) => + sessions.fromPartition(`${PASEO_BROWSER_PROFILE_PARTITION}-${browserId}`), + ), + ]; +} + +export function getLegacyPaseoBrowserProfileSession( + sessions: ElectronSessions, + browserId: string, +): BrowserProfileSession | null { + const [legacyBrowserId] = readLegacyPaseoBrowserIds([browserId]); + return legacyBrowserId + ? sessions.fromPartition(`${PASEO_BROWSER_PROFILE_PARTITION}-${legacyBrowserId}`) + : null; +} + +export function listPaseoBrowserProfileGuests( + input: ListBrowserProfileGuestsInput, +): BrowserProfileGuest[] { + return input.webContents.filter( + (contents) => + !contents.isDestroyed() && + contents.getType() === "webview" && + contents.session === input.profileSession, + ); +} + +export async function clearPaseoBrowserProfile(input: ClearBrowserProfileInput): Promise { + await Promise.all( + input.profileSessions.flatMap((profileSession) => [ + profileSession.clearStorageData({ storages: [...PASEO_BROWSER_STORAGE_TYPES] }), + profileSession.clearCache(), + profileSession.clearAuthCache(), + ]), + ); + + for (const guest of input.listGuests()) { + if (guest.isDestroyed()) { + continue; + } + try { + guest.reload(); + } catch (error) { + input.logReloadError(guest.id, error); + } + } +} diff --git a/packages/desktop/src/features/browser-webviews/index.test.ts b/packages/desktop/src/features/browser-webviews/index.test.ts index bec113075..f9d0d55df 100644 --- a/packages/desktop/src/features/browser-webviews/index.test.ts +++ b/packages/desktop/src/features/browser-webviews/index.test.ts @@ -1,12 +1,31 @@ import { describe, expect, test } from "vitest"; -import { getPaseoBrowserIdForWebContents, registerPaseoBrowserWebContents } from "./index.js"; +import { PASEO_BROWSER_PROFILE_PARTITION } from "../browser-profile.js"; +import { + getPaseoBrowserIdForWebContents, + getPaseoBrowserWorkspaceId, + isPaseoBrowserWebviewAttach, + preparePaseoBrowserWebContents, + registerAttachedPaseoBrowser, +} from "./index.js"; -class FakeRegisteredWebContents { +class FakeRenderer { + public constructor(public readonly id: number) {} + + public isDestroyed(): boolean { + return false; + } +} + +class FakeBrowserGuest { public readonly backgroundThrottlingCalls: boolean[] = []; private destroyedListener: (() => void) | null = null; private destroyed = false; - public constructor(public readonly id: number) {} + public constructor( + public readonly id: number, + public readonly hostWebContents: FakeRenderer, + public readonly session: object, + ) {} public isDestroyed(): boolean { return this.destroyed; @@ -27,18 +46,133 @@ class FakeRegisteredWebContents { } } -describe("registerPaseoBrowserWebContents", () => { - test("disables guest background throttling once when the webview is registered", () => { - const contents = new FakeRegisteredWebContents(9001); +describe("browser webview attachment", () => { + test("accepts only allowed URLs on the shared profile partition", () => { + expect( + isPaseoBrowserWebviewAttach({ + src: "https://example.com", + partition: PASEO_BROWSER_PROFILE_PARTITION, + }), + ).toBe(true); + expect( + isPaseoBrowserWebviewAttach({ + src: "https://example.com", + partition: "persist:paseo-browser-tab-a", + }), + ).toBe(false); + expect( + isPaseoBrowserWebviewAttach({ src: "https://example.com", partition: "persist:foreign" }), + ).toBe(false); + }); - registerPaseoBrowserWebContents(contents, "browser-throttle"); + test("binds explicit browser identity to the renderer that hosts the guest", () => { + const profileSession = {}; + const renderer = new FakeRenderer(1); + const guest = new FakeBrowserGuest(101, renderer, profileSession); - expect(contents.backgroundThrottlingCalls).toEqual([false]); - expect(getPaseoBrowserIdForWebContents(contents)).toBe("browser-throttle"); + const registered = registerAttachedPaseoBrowser({ + browserId: "browser-a", + workspaceId: "workspace-a", + webContentsId: guest.id, + sender: renderer, + profileSession, + findWebContents: () => guest, + }); - contents.destroy(); + expect(registered).toBe(true); + expect(getPaseoBrowserIdForWebContents(guest)).toBe("browser-a"); + expect(getPaseoBrowserWorkspaceId("browser-a")).toBe("workspace-a"); + }); - expect(getPaseoBrowserIdForWebContents(contents)).toBeNull(); - expect(contents.backgroundThrottlingCalls).toEqual([false]); + test("rejects a guest hosted by another renderer", () => { + const profileSession = {}; + const owner = new FakeRenderer(1); + const claimant = new FakeRenderer(2); + const guest = new FakeBrowserGuest(201, owner, profileSession); + + const registered = registerAttachedPaseoBrowser({ + browserId: "browser-a", + workspaceId: "workspace-a", + webContentsId: guest.id, + sender: claimant, + profileSession, + findWebContents: () => guest, + }); + + expect(registered).toBe(false); + expect(getPaseoBrowserIdForWebContents(guest)).toBeNull(); + }); + + test("rejects a guest outside the shared profile", () => { + const profileSession = {}; + const renderer = new FakeRenderer(1); + const guest = new FakeBrowserGuest(301, renderer, {}); + + const registered = registerAttachedPaseoBrowser({ + browserId: "browser-a", + workspaceId: "workspace-a", + webContentsId: guest.id, + sender: renderer, + profileSession, + findWebContents: () => guest, + }); + + expect(registered).toBe(false); + expect(getPaseoBrowserIdForWebContents(guest)).toBeNull(); + }); + + test("concurrent windows cannot swap browser identities", () => { + const profileSession = {}; + const firstRenderer = new FakeRenderer(1); + const secondRenderer = new FakeRenderer(2); + const firstGuest = new FakeBrowserGuest(401, firstRenderer, profileSession); + const secondGuest = new FakeBrowserGuest(402, secondRenderer, profileSession); + const guests = new Map([ + [firstGuest.id, firstGuest], + [secondGuest.id, secondGuest], + ]); + + registerAttachedPaseoBrowser({ + browserId: "browser-second", + workspaceId: "workspace-second", + webContentsId: secondGuest.id, + sender: secondRenderer, + profileSession, + findWebContents: (id) => guests.get(id) ?? null, + }); + registerAttachedPaseoBrowser({ + browserId: "browser-first", + workspaceId: "workspace-first", + webContentsId: firstGuest.id, + sender: firstRenderer, + profileSession, + findWebContents: (id) => guests.get(id) ?? null, + }); + + expect(getPaseoBrowserIdForWebContents(firstGuest)).toBe("browser-first"); + expect(getPaseoBrowserIdForWebContents(secondGuest)).toBe("browser-second"); + }); + + test("prepares throttling once and removes registration when the guest is destroyed", () => { + const profileSession = {}; + const renderer = new FakeRenderer(1); + const guest = new FakeBrowserGuest(501, renderer, profileSession); + preparePaseoBrowserWebContents(guest); + registerAttachedPaseoBrowser({ + browserId: "browser-cleanup", + workspaceId: "workspace-cleanup", + webContentsId: guest.id, + sender: renderer, + profileSession, + findWebContents: () => guest, + }); + + expect(guest.backgroundThrottlingCalls).toEqual([false]); + expect(getPaseoBrowserIdForWebContents(guest)).toBe("browser-cleanup"); + + guest.destroy(); + + expect(getPaseoBrowserIdForWebContents(guest)).toBeNull(); + expect(guest.backgroundThrottlingCalls).toEqual([false]); }); }); diff --git a/packages/desktop/src/features/browser-webviews/index.ts b/packages/desktop/src/features/browser-webviews/index.ts index ff5464994..501ad4fe7 100644 --- a/packages/desktop/src/features/browser-webviews/index.ts +++ b/packages/desktop/src/features/browser-webviews/index.ts @@ -1,13 +1,18 @@ import { webContents as allWebContents, type WebContents } from "electron"; +import { PASEO_BROWSER_PROFILE_PARTITION } from "../browser-profile.js"; import { BROWSER_NEW_TAB_REQUEST_EVENT, handleBrowserWindowOpenRequest, isAllowedBrowserWebviewUrl, + PendingBrowserWindowOpenRequests, } from "./window-open.js"; -import { PaseoBrowserWebviewRegistry, type BrowserWorkspaceRegistration } from "./registry.js"; +import { PaseoBrowserWebviewRegistry } from "./registry.js"; -export { BROWSER_NEW_TAB_REQUEST_EVENT, handleBrowserWindowOpenRequest }; -export type { BrowserWorkspaceRegistration }; +export { + BROWSER_NEW_TAB_REQUEST_EVENT, + handleBrowserWindowOpenRequest, + PendingBrowserWindowOpenRequests, +}; const browserRegistry = new PaseoBrowserWebviewRegistry(); @@ -17,27 +22,28 @@ interface BrowserWebContentsIdentity { } interface RegisteredBrowserWebContents extends BrowserWebContentsIdentity { + readonly hostWebContents: BrowserWebContentsIdentity | null; + readonly session: object; setBackgroundThrottling(allowed: boolean): void; once(event: "destroyed", listener: () => void): void; } -function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null { - const prefix = "persist:paseo-browser-"; - if (!partition?.startsWith(prefix)) { - return null; - } - const browserId = partition.slice(prefix.length).trim(); - return browserId.length > 0 ? browserId : null; +interface AttachedBrowserRegistration { + browserId: string; + workspaceId: string; + webContentsId: number; } -export function readBrowserIdFromWebviewAttach(input: { - src?: string; - partition?: string; -}): string | null { - if (!isAllowedBrowserWebviewUrl(input.src)) { - return null; - } - return getBrowserIdFromWebviewPartition(input.partition); +interface RegisterAttachedBrowserInput extends AttachedBrowserRegistration { + sender: BrowserWebContentsIdentity; + profileSession: object; + findWebContents(webContentsId: number): RegisteredBrowserWebContents | null; +} + +export function isPaseoBrowserWebviewAttach(input: { src?: string; partition?: string }): boolean { + return ( + isAllowedBrowserWebviewUrl(input.src) && input.partition === PASEO_BROWSER_PROFILE_PARTITION + ); } export function listRegisteredPaseoBrowserIds(): string[] { @@ -46,17 +52,35 @@ export function listRegisteredPaseoBrowserIds(): string[] { .filter((browserId) => getPaseoBrowserWebContents(browserId)); } -export function registerPaseoBrowserWebContents( - contents: RegisteredBrowserWebContents, - browserId: string, -): void { +export function preparePaseoBrowserWebContents(contents: RegisteredBrowserWebContents): void { contents.setBackgroundThrottling(false); - browserRegistry.registerWebContents({ webContentsId: contents.id, browserId }); contents.once("destroyed", () => { browserRegistry.unregisterWebContents(contents.id); }); } +export function registerAttachedPaseoBrowser(input: RegisterAttachedBrowserInput): boolean { + const guest = input.findWebContents(input.webContentsId); + if ( + !guest || + guest.isDestroyed() || + guest.hostWebContents !== input.sender || + guest.session !== input.profileSession + ) { + return false; + } + + browserRegistry.registerWebContents({ + webContentsId: input.webContentsId, + browserId: input.browserId, + }); + browserRegistry.registerWorkspace({ + browserId: input.browserId, + workspaceId: input.workspaceId, + }); + return true; +} + export function getPaseoBrowserIdForWebContents( contents: BrowserWebContentsIdentity | null, ): string | null { @@ -66,10 +90,6 @@ export function getPaseoBrowserIdForWebContents( return browserRegistry.getBrowserIdForWebContents(contents.id); } -export function registerPaseoBrowserWorkspace(input: BrowserWorkspaceRegistration): void { - browserRegistry.registerWorkspace(input); -} - export function unregisterPaseoBrowser(browserId: string): void { browserRegistry.unregisterBrowser(browserId); } diff --git a/packages/desktop/src/features/browser-webviews/registry.test.ts b/packages/desktop/src/features/browser-webviews/registry.test.ts index 1120feba1..11896078b 100644 --- a/packages/desktop/src/features/browser-webviews/registry.test.ts +++ b/packages/desktop/src/features/browser-webviews/registry.test.ts @@ -26,4 +26,18 @@ describe("PaseoBrowserWebviewRegistry", () => { expect(registry.getWebContentsIdForBrowser("browser-a")).toBe(2); }); + + it("keeps one browser identity per webContents target", () => { + const registry = new PaseoBrowserWebviewRegistry(); + + registry.registerWebContents({ webContentsId: 1, browserId: "browser-a" }); + registry.registerWorkspace({ browserId: "browser-a", workspaceId: "workspace-a" }); + registry.setWorkspaceActiveBrowser({ workspaceId: "workspace-a", browserId: "browser-a" }); + registry.registerWebContents({ webContentsId: 1, browserId: "browser-b" }); + + expect(registry.getWebContentsIdForBrowser("browser-a")).toBeNull(); + expect(registry.getWorkspaceId("browser-a")).toBeNull(); + expect(registry.getWorkspaceActiveBrowserId("workspace-a")).toBeNull(); + expect(registry.getBrowserIdForWebContents(1)).toBe("browser-b"); + }); }); diff --git a/packages/desktop/src/features/browser-webviews/registry.ts b/packages/desktop/src/features/browser-webviews/registry.ts index cdadd643c..6d113f479 100644 --- a/packages/desktop/src/features/browser-webviews/registry.ts +++ b/packages/desktop/src/features/browser-webviews/registry.ts @@ -10,6 +10,17 @@ export class PaseoBrowserWebviewRegistry { private readonly activeBrowserIdsByWorkspaceId = new Map(); public registerWebContents(input: { webContentsId: number; browserId: string }): void { + const previousBrowserId = this.browserIdsByWebContentsId.get(input.webContentsId) ?? null; + if ( + previousBrowserId !== null && + previousBrowserId !== input.browserId && + this.webContentsIdsByBrowserId.get(previousBrowserId) === input.webContentsId + ) { + this.webContentsIdsByBrowserId.delete(previousBrowserId); + this.workspaceIdsByBrowserId.delete(previousBrowserId); + this.deleteActiveBrowserReferences(previousBrowserId); + } + const previousWebContentsId = this.webContentsIdsByBrowserId.get(input.browserId) ?? null; if (previousWebContentsId !== null && previousWebContentsId !== input.webContentsId) { this.browserIdsByWebContentsId.delete(previousWebContentsId); diff --git a/packages/desktop/src/features/browser-webviews/window-open.test.ts b/packages/desktop/src/features/browser-webviews/window-open.test.ts index 9417c4e68..e40d490ad 100644 --- a/packages/desktop/src/features/browser-webviews/window-open.test.ts +++ b/packages/desktop/src/features/browser-webviews/window-open.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { handleBrowserWindowOpenRequest } from "."; +import { handleBrowserWindowOpenRequest, PendingBrowserWindowOpenRequests } from "."; describe("browser webview window-open requests", () => { it("denies Electron window creation and requests a Paseo browser tab", () => { @@ -32,3 +32,23 @@ describe("browser webview window-open requests", () => { expect(requestNewTab).not.toHaveBeenCalled(); }); }); + +describe("pending browser window-open requests", () => { + it("holds early allowed popups until browser identity registration", () => { + const pending = new PendingBrowserWindowOpenRequests(); + pending.add(101, "https://example.com/first"); + pending.add(101, "file:///etc/passwd"); + pending.add(101, "https://example.com/second"); + + expect(pending.take(101)).toEqual(["https://example.com/first", "https://example.com/second"]); + expect(pending.take(101)).toEqual([]); + }); + + it("drops pending popups when an unregistered guest is destroyed", () => { + const pending = new PendingBrowserWindowOpenRequests(); + pending.add(202, "https://example.com/target"); + pending.delete(202); + + expect(pending.take(202)).toEqual([]); + }); +}); diff --git a/packages/desktop/src/features/browser-webviews/window-open.ts b/packages/desktop/src/features/browser-webviews/window-open.ts index 18535722a..f535abb1b 100644 --- a/packages/desktop/src/features/browser-webviews/window-open.ts +++ b/packages/desktop/src/features/browser-webviews/window-open.ts @@ -5,6 +5,34 @@ export interface BrowserNewTabRequestPayload { url: string; } +const MAX_PENDING_WINDOW_OPEN_REQUESTS_PER_GUEST = 20; + +export class PendingBrowserWindowOpenRequests { + private readonly urlsByWebContentsId = new Map(); + + public add(webContentsId: number, url: string): void { + if (!isAllowedBrowserWebviewUrl(url)) { + return; + } + const urls = this.urlsByWebContentsId.get(webContentsId) ?? []; + if (urls.length >= MAX_PENDING_WINDOW_OPEN_REQUESTS_PER_GUEST) { + return; + } + urls.push(url); + this.urlsByWebContentsId.set(webContentsId, urls); + } + + public take(webContentsId: number): string[] { + const urls = this.urlsByWebContentsId.get(webContentsId) ?? []; + this.urlsByWebContentsId.delete(webContentsId); + return urls; + } + + public delete(webContentsId: number): void { + this.urlsByWebContentsId.delete(webContentsId); + } +} + export function isAllowedBrowserWebviewUrl(value: string | undefined): boolean { if (!value) { return true; diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index fd4ffb7fd..28bd4fbe9 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -21,6 +21,7 @@ import { protocol, screen, session, + webContents, } from "electron"; import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js"; import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js"; @@ -52,13 +53,22 @@ import { getPaseoBrowserWebContents, handleBrowserWindowOpenRequest, listRegisteredPaseoBrowserIds, - readBrowserIdFromWebviewAttach, + isPaseoBrowserWebviewAttach, + preparePaseoBrowserWebContents, + PendingBrowserWindowOpenRequests, registerBrowserWebviewNavigationGuards, unregisterPaseoBrowser, - registerPaseoBrowserWorkspace, - registerPaseoBrowserWebContents, + registerAttachedPaseoBrowser, setWorkspaceActivePaseoBrowserId, } from "./features/browser-webviews/index.js"; +import { + clearPaseoBrowserProfile, + getLegacyPaseoBrowserProfileSession, + getPaseoBrowserProfileSession, + getPaseoBrowserProfileSessions, + listPaseoBrowserProfileGuests, + readLegacyPaseoBrowserIds, +} from "./features/browser-profile.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; import { PendingOpenProjectStore } from "./pending-open-project-store.js"; import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js"; @@ -80,6 +90,7 @@ const APP_SCHEME = "paseo"; const PASEO_DEBUG = process.env.PASEO_DEBUG === "1"; const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1"; const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo"; +const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests(); const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut"; const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key"; @@ -114,9 +125,13 @@ const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE"; const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop"; app.setName(APP_NAME); -function readBrowserWorkspaceInput( - input: unknown, -): { browserId: string; workspaceId: string } | null { +interface AttachedBrowserInput { + browserId: string; + workspaceId: string; + webContentsId: number; +} + +function readAttachedBrowserInput(input: unknown): AttachedBrowserInput | null { if (typeof input !== "object" || input === null || Array.isArray(input)) { return null; } @@ -127,7 +142,18 @@ function readBrowserWorkspaceInput( if (typeof record.workspaceId !== "string" || record.workspaceId.trim().length === 0) { return null; } - return { browserId: record.browserId.trim(), workspaceId: record.workspaceId.trim() }; + if ( + typeof record.webContentsId !== "number" || + !Number.isInteger(record.webContentsId) || + record.webContentsId <= 0 + ) { + return null; + } + return { + browserId: record.browserId.trim(), + workspaceId: record.workspaceId.trim(), + webContentsId: record.webContentsId, + }; } function readActiveBrowserInput( @@ -144,8 +170,6 @@ function readActiveBrowserInput( return { workspaceId: record.workspaceId.trim(), browserId: browserId || null }; } -const pendingBrowserWebviewIds: string[] = []; - function isBrowserRefreshInput(input: Electron.Input): boolean { if (input.type !== "keyDown" || input.alt || input.shift) { return false; @@ -322,16 +346,53 @@ function normalizeBrowserCaptureRect( }; } -ipcMain.handle("paseo:browser:register-workspace-browser", (_event, rawInput: unknown) => { - const input = readBrowserWorkspaceInput(rawInput); - if (input) { - registerPaseoBrowserWorkspace(input); +ipcMain.handle("paseo:browser:register-attached", (event, rawInput: unknown) => { + const input = readAttachedBrowserInput(rawInput); + if (!input) { + throw new Error("Invalid attached browser registration"); + } + const registered = registerAttachedPaseoBrowser({ + ...input, + sender: event.sender, + profileSession: getPaseoBrowserProfileSession(session), + findWebContents: (webContentsId) => webContents.fromId(webContentsId) ?? null, + }); + if (!registered) { + throw new Error("Attached browser registration was rejected"); + } + log.info("[browser-webview] registered", { + browserId: input.browserId, + webContentsId: input.webContentsId, + registeredBrowserIds: listRegisteredPaseoBrowserIds(), + }); + for (const url of pendingBrowserWindowOpenRequests.take(input.webContentsId)) { + event.sender.send(BROWSER_NEW_TAB_REQUEST_EVENT, { + sourceBrowserId: input.browserId, + url, + }); } }); -ipcMain.handle("paseo:browser:unregister-workspace-browser", (_event, browserId: unknown) => { +ipcMain.handle("paseo:browser:unregister-workspace-browser", async (_event, browserId: unknown) => { if (typeof browserId === "string" && browserId.trim().length > 0) { - unregisterPaseoBrowser(browserId.trim()); + const normalizedBrowserId = browserId.trim(); + unregisterPaseoBrowser(normalizedBrowserId); + // COMPAT(browserProfile): added in v0.1.108; remove after 2027-01-15. + const legacyProfile = getLegacyPaseoBrowserProfileSession(session, normalizedBrowserId); + if (legacyProfile) { + try { + await clearPaseoBrowserProfile({ + profileSessions: [legacyProfile], + listGuests: () => [], + logReloadError: () => {}, + }); + } catch (error) { + log.warn("[browser-profile] failed to clear legacy tab profile", { + browserId: normalizedBrowserId, + error, + }); + } + } } }); @@ -383,12 +444,23 @@ ipcMain.handle("paseo:browser:open-devtools", (_event, browserId: unknown) => { return result; }); -ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknown) => { - if (typeof browserId !== "string" || browserId.trim().length === 0) { - return; - } - const partition = `persist:paseo-browser-${browserId}`; - await session.fromPartition(partition).clearStorageData(); +ipcMain.handle("paseo:browser:clear-profile", async (_event, rawLegacyBrowserIds: unknown) => { + const profileSessions = getPaseoBrowserProfileSessions( + session, + readLegacyPaseoBrowserIds(rawLegacyBrowserIds), + ); + const profileSession = profileSessions[0]; + await clearPaseoBrowserProfile({ + profileSessions, + listGuests: () => + listPaseoBrowserProfileGuests({ + profileSession, + webContents: webContents.getAllWebContents(), + }), + logReloadError: (webContentsId, error) => { + log.warn("[browser-profile] failed to reload guest", { webContentsId, error }); + }, + }); }); ipcMain.handle( @@ -599,12 +671,10 @@ async function createWindow( setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => { - const browserId = readBrowserIdFromWebviewAttach(params); - if (!browserId) { + if (!isPaseoBrowserWebviewAttach(params)) { event.preventDefault(); return; } - pendingBrowserWebviewIds.push(browserId); webPreferences.nodeIntegration = false; webPreferences.nodeIntegrationInSubFrames = false; webPreferences.nodeIntegrationInWorker = false; @@ -619,15 +689,10 @@ async function createWindow( delete (params as { preloadURL?: string }).preloadURL; }); mainWindow.webContents.on("did-attach-webview", (_event, contents) => { - const browserId = pendingBrowserWebviewIds.shift() ?? null; - if (browserId) { - registerPaseoBrowserWebContents(contents, browserId); - log.info("[browser-webview] registered", { - browserId, - webContentsId: contents.id, - registeredBrowserIds: listRegisteredPaseoBrowserIds(), - }); - } + preparePaseoBrowserWebContents(contents); + contents.once("destroyed", () => { + pendingBrowserWindowOpenRequests.delete(contents.id); + }); contents.on("before-input-event", (event, input) => { if (isBrowserRefreshInput(input)) { event.preventDefault(); @@ -659,15 +724,19 @@ async function createWindow( }); } }); - contents.setWindowOpenHandler(({ url }) => - handleBrowserWindowOpenRequest({ + contents.setWindowOpenHandler(({ url }) => { + const sourceBrowserId = getPaseoBrowserIdForWebContents(contents); + if (!sourceBrowserId) { + pendingBrowserWindowOpenRequests.add(contents.id, url); + } + return handleBrowserWindowOpenRequest({ url, - sourceBrowserId: getPaseoBrowserIdForWebContents(contents), + sourceBrowserId, requestNewTab: (payload) => { mainWindow.webContents.send(BROWSER_NEW_TAB_REQUEST_EVENT, payload); }, - }), - ); + }); + }); contents.on("context-menu", (_contextMenuEvent, params) => { showBrowserWebviewContextMenu(mainWindow, contents, params); }); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index e109b9186..e01a38e66 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,7 +1,14 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; +import { PASEO_BROWSER_PROFILE_PARTITION } from "./features/browser-profile.js"; type EventHandler = (payload: unknown) => void; +interface AttachedBrowserRegistration { + browserId: string; + workspaceId: string; + webContentsId: number; +} + contextBridge.exposeInMainWorld("paseoDesktop", { platform: process.platform, invoke: (command: string, args?: Record) => @@ -78,16 +85,17 @@ contextBridge.exposeInMainWorld("paseoDesktop", { ipcRenderer.invoke("paseo:menu:set-capturing-shortcut", capturing), }, browser: { - registerWorkspaceBrowser: (input: { browserId: string; workspaceId: string }) => - ipcRenderer.invoke("paseo:browser:register-workspace-browser", input), + profilePartition: PASEO_BROWSER_PROFILE_PARTITION, + registerAttachedBrowser: (input: AttachedBrowserRegistration) => + ipcRenderer.invoke("paseo:browser:register-attached", input), unregisterWorkspaceBrowser: (browserId: string) => ipcRenderer.invoke("paseo:browser:unregister-workspace-browser", browserId), setWorkspaceActiveBrowser: (input: { workspaceId: string; browserId: string | null }) => ipcRenderer.invoke("paseo:browser:set-workspace-active-browser", input), openDevTools: (browserId: string) => ipcRenderer.invoke("paseo:browser:open-devtools", browserId), - clearPartition: (browserId: string) => - ipcRenderer.invoke("paseo:browser:clear-partition", browserId), + clearProfile: (legacyBrowserIds: string[]) => + ipcRenderer.invoke("paseo:browser:clear-profile", legacyBrowserIds), executeAutomationCommand: (request: Record) => ipcRenderer.invoke("paseo:browser:execute-automation-command", request), captureElement: ( From 13e92f8a3075ef815da143573c5f6a78cd2458f9 Mon Sep 17 00:00:00 2001 From: 1254087415 <53247943+1254087415@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:42:10 +0800 Subject: [PATCH 19/20] fix(server): create autonomous turns for spontaneous ACP session updates (#2058) * fix(server): create autonomous turns for spontaneous ACP session updates When an ACP-based provider handles background-agent completion notifications, the resulting spontaneous session/update messages have no active foreground turn to attach to. Mirror the Claude provider's autonomous-turn mechanism so these updates are recorded in Paseo's timeline. - Start an autonomous turn when a sessionUpdate arrives with no active foreground turn - Tag timeline events with the autonomous turn id - Complete the autonomous turn after a timeout, or before a new foreground turn starts * Fix ACP autonomous event scoping --------- Co-authored-by: zab Co-authored-by: Mohamed Boudra --- .../server/agent/providers/acp-agent.test.ts | 87 +++++++++++++++++++ .../src/server/agent/providers/acp-agent.ts | 56 ++++++++++-- 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 546ff8c80..828b1b4de 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -2208,6 +2208,93 @@ describe("ACPAgentSession", () => { expect(assistantMessages[2].messageId).not.toBe(assistantMessages[0].messageId); }); + test("starts an autonomous turn for spontaneous session updates outside a foreground turn", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const session = createSession(); + asInternals(session).sessionId = "session-1"; + + const events: AgentStreamEvent[] = []; + session.subscribe((event) => { + events.push(event); + }); + + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "autonomous-msg", + content: { type: "text", text: "Autonomous update" }, + } as SessionUpdate, + }); + + // Should emit turn_started before the timeline item. + const turnStartedIndex = events.findIndex((e) => e.type === "turn_started"); + expect(turnStartedIndex).toBeGreaterThanOrEqual(0); + const timelineIndex = events.findIndex( + (e) => + e.type === "timeline" && + e.item.type === "assistant_message" && + e.item.text === "Autonomous update", + ); + expect(timelineIndex).toBeGreaterThan(turnStartedIndex); + + const turnStarted = events[turnStartedIndex]; + expect(turnStarted.type).toBe("turn_started"); + const autonomousTurnId = (turnStarted as { turnId?: string }).turnId; + expect(autonomousTurnId).toEqual(expect.any(String)); + + // Timeline item should be tagged with the autonomous turn id. + const timelineEvent = events[timelineIndex]; + expect(timelineEvent.type).toBe("timeline"); + expect((timelineEvent as { turnId?: string }).turnId).toBe(autonomousTurnId); + + // Advance timers to complete the autonomous turn. + await vi.advanceTimersByTimeAsync(ACPAgentSession["AUTONOMOUS_TURN_TIMEOUT_MS"] + 10); + const turnCompleted = events.find((e) => e.type === "turn_completed"); + expect(turnCompleted).toBeDefined(); + expect((turnCompleted as { turnId?: string }).turnId).toBe(autonomousTurnId); + + vi.useRealTimers(); + }); + + test("completes an existing autonomous turn before starting a foreground turn", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const session = createSession(); + asInternals(session).sessionId = "session-1"; + asInternals(session).connection = { + prompt: vi.fn(() => new Promise(() => {})), + }; + + const events: AgentStreamEvent[] = []; + session.subscribe((event) => { + events.push(event); + }); + + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "autonomous-msg", + content: { type: "text", text: "Autonomous update" }, + } as SessionUpdate, + }); + + const turnStarted = events.find((e) => e.type === "turn_started"); + expect(turnStarted).toBeDefined(); + const autonomousTurnId = (turnStarted as { turnId?: string }).turnId; + + // Starting a foreground turn should complete the autonomous turn first. + void session.startTurn("user prompt"); + await vi.runOnlyPendingTimersAsync(); + + const turnCompleted = events.find( + (e) => e.type === "turn_completed" && (e as { turnId?: string }).turnId === autonomousTurnId, + ); + expect(turnCompleted).toBeDefined(); + + vi.useRealTimers(); + }); + test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => { const session = createSession(); const events: Array<{ type: string; turnId?: string }> = []; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 6da37dad6..51e24c669 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1331,6 +1331,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly extensionCommandsParser?: ACPExtensionCommandsParser; private currentTurnUsage: AgentUsage | undefined; private activeForegroundTurnId: string | null = null; + private autonomousTurnId: string | null = null; + private autonomousTurnTimer: ReturnType | null = null; + private static readonly AUTONOMOUS_TURN_TIMEOUT_MS = 30_000; private fallbackAssistantMessageId: string | null = null; private closed = false; private historyPending = false; @@ -1472,6 +1475,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (this.activeForegroundTurnId) { throw new Error("A foreground turn is already active"); } + this.completeAutonomousTurn(); const turnId = randomUUID(); const messageId = options?.messageId ?? randomUUID(); @@ -2115,7 +2119,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { agentId: this.agentId, provider: this.provider, sessionId: this.sessionId, - turnId: this.activeForegroundTurnId ?? undefined, + turnId: this.activeForegroundTurnId ?? this.autonomousTurnId ?? undefined, rawEvent: params, events, }, @@ -2130,6 +2134,11 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } + if (events.length > 0 && !this.activeForegroundTurnId) { + this.startAutonomousTurn(); + this.resetAutonomousTurnTimer(); + } + for (const event of events) { this.pushEvent(event); } @@ -2663,23 +2672,25 @@ export class ACPAgentSession implements AgentSession, ACPClient { type: "timeline", provider: this.provider, item, - turnId: this.activeForegroundTurnId ?? undefined, + turnId: this.activeForegroundTurnId ?? this.autonomousTurnId ?? undefined, }; } private pushEvent(event: AgentStreamEvent): void { + const turnId = this.activeForegroundTurnId ?? this.autonomousTurnId; + const tagged = event.type === "timeline" && turnId ? { ...event, turnId } : event; this.logger.trace( { agentId: this.agentId, provider: this.provider, sessionId: this.sessionId, - turnId: getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? undefined, - event, + turnId: getAgentStreamEventTurnId(tagged) ?? turnId ?? undefined, + event: tagged, }, "provider.acp.event_emit", ); for (const subscriber of this.subscribers) { - subscriber(event); + subscriber(tagged); } } @@ -2727,6 +2738,41 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.pushEvent(event); } + private startAutonomousTurn(): void { + if (this.autonomousTurnId) { + return; + } + this.autonomousTurnId = randomUUID(); + this.pushEvent({ + type: "turn_started", + provider: this.provider, + turnId: this.autonomousTurnId, + }); + } + + private completeAutonomousTurn(): void { + if (!this.autonomousTurnId) { + return; + } + if (this.autonomousTurnTimer) { + clearTimeout(this.autonomousTurnTimer); + this.autonomousTurnTimer = null; + } + const turnId = this.autonomousTurnId; + this.autonomousTurnId = null; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + } + + private resetAutonomousTurnTimer(): void { + if (this.autonomousTurnTimer) { + clearTimeout(this.autonomousTurnTimer); + } + this.autonomousTurnTimer = setTimeout(() => { + this.completeAutonomousTurn(); + }, ACPAgentSession.AUTONOMOUS_TURN_TIMEOUT_MS); + this.autonomousTurnTimer.unref?.(); + } + private isSubmittedUserMessageEcho( item: Extract, ): boolean { From 6804882761fcb9a511338f1fed19b9ed45e99e45 Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Wed, 15 Jul 2026 18:44:17 +0200 Subject: [PATCH 20/20] Document iOS/Android local dev setup and fix the mise Android toolchain (#2092) * Pin android-sdk to 21.0 and unify Java on 21 for mise The `.tool-versions` `android-sdk latest` pin had resolved to the ancient `1.0` cmdline-tools bundle, whose sdkmanager (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. Pin `android-sdk 21.0`, point the hardcoded install paths in `.mise.toml` at it, and add `cmdline-tools/21.0/bin` to PATH so sdkmanager and avdmanager resolve. Also unify the Java version on 21: `.tool-versions` already pinned 21, but `.mise.toml` overrode it to 17. A local `npm run android` Gradle build succeeds on 21. Co-Authored-By: Claude Opus 4.8 * Document iOS simulator and Android emulator local dev setup Add local-dev docs for running the app against a worktree daemon: - development.md: prerequisites for the ios-simulator preview service (Xcode, an iOS runtime, automatic CocoaPods install), the xcode-select/simctl troubleshooting fix, and a "Running the iOS app on a local simulator" section covering `npm run ios` and pointing the app at a worktree daemon via EXPO_PUBLIC_LOCAL_DAEMON. - android.md: a "Prerequisites (local dev)" section (mise java 21 + android-sdk 21.0, sdkmanager components, AVD creation) and a "Running on an emulator against a worktree daemon" section covering REACT_NATIVE_PACKAGER_HOSTNAME and EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2 for reaching the host daemon. Co-Authored-By: Claude Opus 4.8 * Give full Intel x86_64 emulator setup commands in Android docs Greptile flagged that the arm64-v8a -> x86_64 substitution for Intel Macs was only a parenthetical. Provide a complete, copy-pasteable x86_64 command block instead of asking readers to hand-edit the arm64 one. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .mise.toml | 9 +++---- .tool-versions | 2 +- docs/android.md | 57 +++++++++++++++++++++++++++++++++++++++++++++ docs/development.md | 33 +++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/.mise.toml b/.mise.toml index 554d085a5..7c1a0bad9 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,9 +1,10 @@ [env] -ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0" +ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0" _.path = [ - "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools", - "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator", + "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin", + "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools", + "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator", ] [tools] -java = "17" +java = "21" diff --git a/.tool-versions b/.tool-versions index 82356b6df..7dfc8d848 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,4 @@ rust 1.85.1 nodejs 22.20.0 java 21 -android-sdk latest +android-sdk 21.0 diff --git a/docs/android.md b/docs/android.md index 9ecc66063..ec75faa7c 100644 --- a/docs/android.md +++ b/docs/android.md @@ -25,6 +25,38 @@ Prerelease metadata is ignored, so `0.1.102-beta.1` and `0.1.102` both produce ` The formula reserves three digits each for minor and patch. If either reaches `1000`, change the formula before cutting that release. +## Prerequisites (local dev) + +Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev): + +```bash +mise install # java 21 + android-sdk 21.0 command-line tools +``` + +> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`. + +`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon: + +```bash +sdkmanager --licenses +sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \ + "system-images;android-35;google_apis;arm64-v8a" +avdmanager create avd -n paseo -k "system-images;android-35;google_apis;arm64-v8a" -d pixel_7 +emulator @paseo # start it; leave running +``` + +On an Intel Mac, use the `x86_64` system image: + +```bash +sdkmanager --licenses +sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \ + "system-images;android-35;google_apis;x86_64" +avdmanager create avd -n paseo -k "system-images;android-35;google_apis;x86_64" -d pixel_7 +emulator @paseo # start it; leave running +``` + +Gradle auto-fetches the platform/build-tools it needs once licenses are accepted, so adjust `android-35` only if it asks for a different level. + ## Local build + install From repo root: @@ -50,6 +82,31 @@ npx cross-env APP_VARIANT=production expo run:android --variant=release rm -rf android ``` +## Running on an emulator against a worktree daemon + +`npm run android` builds and installs the dev client, but two connections have to reach your Mac from inside the emulator — Metro (the JS bundle) and the Paseo daemon — and **the emulator does not share the host's loopback**: `localhost` inside the emulator is the emulator itself. Reach the host at `10.0.2.2` (the standard AVD's host alias) for both: + +```bash +REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 \ + EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:$PASEO_SERVICE_DAEMON_PORT \ + npm run android +``` + +- **`REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2`** — without it, Expo bakes your Mac's LAN IP into the dev client's Metro URL, which the emulator can't route to, and the app dies with `Failed to connect to /:8081` before any JS loads. +- **`EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:`** — the client's daemon endpoint (`packages/app/src/runtime/host-runtime.ts`); when unset it defaults to `localhost:6767`, the production daemon. Use `$PASEO_SERVICE_DAEMON_PORT` for a worktree daemon running as a Paseo service, or `6768` for a standalone `npm run dev:server`. It is inlined into the JS bundle at Metro bundle time, so set it on the build command and clear the Metro cache (`npx expo start -c`) if a change doesn't take. + +**Alternative — `adb reverse` + `localhost`** (if `10.0.2.2` misbehaves): + +```bash +adb reverse tcp:8081 tcp:8081 +adb reverse tcp:$PASEO_SERVICE_DAEMON_PORT tcp:$PASEO_SERVICE_DAEMON_PORT +REACT_NATIVE_PACKAGER_HOSTNAME=localhost \ + EXPO_PUBLIC_LOCAL_DAEMON=localhost:$PASEO_SERVICE_DAEMON_PORT \ + npm run android +``` + +This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:` works directly; on Android you need `10.0.2.2` or `adb reverse`. + ## F-Droid / source-only Android builds F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild: diff --git a/docs/development.md b/docs/development.md index b86772680..443e8e5db 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,11 +59,42 @@ startup routing, remembered workspace restore, or active workspace selection. Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`. +**Prerequisites (macOS only).** The service shells out to the Apple toolchain, so beyond the `npm ci` that worktree setup runs you must install: + +- **Xcode** (the full app, not just the Command Line Tools) — install it from the Mac App Store, or from `developer.apple.com/download` for a specific version. It provides `xcodebuild` and `xcrun simctl`; accept its license and let first-run component installation finish before starting the service. +- **An iOS Simulator runtime with at least one iPhone device type**. Recent Xcode versions may not bundle a runtime — add one via Xcode → Settings → Components (older Xcode: "Platforms"). The service targets `iPhone 16 Pro` by default (override with `PASEO_IOS_DEVICE_TYPE`) and falls back to any iPhone; it fails with `No iPhone simulator device type is installed` when none exist. +- **Homebrew** — CocoaPods itself installs automatically: `expo prebuild` runs `pod install` on a cold worktree, and when the CocoaPods CLI is missing the runner installs it for you. It tries `gem install cocoapods` first and falls back to Homebrew (`brew install cocoapods`), so having Homebrew available lets that fallback succeed without a manual step. + +`serve-sim`, Expo, and Metro come from `npm ci`, and CocoaPods installs itself on the first prebuild as described above. + The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port. Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time. -Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows. The browser preview is the user-visible simulator surface. +Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows — a guard hides Simulator.app every 250ms, so the native window vanishes if you focus it. The user-visible surface is the interactive `/.sim` preview: a `serve-sim` stream (60 FPS MJPEG + a WebSocket control channel) that Metro mounts at `basePath: "/.sim"` (`packages/app/metro.config.cjs`) and that forwards taps and gestures, so first-launch prompts like "Open in PaseoDebug?" are answered there, not in the native window. Open the `${PASEO_URL}/.sim` link the service prints — not `serve-sim`'s raw stream port (`:3100`), which is view-only. Because the stream sits behind the daemon proxy it is convenient for remote viewing but laggy up close; for fast local dev at the Mac, use the native simulator path below. + +**Troubleshooting.** If `xcrun simctl` fails with `unable to find utility "simctl"`, the active developer directory is still the Command Line Tools even though Xcode is installed. Point it at Xcode: `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, then confirm with `xcrun --find simctl`. + +### Running the iOS app on a local simulator + +For fast, native, interactive iOS dev at the Mac — as opposed to the remote `/.sim` preview above — skip the service and build the dev client directly: + +```bash +npm run ios # → expo run:ios (packages/app): builds and launches the app in the real Simulator.app +``` + +`expo run:ios` starts its own Metro and gives you the normal Simulator.app window (full speed, native touch, no stream). + +**Pointing the app at a daemon.** The client resolves its local daemon from `EXPO_PUBLIC_LOCAL_DAEMON` (`packages/app/src/runtime/host-runtime.ts`); when unset it falls back to `localhost:6767`, the production `~/.paseo` daemon. To target a worktree's dev daemon instead, set it on the build command: + +```bash +EXPO_PUBLIC_LOCAL_DAEMON=localhost:${PASEO_SERVICE_DAEMON_PORT} npm run ios # worktree daemon running as a Paseo service +EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run ios # standalone `npm run dev:server` +``` + +The iOS simulator shares the Mac's loopback, so `localhost:` reaches the host daemon directly. + +**Gotcha — `EXPO_PUBLIC_*` is inlined into the JS bundle at Metro bundle time, not read at runtime.** Set it in the same shell that starts Metro. If the app still connects to the old daemon, Metro served a cached bundle; re-bundle clean with `cd packages/app && EXPO_PUBLIC_LOCAL_DAEMON=… npx expo start -c` and reload the app. ### Desktop renderer profiling