From 14acb97662053bf3443f79f1f6c6d07e78e2249e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 10:30:01 +0200 Subject: [PATCH 001/420] fix(server): preserve Pi message IDs after resume (#2313) --- .../server/agent/providers/pi/agent.test.ts | 107 ++++++++++++-- .../src/server/agent/providers/pi/agent.ts | 130 ++++++++++-------- .../agent/providers/pi/test-utils/fake-pi.ts | 19 +++ .../src/server/daemon-e2e/pi.real.e2e.test.ts | 70 +++++++++- 4 files changed, 252 insertions(+), 74 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 88a3f6cb571..daf25470cfd 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -57,14 +57,15 @@ function readUtf8File(pathname: string): string { } } -async function applyPaseoExtensionSystemPrompt( +type PaseoExtensionListener = (event: unknown, context?: unknown) => unknown; + +async function loadPaseoExtensionListeners( extensionPath: string, - systemPrompt: string, -): Promise { - const listeners = new Map unknown>(); +): Promise> { + const listeners = new Map(); const extension = (await import(pathToFileURL(extensionPath).href)) as { default: (piApi: { - on: (event: string, listener: (event: { systemPrompt: string }) => unknown) => void; + on: (event: string, listener: PaseoExtensionListener) => void; registerCommand: () => void; }) => void; }; @@ -72,6 +73,14 @@ async function applyPaseoExtensionSystemPrompt( on: (event, listener) => listeners.set(event, listener), registerCommand: () => undefined, }); + return listeners; +} + +async function applyPaseoExtensionSystemPrompt( + extensionPath: string, + systemPrompt: string, +): Promise { + const listeners = await loadPaseoExtensionListeners(extensionPath); const result = await listeners.get("before_agent_start")?.({ systemPrompt }); return (result as { systemPrompt?: string } | undefined)?.systemPrompt; } @@ -594,15 +603,15 @@ describe("PiRpcAgentSession", () => { ]); }); - test("emits live user messages with captured Pi tree entry ids", async () => { + test("emits live user messages with submitted Pi tree entry ids", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); - fakeSession.capturedUserEntries = [{ id: "entry-user-1", parentId: null, text: "hello" }]; await session.startTurn("hello"); - fakeSession.emit({ - type: "message_end", - message: { role: "user", content: "hello" }, + fakeSession.finishSubmittedUserMessage({ + id: "entry-user-1", + parentId: null, + text: "hello", }); await events.nextTimelineEvent(); @@ -612,6 +621,38 @@ describe("PiRpcAgentSession", () => { ]); }); + test("uses the Pi entry attached to a submitted prompt after resuming old history", async () => { + const pi = new FakePi(); + const client = createClient(pi); + const session = (await client.resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project" }, + })) as PiRpcAgentSession; + const events = new SessionEvents(session); + const fakeSession = pi.latestSession(); + fakeSession.capturedUserEntries = [{ id: "entry-old", parentId: null, text: "old prompt" }]; + + await session.startTurn("new prompt", { clientMessageId: "client-new" }); + fakeSession.finishSubmittedUserMessage({ + id: "entry-new", + parentId: "entry-old-assistant", + text: "new prompt", + }); + + await events.nextTimelineEvent(); + + expect(events.timelineItems()).toEqual([ + { + type: "user_message", + text: "new prompt", + messageId: "entry-new", + clientMessageId: "client-new", + }, + ]); + }); + test("surfaces Pi extension command messages and completes when no agent turn starts", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -734,6 +775,52 @@ describe("PiRpcAgentSession", () => { ]); }); + test("reports the persisted Pi entry attached to the submitted message", async () => { + const pi = new FakePi(); + const client = createClient(pi); + const session = await client.createSession(createConfig()); + const extensionPath = pi.recordedLaunches[0]?.extensionPaths[0]; + expect(extensionPath).toBeDefined(); + const listeners = await loadPaseoExtensionListeners(extensionPath!); + const submittedMessage = { role: "user", content: "new prompt" }; + const entries: Array<{ + type: string; + id: string; + parentId: string | null; + message: { role: string; content: string }; + }> = [ + { + type: "message", + id: "entry-old", + parentId: null, + message: { role: "user", content: "old prompt" }, + }, + ]; + const notifications: string[] = []; + const context = { + sessionManager: { getEntries: () => entries }, + ui: { notify: (message: string) => notifications.push(message) }, + }; + + await listeners.get("message_end")?.({ message: submittedMessage }, context); + entries.push({ + type: "message", + id: "entry-new", + parentId: "entry-old-assistant", + message: submittedMessage, + }); + await listeners.get("message_start")?.( + { message: { role: "assistant", content: [] } }, + context, + ); + + expect(notifications).toEqual([ + 'PASEO_SUBMITTED_USER_ENTRY {"entry":{"id":"entry-new","parentId":"entry-old-assistant","text":"new prompt"}}', + ]); + + await session.close(); + }); + test("appends agent and daemon prompts after Pi's discovered system prompt", async () => { const pi = new FakePi(); const client = createClient(pi); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index f1f55d60035..66237340d4d 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -92,6 +92,7 @@ const PI_CATALOG_REQUEST_TIMEOUT_MS = 120_000; const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree"; const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries"; const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE"; +const PASEO_PI_SUBMITTED_USER_ENTRY_MARKER = "PASEO_SUBMITTED_USER_ENTRY"; const PASEO_PI_COMMAND_RESULT_MARKER = "PASEO_COMMAND_RESULT"; const DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS = 30_000; const QUESTION_RESPONSE_HEADER = "Response"; @@ -256,12 +257,6 @@ interface PiCapturedEntry extends PiCapturedUserMessageEntry { parentId: string | null; } -interface PendingPiUserMessage { - text: string; - turnId: string | undefined; - clientMessageId?: string; -} - interface PendingExtensionResult { resolve: (value: unknown) => void; reject: (error: Error) => void; @@ -653,11 +648,15 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { return ctx.sessionManager .getEntries() .filter((entry) => entry.type === "message" && entry.message?.role === "user") - .map((entry) => ({ + .map(toCapturedUserEntry); + } + + function toCapturedUserEntry(entry) { + return { id: entry.id, parentId: entry.parentId ?? null, text: readTextContent(entry.message.content), - })); + }; } function emitEntryCapture(ctx, reason, requestId) { @@ -676,6 +675,30 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { } export default function paseoIntegration(pi) { + const submittedUserMessages = []; + + function emitSubmittedUserEntries(ctx) { + const entries = ctx.sessionManager.getEntries(); + for (let index = 0; index < submittedUserMessages.length; index += 1) { + const message = submittedUserMessages[index]; + // Pi assigns the entry ID after message_end, then persists this same message object. + // Reference equality preserves the exact association even when another extension edits it. + const entry = entries.find( + (candidate) => candidate.type === "message" && candidate.message === message, + ); + if (!entry) { + continue; + } + submittedUserMessages.splice(index, 1); + index -= 1; + ctx.ui.notify( + "${PASEO_PI_SUBMITTED_USER_ENTRY_MARKER} " + + JSON.stringify({ entry: toCapturedUserEntry(entry) }), + "info", + ); + } + } + ${ systemPrompt ? `pi.on("before_agent_start", async (event) => ({ @@ -688,7 +711,20 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { emitEntryCapture(ctx, "session_start"); }); + pi.on("message_end", async (event) => { + if (event.message?.role === "user") { + submittedUserMessages.push(event.message); + } + }); + + pi.on("message_start", async (event, ctx) => { + if (event.message?.role === "assistant") { + emitSubmittedUserEntries(ctx); + } + }); + pi.on("turn_end", async (_event, ctx) => { + emitSubmittedUserEntries(ctx); emitEntryCapture(ctx, "turn_end"); }); @@ -1197,8 +1233,6 @@ export class PiRpcAgentSession implements AgentSession { currentLeafOverrideId: string | null | undefined; private readonly capturedUserEntries: PiCapturedEntry[] = []; private readonly capturedUserEntriesById = new Map(); - private readonly seenUserEntryIds = new Set(); - private readonly pendingUserMessages: PendingPiUserMessage[] = []; private readonly pendingExtensionResults = new Map(); private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; private outOfBandCompactionStarted = false; @@ -1776,42 +1810,34 @@ export class PiRpcAgentSession implements AgentSession { } private recordCapturedUserEntries(entries: PiCapturedEntry[]): void { - const previouslySeenEntryIds = new Set(this.seenUserEntryIds); this.capturedUserEntries.splice(0, this.capturedUserEntries.length, ...entries); this.capturedUserEntriesById.clear(); for (const entry of entries) { this.capturedUserEntriesById.set(entry.id, entry); } - this.flushPendingUserMessages(previouslySeenEntryIds); - for (const entry of entries) { - this.seenUserEntryIds.add(entry.id); - } } - private flushPendingUserMessages(previouslySeenEntryIds: Set): void { - for (let index = 0; index < this.pendingUserMessages.length; index += 1) { - const pending = this.pendingUserMessages[index]!; - const entry = this.capturedUserEntries.find( - (candidate) => !previouslySeenEntryIds.has(candidate.id), - ); - if (!entry) { - continue; - } - previouslySeenEntryIds.add(entry.id); - this.pendingUserMessages.splice(index, 1); - index -= 1; - this.emit({ - type: "timeline", - provider: this.provider, - turnId: pending.turnId, - item: { - type: "user_message", - text: pending.text, - messageId: entry.id, - ...(pending.clientMessageId ? { clientMessageId: pending.clientMessageId } : {}), - }, - }); + private handleSubmittedUserEntryMarker(message: string): boolean { + const payload = parseExtensionMarkerPayload(message, PASEO_PI_SUBMITTED_USER_ENTRY_MARKER); + if (!payload) { + return false; + } + const [entry] = parseCapturedEntries([payload.entry]); + if (!entry) { + return true; } + this.emit({ + type: "timeline", + provider: this.provider, + turnId: this.currentTurnIdForEvent(), + item: { + type: "user_message", + text: entry.text, + messageId: entry.id, + ...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}), + }, + }); + return true; } private handleEntryCaptureMarker(message: string): boolean { @@ -1849,7 +1875,11 @@ export class PiRpcAgentSession implements AgentSession { ): void { const message = optionalString(event.message); if (event.method === "notify" && message) { - if (this.handleEntryCaptureMarker(message) || this.handleCommandResultMarker(message)) { + if ( + this.handleSubmittedUserEntryMarker(message) || + this.handleEntryCaptureMarker(message) || + this.handleCommandResultMarker(message) + ) { return; } this.bufferNoTurnOutput(message); @@ -2176,28 +2206,6 @@ export class PiRpcAgentSession implements AgentSession { this.completeTurn(turnId, []); return; } - - if (event.message.role !== "user") { - return; - } - const text = getUserMessageText(event.message.content); - if (!text) { - return; - } - this.pendingUserMessages.push({ - text, - turnId, - ...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}), - }); - void this.requestEntryCapture("message_end").catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - this.emit({ - type: "turn_failed", - provider: this.provider, - turnId, - error: message, - }); - }); } private emitToolCallEvent( 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 8093baeb200..bf0f5907d3d 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 @@ -45,6 +45,12 @@ export interface FakePiSubagentMessagesResult { messages: PiAgentMessage[]; } +interface FakePiUserEntry { + id: string; + parentId: string | null; + text: string; +} + export class FakePi implements PiRuntime { readonly recordedLaunches: PiRuntimeLaunch[] = []; private readonly sessions: FakePiSession[] = []; @@ -349,6 +355,19 @@ export class FakePiSession implements PiRuntimeSession { this.emit({ type: "agent_end", messages: this.messages }); } + finishSubmittedUserMessage(entry: FakePiUserEntry): void { + this.emit({ + type: "message_end", + message: { role: "user", content: entry.text }, + }); + this.emit({ + type: "extension_ui_request", + id: `submitted-user-${entry.id}`, + method: "notify", + message: `PASEO_SUBMITTED_USER_ENTRY ${JSON.stringify({ entry })}`, + }); + } + private handleTreeNavigationCommand(message: string): void { const prefix = "/paseo_tree "; if (!message.startsWith(prefix)) { diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index d992638baad..ef7d43a0fb9 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -12,7 +12,7 @@ import type { AgentTimelineItem, } from "../agent/agent-sdk-types.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { canRunRealProvider, createRealProviderClient, @@ -107,7 +107,7 @@ async function waitForTimelineItem( } async function withConnectedPiDaemon( - run: (context: { client: DaemonClient }) => Promise, + run: (context: { client: DaemonClient; daemon: TestPaseoDaemon }) => Promise, ): Promise { const daemon = await createPiToolDaemon(); const client = new DaemonClient({ @@ -120,7 +120,7 @@ async function withConnectedPiDaemon( await client.fetchAgents({ subscribe: { subscriptionId: `pi-real-${randomUUID()}` }, }); - await run({ client }); + await run({ client, daemon }); } finally { await client.close().catch(() => undefined); await daemon.close().catch(() => undefined); @@ -645,6 +645,70 @@ test( PI_TEST_TIMEOUT_MS, ); +test( + "resumed Pi prompts retain their exact native entry ids after idle collection", + async () => { + const cwd = tmpCwd("pi-resumed-entry-id-"); + const firstPrompt = "PASEO_PI_ENTRY_ID_FIRST. Reply exactly: first-ok"; + const secondPrompt = "PASEO_PI_ENTRY_ID_SECOND. Reply exactly: second-ok"; + + try { + await withConnectedPiDaemon(async ({ client, daemon }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-resumed-entry-id", + provider: "pi", + model: PI_REAL_TEST_MODEL, + }); + + await client.sendMessage(agent.id, firstPrompt); + const firstFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(firstFinish.status).toBe("idle"); + + const collection = await daemon.daemon.agentManager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + expect(collection.failures).toEqual([]); + expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id); + + await client.sendMessage(agent.id, secondPrompt); + const secondFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(secondFinish.status).toBe("idle"); + + const nativeHandle = secondFinish.final?.persistence?.nativeHandle; + if (typeof nativeHandle !== "string") { + throw new Error("Real Pi run did not return a native session file"); + } + const nativeUserEntryIds = readFileSync(nativeHandle, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record) + .filter( + (entry) => + entry.type === "message" && + typeof entry.id === "string" && + typeof entry.message === "object" && + entry.message !== null && + (entry.message as { role?: unknown }).role === "user", + ) + .map((entry) => entry.id as string); + const userMessages = (await fetchCanonicalTimeline(client, agent.id)).filter( + (item): item is Extract => + item.type === "user_message", + ); + + expect(userMessages.map((item) => item.text)).toEqual([firstPrompt, secondPrompt]); + expect(userMessages.map((item) => item.messageId)).toEqual(nativeUserEntryIds); + expect(new Set(nativeUserEntryIds).size).toBe(2); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS * 2, +); + test( "streamHistory replays user and assistant timeline after resume", async () => { From 6cddc657cda45f3677b3423a4d6d42a142a187f4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 10:33:51 +0200 Subject: [PATCH 002/420] chore(acp): refresh provider catalog versions --- packages/app/src/data/acp-provider-catalog.ts | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index a49a63589ed..32be1e16f58 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -37,10 +37,10 @@ const CATALOG_DATA = [ title: "Auggie CLI", description: "Augment Code's powerful software agent, backed by industry-leading context engine", - version: "0.32.0", + version: "0.33.0", iconId: "auggie", installLink: "https://www.augmentcode.com/", - command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"], + command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"], env: { AUGMENT_DISABLE_AUTO_UPDATE: "1", }, @@ -59,10 +59,10 @@ const CATALOG_DATA = [ title: "Cline", description: "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", - version: "3.0.44", + version: "3.0.46", iconId: "cline", installLink: "https://cline.bot/cli", - command: ["npx", "-y", "cline@3.0.44", "--acp"], + command: ["npx", "-y", "cline@3.0.46", "--acp"], }, { id: "codebuddy-code", @@ -122,10 +122,10 @@ const CATALOG_DATA = [ id: "deepagents", title: "DeepAgents", description: "Batteries-included AI coding and general purpose agent powered by LangChain.", - version: "0.1.19", + version: "0.1.20", iconId: "deepagents", installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview", - command: ["npx", "-y", "deepagents-acp@0.1.19"], + command: ["npx", "-y", "deepagents-acp@0.1.20"], }, { id: "devin", @@ -140,29 +140,29 @@ const CATALOG_DATA = [ id: "dimcode", title: "DimCode", description: "A coding agent that puts leading models at your command.", - version: "0.2.31", + version: "0.2.35", iconId: "dimcode", installLink: "https://dimcode.dev/docs/acp.html", - command: ["npx", "-y", "dimcode@0.2.31", "acp"], + command: ["npx", "-y", "dimcode@0.2.35", "acp"], }, { id: "dirac", title: "Dirac", description: "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", - version: "0.4.17", + version: "0.4.21", iconId: "dirac", installLink: "https://dirac.run", - command: ["npx", "-y", "dirac-cli@0.4.17", "--acp"], + command: ["npx", "-y", "dirac-cli@0.4.21", "--acp"], }, { id: "factory-droid", title: "Factory Droid", description: "Factory Droid - AI coding agent powered by Factory AI", - version: "0.174.0", + version: "0.177.0", iconId: "factory-droid", installLink: "https://factory.ai/product/cli", - command: ["npx", "-y", "droid@0.174.0", "exec", "--output-format", "acp-daemon"], + command: ["npx", "-y", "droid@0.177.0", "exec", "--output-format", "acp-daemon"], env: { DROID_DISABLE_AUTO_UPDATE: "true", FACTORY_DROID_AUTO_UPDATE_ENABLED: "false", @@ -173,10 +173,10 @@ const CATALOG_DATA = [ id: "fast-agent", title: "fast-agent", description: "Code and build agents with comprehensive multi-provider support", - version: "0.9.14", + version: "0.9.20", iconId: "fast-agent", installLink: "https://fast-agent.ai/acp/", - command: ["uvx", "--from", "fast-agent-acp==0.9.14", "fast-agent-acp", "-x"], + command: ["uvx", "--from", "fast-agent-acp==0.9.20", "fast-agent-acp", "-x"], }, { id: "gemini", @@ -192,10 +192,10 @@ const CATALOG_DATA = [ title: "GLM Agent", description: "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", - version: "1.1.4", + version: "1.3.0", iconId: "glm-acp-agent", installLink: "https://github.com/stefandevo/glm-acp-agent", - command: ["npx", "-y", "glm-acp-agent@1.1.4"], + command: ["npx", "-y", "glm-acp-agent@1.3.0"], }, { id: "goose", @@ -284,10 +284,10 @@ const CATALOG_DATA = [ id: "nova", title: "Nova", description: "Nova by Compass AI - a fully-fledged software engineer at your command", - version: "1.1.27", + version: "1.1.29", iconId: "nova", installLink: "https://www.compassap.ai/portfolio/nova.html", - command: ["npx", "-y", "@compass-ai/nova@1.1.27", "acp"], + command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"], }, { id: "poolside", @@ -302,19 +302,19 @@ const CATALOG_DATA = [ id: "qoder", title: "Qoder CLI", description: "AI coding assistant with agentic capabilities", - version: "1.0.48", + version: "1.1.2", iconId: "qoder", installLink: "https://qoder.com", - command: ["npx", "-y", "@qoder-ai/qodercli@1.0.48", "--acp"], + command: ["npx", "-y", "@qoder-ai/qodercli@1.1.2", "--acp"], }, { id: "qwen-code", title: "Qwen Code", description: "Alibaba's Qwen coding assistant", - version: "0.19.11", + version: "0.20.1", iconId: "qwen-code", installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview", - command: ["npx", "-y", "@qwen-code/qwen-code@0.19.11", "--acp", "--experimental-skills"], + command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"], }, { id: "sigit", From 14b25d4266aa72c9955ae835c61673bd39de0814 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 10:34:05 +0200 Subject: [PATCH 003/420] docs(changelog): add 0.2.0 beta 2 notes --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd2895dc51..4756b671c54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 0.2.0-beta.2 - 2026-07-22 + +### Added + +- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309)) +- Automate local and worktree-backed workspaces through the CLI and agent tools ([#2186](https://github.com/getpaseo/paseo/pull/2186)) +- Start a new workspace from a pasted pull request or merge request link ([#2290](https://github.com/getpaseo/paseo/pull/2290)) +- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu)) +- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger)) +- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160)) +- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257)) +- Use Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue)) + +### Improved + +- Workspace and chat sync uses less data without losing or reordering history ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263)) +- Added folders remain independent projects across nested repositories, worktrees, and filesystem mounts ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187)) +- Idle agents release provider resources automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209)) +- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213)) +- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn)) +- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312)) +- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201)) + +### Fixed + +- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx)) +- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd)) +- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313)) +- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258)) +- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214)) +- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991)) +- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194)) +- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268)) +- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago)) +- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang)) +- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210)) +- Session imports find older matches from the current workspace ([#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs)) + ## 0.2.0-beta.1 - 2026-07-17 ### Added From 89a022e853101cc34e57c7c82998f17dd6e391a7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 10:56:55 +0200 Subject: [PATCH 004/420] chore(release): cut 0.2.0-beta.2 --- package-lock.json | 42 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++--- packages/client/package.json | 6 ++-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 +++--- packages/website/package.json | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26fb9d94a7a..1062baf7345 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35211,7 +35211,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36236,12 +36236,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.0-beta.1", - "@getpaseo/protocol": "0.2.0-beta.1", - "@getpaseo/server": "0.2.0-beta.1", + "@getpaseo/client": "0.2.0-beta.2", + "@getpaseo/protocol": "0.2.0-beta.2", + "@getpaseo/server": "0.2.0-beta.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36487,10 +36487,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "dependencies": { - "@getpaseo/protocol": "0.2.0-beta.1", - "@getpaseo/relay": "0.2.0-beta.1", + "@getpaseo/protocol": "0.2.0-beta.2", + "@getpaseo/relay": "0.2.0-beta.2", "zod": "^4.4.3" }, "devDependencies": { @@ -36501,7 +36501,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36744,7 +36744,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37640,7 +37640,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -37872,7 +37872,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "dependencies": { "zod": "^4.4.3" }, @@ -37885,7 +37885,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38103,15 +38103,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.0-beta.1", - "@getpaseo/highlight": "0.2.0-beta.1", - "@getpaseo/protocol": "0.2.0-beta.1", - "@getpaseo/relay": "0.2.0-beta.1", + "@getpaseo/client": "0.2.0-beta.2", + "@getpaseo/highlight": "0.2.0-beta.2", + "@getpaseo/protocol": "0.2.0-beta.2", + "@getpaseo/relay": "0.2.0-beta.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38648,7 +38648,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 38673a1aa12..9c2fc3e38fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 8fae0b6bc7f..46cbf4b25a0 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 84a4e314a65..5e896ceb6c0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -27,9 +27,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.0-beta.1", - "@getpaseo/protocol": "0.2.0-beta.1", - "@getpaseo/server": "0.2.0-beta.1", + "@getpaseo/client": "0.2.0-beta.2", + "@getpaseo/protocol": "0.2.0-beta.2", + "@getpaseo/server": "0.2.0-beta.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index f6485f63422..4a18c9c3b5a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "description": "Paseo client SDK package", "files": [ "dist", @@ -35,8 +35,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.2.0-beta.1", - "@getpaseo/relay": "0.2.0-beta.1", + "@getpaseo/protocol": "0.2.0-beta.2", + "@getpaseo/relay": "0.2.0-beta.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ebf9dfec29b..282e40a39ab 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index ef9b00bf053..c52bbf447b3 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 1165d83a83d..72a99785ca8 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 510625f6d5f..09cde68fa7b 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 1de1235e5c5..38dfb5199e3 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 456e78d1b53..2e9a1eabc63 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -67,10 +67,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.0-beta.1", - "@getpaseo/highlight": "0.2.0-beta.1", - "@getpaseo/protocol": "0.2.0-beta.1", - "@getpaseo/relay": "0.2.0-beta.1", + "@getpaseo/client": "0.2.0-beta.2", + "@getpaseo/highlight": "0.2.0-beta.2", + "@getpaseo/protocol": "0.2.0-beta.2", + "@getpaseo/relay": "0.2.0-beta.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index efbdd7abccd..8fc91ef32f4 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.2.0-beta.1", + "version": "0.2.0-beta.2", "private": true, "type": "module", "scripts": { From f22a8ea8e0964fbfa119b667fd0aa38be0184c9c Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:05:28 +0000 Subject: [PATCH 005/420] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 1d72357fb07..83cf1ee765b 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-prXQpWLMwN1JO2tGhGDie/Ud9vnmUSF44581Mm7R7eA= +sha256-Ys/R5+0O3SDYeCSd7hatTuCwV9S0kIO8vhmPKGZsvn0= From 3a8d08ba91a93bc0490c32a664470c1aaa09ed97 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 11:11:08 +0200 Subject: [PATCH 006/420] Refactor changelog entries and improve descriptions --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4756b671c54..f91f3f10603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,6 @@ ### Added - Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309)) -- Automate local and worktree-backed workspaces through the CLI and agent tools ([#2186](https://github.com/getpaseo/paseo/pull/2186)) -- Start a new workspace from a pasted pull request or merge request link ([#2290](https://github.com/getpaseo/paseo/pull/2290)) - Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu)) - Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger)) - Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160)) @@ -15,9 +13,11 @@ ### Improved -- Workspace and chat sync uses less data without losing or reordering history ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263)) -- Added folders remain independent projects across nested repositories, worktrees, and filesystem mounts ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187)) -- Idle agents release provider resources automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209)) +- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#2186](https://github.com/getpaseo/paseo/pull/2186)) +- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290)) +- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263)) +- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187)) +- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209)) - New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213)) - Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn)) - Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312)) From 0afcc96370afa60f31093694d45c33a1d7874fbe Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 12:24:30 +0200 Subject: [PATCH 007/420] docs(website): correct provider usage guidance --- public-docs/claude-code.md | 50 +++++++++----------------------------- 1 file changed, 11 insertions(+), 39 deletions(-) diff --git a/public-docs/claude-code.md b/public-docs/claude-code.md index 83ece5d6bda..e98081b29db 100644 --- a/public-docs/claude-code.md +++ b/public-docs/claude-code.md @@ -1,6 +1,6 @@ --- title: Claude Code -description: How Paseo runs Claude Code and how Anthropic's usage policy applies. +description: Run Claude Code in Paseo using your existing Claude plan. nav: Claude Code order: 23 category: Providers @@ -8,54 +8,26 @@ category: Providers # Claude Code -Paseo runs Claude Code through the official `claude` CLI using the same Claude Agent SDK that Claude Desktop uses internally. +Paseo runs Claude Code through the official `claude` CLI using the Claude Agent SDK. -## Anthropic's June 15, 2026 policy change +## Does Claude Code cost extra in Paseo? -Starting June 15, 2026, Anthropic splits Claude Code usage into two buckets. This is documented in their official support article: ["Use the Claude Agent SDK with your Claude plan"](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan). +No. Claude Code usage in Paseo counts against your normal Claude plan limits. It does not require a separate pool of Agent SDK credits. -**Interactive usage** — draws from your main subscription limits: +You still need a Claude plan that includes Claude Code, and your plan's usual usage limits apply. -- Claude Code in your terminal -- Claude Code in VS Code, JetBrains, and other IDEs -- Claude Desktop (Anthropic's own app) -- Claude chat on web, mobile, and desktop +## Getting started -**Programmatic usage** — draws from a separate monthly credit pool: +Install and sign in to the Claude Code CLI on the machine running Paseo. Paseo uses that existing installation and account when you start a Claude Code agent. -- `claude -p` (non-interactive/scripting mode) -- Claude Agent SDK usage in your own projects -- Claude Code GitHub Actions integration -- Third-party apps that authenticate through the Agent SDK +## Use Claude Code in the Paseo terminal -Credit amounts per month: Pro ($20), Max 5x ($100), Max 20x ($200), Team Standard ($20/seat), Team Premium ($100/seat). Credits don't roll over. After they run out, additional usage flows to pay-as-you-go API rates (if you have usage credits enabled). +Claude Code also works great inside the Paseo terminal. If you prefer the standard CLI experience, open a terminal in your workspace and run `claude` as usual. -## Where Paseo fits - -Paseo uses the **Claude Agent SDK** to run Claude Code — the exact same mechanism Claude Desktop uses under the hood. Claude Desktop is whitelisted by Anthropic and counts as interactive usage. Paseo is not. - -Even though the usage is practically interactive — you type prompts, review output, and approve tool calls in real time — Anthropic classifies Paseo as "programmatic usage" because it is a third-party app that authenticates through the Agent SDK. - -## What this means for you - -- Your **interactive** Claude Code usage (terminal, IDE, Claude Desktop) continues to draw from your main subscription limits, unchanged. -- Your **Paseo chat** usage draws from the separate Agent SDK monthly credits. -- Using Claude Code inside a Paseo terminal draws from your main subscription limits, same as any terminal. - -## You can still use the terminal - -Paseo has first-class terminal support. You can run Claude Code in your terminal exactly as you always have, and Paseo will still give you: - -- **Worktree management** — create and switch git worktrees from the app -- **Remote access** — connect to your daemon from mobile or web while your terminal session runs locally -- **Git diffs** — review changes in a visual diff viewer -- **GitHub integration** — commit, push, open PRs, watch checks, and merge from the app -- **Agent supervision** — see all running agents, send follow-up prompts, and review output history -- **Relay** — access your terminal session from anywhere without exposing your machine +You can use the terminal from Paseo's desktop, web, or mobile app while keeping access to your workspace, git changes, and other Paseo tools. ## See also -- [Anthropic: Use the Claude Agent SDK with your Claude plan](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) -- [Custom providers](/docs/custom-providers), for custom binaries, third-party endpoints, or multiple Claude profiles. - [Supported providers](/docs/supported-providers), for other agents you can run alongside Claude Code. +- [Custom providers](/docs/custom-providers), for custom binaries, third-party endpoints, or multiple Claude profiles. - [Paseo vs Claude Desktop](/alternatives/claude-desktop), for a feature comparison. From 246c07fba58468e0044109f62055f589afd1f8c2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 12:26:40 +0200 Subject: [PATCH 008/420] fix(cli): make new workspace creation explicit (#2315) Agent callers now stay in their current workspace unless --new-workspace explicitly requests a separate local or worktree workspace. --- docs/development.md | 2 +- packages/cli/src/cli-surface.test.ts | 19 ++- packages/cli/src/commands/agent/run.test.ts | 30 +++-- packages/cli/src/commands/agent/run.ts | 136 ++++++++++++++------ packages/cli/tests/05-agent-run.test.ts | 24 ++++ public-docs/cli.md | 6 +- public-docs/worktrees.md | 2 +- skills/paseo/SKILL.md | 1 + 8 files changed, 167 insertions(+), 53 deletions(-) diff --git a/docs/development.md b/docs/development.md index 2bb5a5403a2..81e43cb3b8b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -383,7 +383,7 @@ install. Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing. -Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases. +Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases. ```bash npm run cli -- ls -a -g # List all agents globally diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index fa772f85c38..769d2e4a654 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -10,10 +10,23 @@ describe("canonical CLI surface", () => { expect(help).not.toContain("worktree"); }); - it("hides legacy run worktree syntax", () => { + it("names explicit workspace creation without exposing older syntax", () => { const run = createCli().commands.find((command) => command.name() === "run"); - expect(run?.helpInformation()).toContain("--isolation "); - expect(run?.helpInformation()).not.toContain("--worktree "); + const help = run?.helpInformation(); + expect(help).toContain("--new-workspace "); + expect(help).not.toContain("--isolation"); + expect(help).not.toContain("--worktree "); + }); + + it("offers the worktree creation options on run", () => { + const run = createCli().commands.find((command) => command.name() === "run"); + const help = run?.helpInformation(); + expect(help).toContain("--worktree-mode "); + expect(help).toContain("--worktree-slug "); + expect(help).toContain("--new-branch "); + expect(help).toContain("--branch "); + expect(help).toContain("--pr-number "); + expect(help).toContain("--forge "); }); it("uses background for execution and reserves detach for ownership", () => { diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 59081c99d8b..755638d4d3d 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -72,23 +72,37 @@ describe("runRunCommand option validation", () => { }); } - it("rejects --isolation combined with --workspace", async () => { + it("rejects --new-workspace combined with --workspace", async () => { await expectInvalidOptions( - { isolation: "worktree", workspace: "ws-1" }, - /--isolation and --workspace cannot be combined/, + { newWorkspace: "worktree", workspace: "ws-1" }, + /--new-workspace and --workspace cannot be combined/, ); }); - it("allows explicit worktree isolation through validation", async () => { - // Explicit isolation with no --workspace + it("allows explicit worktree workspace creation through validation", async () => { + // Explicit workspace creation with no --workspace // must clear validation. It still fails later (provider resolution), which // is enough to prove the new guard did not reject it. await expect( - runRunCommand("do something", { isolation: "worktree", provider: undefined }, {} as never), + runRunCommand("do something", { newWorkspace: "worktree", provider: undefined }, {} as never), ).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" }); }); - it("rejects unknown workspace isolation", async () => { - await expectInvalidOptions({ isolation: "container" }, /Unsupported workspace isolation/); + it("rejects unknown new workspace kinds", async () => { + await expectInvalidOptions({ newWorkspace: "container" }, /Unsupported new workspace kind/); + }); + + it("rejects two workspace creation flags", async () => { + await expectInvalidOptions( + { newWorkspace: "local", worktree: "legacy-slug" }, + /--new-workspace and --worktree cannot be combined/, + ); + }); + + it("rejects an unknown worktree creation mode before connecting", async () => { + await expectInvalidOptions( + { newWorkspace: "worktree", worktreeMode: "container" }, + /Unsupported worktree mode/, + ); }); }); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index d283a6d10af..14e494c5699 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -14,6 +14,7 @@ import { lookup } from "mime-types"; import { parseDuration } from "../../utils/duration.js"; import { collectMultiple } from "../../utils/command-options.js"; import { resolveProviderAndModel } from "../../utils/provider-model.js"; +import { buildWorkspaceSource } from "../workspace/create.js"; export { resolveProviderAndModel } from "../../utils/provider-model.js"; @@ -38,12 +39,21 @@ export function addRunOptions(cmd: Command): Command { ) .option("--thinking ", "Thinking option ID to use for this run") .option("--mode ", "Provider-specific mode (e.g., plan, default, bypass)") - .option("--isolation ", "Create a new workspace with this isolation") + .option("--new-workspace ", "Create a separate local or worktree workspace") .addOption(new Option("--worktree ", "Legacy workspace isolation alias").hideHelp()) - .option("--base ", "Base branch for an isolated workspace") + .option( + "--worktree-mode ", + "Worktree mode: branch-off, checkout-branch, or checkout-pr", + ) + .option("--worktree-slug ", "Managed worktree path slug") + .option("--new-branch ", "New branch name for branch-off mode") + .option("--base ", "Base ref for branch-off mode") + .option("--branch ", "Existing branch for checkout-branch mode") + .option("--pr-number ", "Pull request or change request number for checkout-pr mode") + .option("--forge ", "Forge for checkout-pr mode") .option( "--workspace ", - "Run in an existing workspace (default: a new workspace is created per run; falls back to $PASEO_WORKSPACE_ID)", + "Run in an existing workspace (defaults to the caller workspace when agent-scoped)", ) .option( "--image ", @@ -105,9 +115,15 @@ export interface AgentRunOptions extends CommandOptions { model?: string; thinking?: string; mode?: string; - isolation?: string; + newWorkspace?: string; worktree?: string; + worktreeMode?: string; + worktreeSlug?: string; + newBranch?: string; base?: string; + branch?: string; + prNumber?: string; + forge?: string; workspace?: string; image?: string[]; cwd?: string; @@ -117,6 +133,25 @@ export interface AgentRunOptions extends CommandOptions { outputSchema?: string; } +function resolveNewWorkspaceKind(options: AgentRunOptions): string | undefined { + return options.newWorkspace ?? (options.worktree ? "worktree" : undefined); +} + +function buildRunWorkspaceSource(options: AgentRunOptions, cwd: string) { + const newWorkspace = resolveNewWorkspaceKind(options) ?? "local"; + return buildWorkspaceSource({ + isolation: newWorkspace, + path: cwd, + mode: options.worktreeMode, + worktreeSlug: options.worktreeSlug ?? options.worktree, + newBranch: options.newBranch, + base: options.base, + branch: options.branch, + prNumber: options.prNumber, + forge: options.forge, + }); +} + function toRunResult( agent: AgentSnapshotPayload, statusOverride?: AgentRunResult["status"], @@ -269,37 +304,61 @@ function structuredRunSchema(output: Record): OutputSchema", + code: "INVALID_OPTIONS", + message: `Unsupported new workspace kind: ${options.newWorkspace}`, + details: "Use --new-workspace local or --new-workspace worktree", } satisfies CommandError; } - const createsIsolatedWorkspace = options.isolation === "worktree" || Boolean(options.worktree); - if (options.isolation && options.isolation !== "local" && options.isolation !== "worktree") { + if (options.newWorkspace && options.worktree) { throw { code: "INVALID_OPTIONS", - message: `Unsupported workspace isolation: ${options.isolation}`, - details: "Use --isolation local or --isolation worktree", + message: "--new-workspace and --worktree cannot be combined", + details: "Use --new-workspace worktree and the supported worktree options", } satisfies CommandError; } - if (options.base && !createsIsolatedWorkspace) { + const hasWorktreeCreationOptions = [ + options.worktreeMode, + options.worktreeSlug, + options.newBranch, + options.base, + options.branch, + options.prNumber, + options.forge, + ].some((value) => value !== undefined); + if (hasWorktreeCreationOptions && newWorkspace !== "worktree") { throw { code: "INVALID_OPTIONS", - message: "--base can only be used with --isolation worktree", - details: "Usage: paseo agent run --isolation worktree --base ", + message: "Worktree options require --new-workspace worktree", + details: "Usage: paseo run --new-workspace worktree [worktree options] ", } satisfies CommandError; } - if (options.isolation && options.workspace) { + if (newWorkspace === "worktree") { + try { + buildRunWorkspaceSource(options, options.cwd ?? process.cwd()); + } catch (error) { + throw { + code: "INVALID_OPTIONS", + message: error instanceof Error ? error.message : String(error), + } satisfies CommandError; + } + } + + if (options.newWorkspace && options.workspace) { throw { code: "INVALID_OPTIONS", - message: "--isolation and --workspace cannot be combined", - details: "Select an existing workspace or create a new one with an isolation choice", + message: "--new-workspace and --workspace cannot be combined", + details: "Select an existing workspace or explicitly create a new one", } satisfies CommandError; } @@ -309,9 +368,21 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche throw { code: "INVALID_OPTIONS", message: "--worktree and --workspace cannot be combined", - details: "Use --isolation worktree instead of the legacy --worktree flag", + details: "Use --new-workspace worktree instead of the legacy --worktree flag", } satisfies CommandError; } +} + +function validateRunOptions(prompt: string, options: AgentRunOptions, outputSchema: unknown): void { + if (!prompt || prompt.trim().length === 0) { + throw { + code: "MISSING_PROMPT", + message: "A prompt is required", + details: "Usage: paseo agent run [options] ", + } satisfies CommandError; + } + + validateRunWorkspaceOptions(options); if (outputSchema && runsInBackground(options)) { throw { @@ -465,27 +536,25 @@ export async function resolveExistingRunWorkspace( // 1. --workspace -> run in that existing workspace // 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace // 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals -// 4. --isolation -> mint a new workspace with explicit isolation +// 4. --new-workspace -> mint a new workspace explicitly // 5. bare run -> mint a new local-backed workspace for cwd async function resolveRunWorkspace( client: ConnectedDaemonClient, options: AgentRunOptions, cwd: string, ): Promise { - const requestedIsolation = options.isolation ?? (options.worktree ? "worktree" : undefined); - const explicit = requestedIsolation ? undefined : options.workspace?.trim(); + const newWorkspace = resolveNewWorkspaceKind(options); + const explicit = newWorkspace ? undefined : options.workspace?.trim(); if (explicit) { console.error(`Using workspace ${explicit}`); return resolveExistingRunWorkspace(client, explicit); } - if (!requestedIsolation && resolveRunCallerAgentId()) { + if (!newWorkspace && resolveRunCallerAgentId()) { return { cwd }; } - const ambientWorkspaceId = requestedIsolation - ? undefined - : process.env.PASEO_WORKSPACE_ID?.trim(); + const ambientWorkspaceId = newWorkspace ? undefined : process.env.PASEO_WORKSPACE_ID?.trim(); if (ambientWorkspaceId) { console.error(`Using workspace ${ambientWorkspaceId}`); return resolveExistingRunWorkspace(client, ambientWorkspaceId); @@ -493,17 +562,8 @@ async function resolveRunWorkspace( // TODO: thread the run `prompt` as firstAgentContext so workspace-level // title/branch generation picks up the task description (U8/U6 deferred). - const result = - requestedIsolation === "worktree" - ? await client.createWorkspace({ - source: { - kind: "worktree", - cwd, - worktreeSlug: options.worktree, - baseBranch: options.base, - }, - }) - : await client.createWorkspace({ source: { kind: "directory", path: cwd } }); + const source = buildRunWorkspaceSource(options, cwd); + const result = await client.createWorkspace({ source }); if (!result.workspace) { throw { diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts index 1c2700a50a0..0af2a6ec7c3 100644 --- a/packages/cli/tests/05-agent-run.test.ts +++ b/packages/cli/tests/05-agent-run.test.ts @@ -58,6 +58,8 @@ try { assert(result.stdout.includes("--title"), "help should mention --title option"); assert(result.stdout.includes("--provider"), "help should mention --provider option"); assert(result.stdout.includes("--mode"), "help should mention --mode option"); + assert(result.stdout.includes("--new-workspace"), "help should mention --new-workspace option"); + assert(!result.stdout.includes("--isolation"), "help should not mention --isolation"); assert(result.stdout.includes("--cwd"), "help should mention --cwd option"); assert(result.stdout.includes("--output-schema"), "help should mention --output-schema option"); assert(result.stdout.includes("--host"), "help should mention --host option"); @@ -244,6 +246,28 @@ try { assert(output.includes("unknown option"), "should report unknown option for --ui"); console.log("✓ run --ui is rejected\n"); } + + // Test 15: run --new-workspace is accepted + { + console.log("Test 15: run --new-workspace is accepted"); + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --new-workspace local "test prompt"`.nothrow(); + const output = result.stdout + result.stderr; + assert(!output.includes("unknown option"), "should accept --new-workspace"); + assert(!output.includes("error: option"), "should not have option parsing error"); + console.log("✓ run --new-workspace is accepted\n"); + } + + // Test 16: run --isolation is rejected (unreleased flag removed) + { + console.log("Test 16: run --isolation is rejected"); + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --isolation local "test prompt"`.nothrow(); + assert.notStrictEqual(result.exitCode, 0, "should fail for removed --isolation flag"); + const output = result.stdout + result.stderr; + assert(output.includes("unknown option"), "should report unknown option for --isolation"); + console.log("✓ run --isolation is rejected\n"); + } } finally { // Clean up temp directory await rm(paseoHome, { recursive: true, force: true }); diff --git a/public-docs/cli.md b/public-docs/cli.md index f568df3e0d4..14b564cc113 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -31,13 +31,15 @@ Use `paseo run` to start a new agent with a task: paseo run "implement user authentication" paseo run --provider codex "refactor the API layer" paseo run --background "run the focused test suite" -paseo run --isolation worktree --base main "implement feature X" +paseo run --new-workspace worktree --worktree-mode branch-off --new-branch feature/x --base main "implement feature X" paseo run --workspace "review the current diff" paseo run --output-schema schema.json "extract release notes" paseo run --output-schema '{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}' "summarize release notes" ``` -From a human shell, a bare `paseo run` creates a new local workspace for the current directory. Use `--workspace ` to add the agent to an existing workspace, or `--isolation worktree` to create a new workspace backed by an isolated git worktree. +From a human shell, a bare `paseo run` creates a new local workspace for the current directory. Use `--workspace ` to add the agent to an existing workspace, or `--new-workspace local|worktree` to explicitly create a separate workspace for the run. + +Worktree creation accepts `--worktree-mode branch-off|checkout-branch|checkout-pr` plus the matching `--new-branch`/`--base`, `--branch`, or `--pr-number`/`--forge` options. Use `--worktree-slug` to choose the managed directory slug. When an existing Paseo agent runs the same command, Paseo recognizes it through `PASEO_AGENT_ID`. Without explicit placement, the new agent becomes its subagent in the same workspace. `--workspace` can place that subagent elsewhere without changing its parent. diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 23a9bc8288b..fbe3e72d065 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -256,4 +256,4 @@ paseo run --workspace "implement auth" paseo workspace archive ``` -For the common case, `paseo run --isolation worktree --base main "implement auth"` creates both the workspace and its first agent. +For the common case, `paseo run --new-workspace worktree --worktree-mode branch-off --new-branch feature/auth --base main "implement auth"` creates both the workspace and its first agent. diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index c94595b93bb..865972c2e8d 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -104,6 +104,7 @@ paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x paseo workspace create --isolation worktree --mode checkout-branch --branch existing-work paseo workspace create --isolation worktree --mode checkout-pr --pr-number 42 paseo run --provider codex/gpt-5.4 --mode full-access --workspace "" +paseo run --provider codex/gpt-5.4 --mode full-access --new-workspace worktree --worktree-mode branch-off --new-branch fix-x --base main "" paseo send "" paseo ls paseo schedule create --cron "*/15 * * * *" "ping main build" From 68993b7ab35239ec44eb69c169de3c43ae4f1903 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 13:16:27 +0200 Subject: [PATCH 009/420] fix(desktop): preserve Windows terminal hook smoke command The outer batch shell expanded the hook variable and control operators before terminal send-keys received them. Encode the probe for PowerShell so expansion happens inside the packaged terminal. --- packages/desktop/scripts/smoke-packaged-desktop-app.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/desktop/scripts/smoke-packaged-desktop-app.js b/packages/desktop/scripts/smoke-packaged-desktop-app.js index f96834d31eb..0c5e2b798bc 100644 --- a/packages/desktop/scripts/smoke-packaged-desktop-app.js +++ b/packages/desktop/scripts/smoke-packaged-desktop-app.js @@ -130,7 +130,13 @@ function shellQuoteCliArg(value) { function getTerminalHookSmokeCommand(marker) { if (process.platform === "win32") { - return `"%PASEO_HOOK_CLI%" hooks codex Stop && echo ${marker}`; + const script = [ + "& $env:PASEO_HOOK_CLI hooks codex Stop", + "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }", + `Write-Output '${marker}'`, + ].join("; "); + const encodedScript = Buffer.from(script, "utf16le").toString("base64"); + return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodedScript}`; } return `"$PASEO_HOOK_CLI" hooks codex Stop && echo ${marker}`; From 42ee5a5949d81993ecc5f83123d864f9f1cd228b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 15:03:09 +0200 Subject: [PATCH 010/420] Restore archived agents from History (#2316) * fix(app): restore archived agents from History History navigation lost the explicit archived-agent intent during tab reconciliation, and workspace recovery stopped after restoring the workspace. Preserve the selected tab and recover its provider session as one action while serializing provider resume with timeline hydration. * fix(app): preserve archived agent recovery invariants * fix(server): preserve timeline hydration call shape * fix(app): retain agent tabs through lookup * fix(server): upgrade in-flight timeline broadcast --- docs/agent-lifecycle.md | 7 ++ .../app/e2e/archived-codex-agent.real.spec.ts | 115 ++++++++++++++++++ packages/app/e2e/helpers/seed-client.ts | 2 +- packages/app/e2e/worktree-restore.spec.ts | 87 +++++++++++-- .../workspace/[workspaceId]/index.tsx | 20 ++- packages/app/src/components/agent-list.tsx | 1 + .../src/components/archived-agent-callout.tsx | 40 ++++-- .../use-agent-screen-state-machine.test.ts | 17 +++ .../hooks/use-agent-screen-state-machine.ts | 5 + packages/app/src/panels/agent-panel.tsx | 39 +++++- .../screens/workspace/workspace-screen.tsx | 6 + .../src/stores/workspace-layout-actions.ts | 9 +- .../src/stores/workspace-layout-store.test.ts | 60 +++++++++ .../app/src/stores/workspace-layout-store.ts | 49 +++++++- .../app/src/workspace-recovery/model.test.ts | 23 +++- packages/app/src/workspace-recovery/model.ts | 16 +++ .../use-workspace-recovery.ts | 13 +- .../server/src/server/agent/agent-loading.ts | 34 ++++-- .../src/server/agent/agent-manager.test.ts | 70 +++++++++++ .../server/src/server/agent/agent-manager.ts | 51 ++++++-- .../src/server/agent/mcp-server.test.ts | 1 + packages/server/src/server/session.ts | 25 ++-- 22 files changed, 629 insertions(+), 61 deletions(-) create mode 100644 packages/app/e2e/archived-codex-agent.real.spec.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index f5e92283034..3d50231facb 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -80,6 +80,13 @@ archived workspace. History navigation must not infer workspace lifecycle from ` or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace. +History navigation preserves the selected agent as an explicit recovery target. If both that agent +and its workspace are archived, the workspace recovery action restores the workspace and unarchives +the selected agent as one user action. Other archived agents in the restored workspace remain +recoverable from History. Opening one pins its tab and renders the archived-agent callout before any +provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex +`thread/unarchive`) before the normal agent resume and timeline hydration flow. + ## Tabs vs archive These are two distinct concepts that used to be conflated: diff --git a/packages/app/e2e/archived-codex-agent.real.spec.ts b/packages/app/e2e/archived-codex-agent.real.spec.ts new file mode 100644 index 00000000000..76b2b7ed631 --- /dev/null +++ b/packages/app/e2e/archived-codex-agent.real.spec.ts @@ -0,0 +1,115 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect } from "./fixtures"; +import { openSessions } from "./helpers/archive-tab"; +import { + assertChatTranscript, + cleanupRewindFlow, + launchAgent, + sendMessage, + type AgentHandle, +} from "./helpers/rewind-flow"; +import type { SeedDaemonClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; + +interface TimelineClient extends SeedDaemonClient { + fetchAgentTimeline( + agentId: string, + options: { direction: "tail"; projection: "projected"; limit: number }, + ): Promise; +} + +const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else."; +const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL"; +const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else."; +const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL"; + +async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise { + const history = await client.fetchAgentHistory({ page: { limit: 200 } }); + return history.entries.some((entry) => entry.agent.id === agentId); +} + +test.describe("archived Codex agent recovery", () => { + test.setTimeout(600_000); + + test("cold-opens without provider history, then unarchives and restores the conversation", async ({ + page, + }) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ + page, + provider: "codex", + cwd, + mode: "full-access", + }); + await sendMessage(handle, INITIAL_PROMPT); + await assertChatTranscript(handle, [ + { role: "user", text: INITIAL_PROMPT }, + { role: "assistant", text: INITIAL_REPLY }, + ]); + + await handle.client.archiveAgent(handle.agentId); + await expect + .poll( + async () => + (await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ?? + null, + { timeout: 30_000 }, + ) + .not.toBeNull(); + + const timelineClient = handle.client as TimelineClient; + await expect( + timelineClient.fetchAgentTimeline(handle.agentId, { + direction: "tail", + projection: "projected", + limit: 100, + }), + ).rejects.toThrow(/archiv/i); + await expect + .poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), { + timeout: 30_000, + }) + .toBe(true); + + await page.reload(); + await waitForSidebarHydration(page); + await openSessions(page); + await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click(); + + await expect( + page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("agent-load-error")).toHaveCount(0); + await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0); + await expect(page.getByTestId("user-message")).toHaveCount(0); + + await page.getByRole("button", { name: "Unarchive" }).click(); + await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, { + timeout: 60_000, + }); + await assertChatTranscript(handle, [ + { role: "user", text: INITIAL_PROMPT }, + { role: "assistant", text: INITIAL_REPLY }, + ]); + + await sendMessage(handle, FOLLOW_UP_PROMPT); + await assertChatTranscript(handle, [ + { role: "user", text: INITIAL_PROMPT }, + { role: "assistant", text: INITIAL_REPLY }, + { role: "user", text: FOLLOW_UP_PROMPT }, + { role: "assistant", text: FOLLOW_UP_REPLY }, + ]); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 2b243022fed..d3fd7973810 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -144,7 +144,7 @@ export interface SeedDaemonClient { } | null; fetchAgentHistory(options?: { page?: { limit: number }; - }): Promise<{ entries: Array<{ id: string }> }>; + }): Promise<{ entries: Array<{ agent: { id: string } }> }>; subscribeTerminal( terminalId: string, ): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>; diff --git a/packages/app/e2e/worktree-restore.spec.ts b/packages/app/e2e/worktree-restore.spec.ts index 480bdbad4ed..a3abdf9bf43 100644 --- a/packages/app/e2e/worktree-restore.spec.ts +++ b/packages/app/e2e/worktree-restore.spec.ts @@ -44,7 +44,10 @@ test.describe("Worktree restore", () => { tempRepo = await createTempGitRepo("wt-restore-"); }); - async function createArchivedMissingWorktree(prefix: string) { + async function createArchivedMissingWorktree( + prefix: string, + options: { agentCount?: number; keepAgentsArchived?: boolean } = {}, + ) { const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); createdProjectIds.add(project.projectKey); const worktree = await createWorktreeViaDaemon(worktreeClient, { @@ -53,11 +56,19 @@ test.describe("Worktree restore", () => { }); createdProjectIds.add(worktree.projectKey); createdWorktreeDirectories.add(worktree.workspaceDirectory); - const agent = await createIdleAgent(client, { - cwd: worktree.workspaceDirectory, - workspaceId: worktree.workspaceId, - title: `${prefix}-${randomUUID().slice(0, 8)}`, - }); + const agents = await Promise.all( + Array.from({ length: options.agentCount ?? 1 }, () => + createIdleAgent(client, { + cwd: worktree.workspaceDirectory, + workspaceId: worktree.workspaceId, + title: `${prefix}-${randomUUID().slice(0, 8)}`, + }), + ), + ); + const agent = agents[0]; + if (!agent) { + throw new Error("Expected at least one archived-worktree agent"); + } await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory); await expect @@ -67,11 +78,21 @@ test.describe("Worktree restore", () => { // Match the remote cloud-race record: workspace archived and absent, while // the surviving closed agent record is not agent-archived. Refresh now owns // only agent lifecycle, so its expected cwd failure cannot recover the workspace. - await client.refreshAgent(agent.id).catch(() => undefined); - await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull(); + if (options.keepAgentsArchived) { + for (const archivedAgent of agents) { + await expect + .poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 }) + .not.toBeNull(); + } + } else { + await client.refreshAgent(agent.id).catch(() => undefined); + await expect + .poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }) + .toBeNull(); + } expect(existsSync(worktree.workspaceDirectory)).toBe(false); - return { agent, worktree }; + return { agent, agents, worktree }; } async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) { @@ -241,6 +262,54 @@ test.describe("Worktree restore", () => { ).toHaveText(switchedBranch, { timeout: 30_000 }); }); + test("recovers the selected agent with its workspace and later rescues another archived agent", async ({ + page, + }) => { + const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", { + agentCount: 2, + keepAgentsArchived: true, + }); + const [firstAgent, secondAgent] = agents; + if (!firstAgent || !secondAgent) { + throw new Error("Expected two archived agents"); + } + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openSessions(page); + await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click(); + + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await page.getByTestId("workspace-recovery-action").click(); + await expect + .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(true); + await expect( + page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect + .poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 }) + .toBeNull(); + await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, { + timeout: 30_000, + }); + + await openSessions(page); + await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click(); + await expect( + page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({ + timeout: 30_000, + }); + await page.getByRole("button", { name: "Unarchive" }).click(); + await expect + .poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 }) + .toBeNull(); + }); + test("restore failure stays visible and permits a successful retry", async ({ page }) => { const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry"); const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`; diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index d6f26bc8f92..f51e1335b69 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -110,7 +110,9 @@ function HostWorkspaceRouteContent() { const openValue = getParamValue(globalParams.open); const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); const workspaceExists = useWorkspaceExists(serverId, workspaceId); - const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent"; + const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]); + const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null; + const isAgentOpenIntent = recoveryAgentId !== null; const isOpenIntentWaitingForWorkspace = Boolean( isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists), ); @@ -147,7 +149,6 @@ function HostWorkspaceRouteContent() { } consumedIntentRef.current = consumptionKey; - const openIntent = parseWorkspaceOpenIntent(openValue); if (openIntent) { prepareWorkspaceTab({ serverId, @@ -171,6 +172,7 @@ function HostWorkspaceRouteContent() { hasHydratedWorkspaceLayoutStore, isOpenIntentWaitingForWorkspace, navigation, + openIntent, openValue, rootNavigationState?.key, serverId, @@ -185,10 +187,16 @@ function HostWorkspaceRouteContent() { return null; } - return ; + return ; } -function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { +function WorkspaceDeck({ + recoveryRequested, + recoveryAgentId, +}: { + recoveryRequested: boolean; + recoveryAgentId: string | null; +}) { const activeSelection = useActiveWorkspaceSelection(); const [mountedSelections, setMountedSelections] = useState(() => activeSelection ? [activeSelection] : [], @@ -234,6 +242,7 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { selection={selection} activeSelection={activeSelection} recoveryRequested={recoveryRequested} + recoveryAgentId={recoveryAgentId} onUnmountInactive={unmountWorkspaceSelection} /> ); @@ -246,11 +255,13 @@ function WorkspaceDeckEntry({ selection, activeSelection, recoveryRequested, + recoveryAgentId, onUnmountInactive, }: { selection: ActiveWorkspaceSelection; activeSelection: ActiveWorkspaceSelection; recoveryRequested: boolean; + recoveryAgentId: string | null; onUnmountInactive: (selection: ActiveWorkspaceSelection) => void; }) { const isActive = areWorkspaceSelectionsEqual(selection, activeSelection); @@ -282,6 +293,7 @@ function WorkspaceDeckEntry({ workspaceId={selection.workspaceId} isRouteFocused={isActive} recoveryRequested={isActive && recoveryRequested} + recoveryAgentId={isActive ? recoveryAgentId : null} /> ); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 3746c4e630f..980fd78487d 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -394,6 +394,7 @@ export function AgentList({ serverId, agentId, workspaceId: agent.workspaceId, + pin: true, }); }, [isActionSheetVisible, onAgentSelect], diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index 4bfb5d30bda..fb04bab6235 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -9,6 +9,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { Button } from "@/components/ui/button"; import type { Theme } from "@/styles/theme"; +import { toErrorMessage } from "@/utils/error-messages"; interface ArchivedAgentCalloutProps { serverId: string; @@ -21,6 +22,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const [isUnarchiving, setIsUnarchiving] = useState(false); + const [unarchiveError, setUnarchiveError] = useState(null); const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" }); @@ -32,10 +34,11 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout const handleUnarchive = useCallback(async () => { if (!client || !isConnected || isUnarchiving) return; setIsUnarchiving(true); + setUnarchiveError(null); try { await client.refreshAgent(agentId); } catch (error) { - console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error); + setUnarchiveError(toErrorMessage(error)); setIsUnarchiving(false); } }, [client, isConnected, isUnarchiving, agentId]); @@ -44,16 +47,23 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout - - {t("agentPanel.archived.callout")} - + + + {t("agentPanel.archived.callout")} + + + {unarchiveError ? ( + + {unarchiveError} + + ) : null} @@ -97,8 +107,16 @@ const styles = StyleSheet.create((theme: Theme) => ({ md: theme.spacing[6], }, }, + calloutStack: { + gap: theme.spacing[2], + }, calloutText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, + errorText: { + color: theme.colors.statusDanger, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, })) as unknown as Record; diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 5112cce4e32..141132942f7 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -57,6 +57,7 @@ function createAgentWithStatus({ id, status }: { id: string; status: Agent["stat function createBaseInput(): AgentScreenMachineInput { return { agent: null, + isArchived: false, continuity: { kind: "none" }, missingAgentState: { kind: "idle" }, isConnected: true, @@ -385,6 +386,22 @@ describe("deriveAgentScreenViewState", () => { expect(result.memory.lastReadyAgent).toBeNull(); }); + it("renders an archived agent before provider history is initialized", () => { + const result = deriveAgentScreenViewState({ + input: { + ...createBaseInput(), + agent: createAgent("agent-1"), + isArchived: true, + needsAuthoritativeSync: true, + }, + memory: createBaseMemory(), + }); + + const ready = expectReadyState(result.state); + expect(ready.agent.id).toBe("agent-1"); + expect(ready.sync).toEqual({ status: "idle" }); + }); + it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => { const memory = createBaseMemory(); const input: AgentScreenMachineInput = { diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index b87189b39c4..1b8dca725a7 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -41,6 +41,7 @@ export type AgentScreenMissingState = export interface AgentScreenMachineInput { agent: AgentScreenAgent | null; + isArchived: boolean; missingAgentState: AgentScreenMissingState; isConnected: boolean; isArchivingCurrentAgent: boolean; @@ -61,6 +62,7 @@ function hasOptimisticCreateContinuity(input: AgentScreenMachineInput): boolean function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInput): boolean { return ( + !input.isArchived && !hasOptimisticCreateContinuity(input) && !input.hasHydratedHistoryBefore && (input.needsAuthoritativeSync || input.isHistorySyncing) @@ -164,6 +166,9 @@ function resolveAgentScreenSync(args: { hadInitialSyncFailure: boolean; }): AgentScreenReadySyncState { const { input, hadInitialSyncFailure } = args; + if (input.isArchived) { + return { status: "idle" }; + } if (!input.isConnected) { return { status: "reconnecting" }; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index e2d915eccfa..ed3a1689a6f 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -341,13 +341,14 @@ function useAgentPanelDescriptor( } function AgentPanel() { - const { serverId, target, openFileInWorkspace } = usePaneContext(); + const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isInteractive } = usePaneFocus(); invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( void; @@ -535,6 +538,7 @@ function AgentPanelContent({ return ( ; @@ -585,6 +591,8 @@ function AgentPanelBody({ ); const [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); + const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent); useEffect(() => { lookupAttemptTokenRef.current += 1; @@ -596,6 +604,9 @@ function AgentPanelBody({ return; } if (agentState.id) { + if (workspaceKey) { + resolvePendingAgent(workspaceKey, agentId); + } if (lookupState.tag !== "idle") { setLookupState({ tag: "idle" }); } @@ -618,6 +629,9 @@ function AgentPanelBody({ return; } if (!result) { + if (workspaceKey) { + resolvePendingAgent(workspaceKey, agentId); + } setLookupState({ tag: "not_found", message: `Agent not found: ${agentId}`, @@ -626,6 +640,9 @@ function AgentPanelBody({ } storeFetchedAgentDetail({ serverId, result }); + if (workspaceKey) { + resolvePendingAgent(workspaceKey, agentId); + } setLookupState({ tag: "idle" }); return; }) @@ -635,12 +652,25 @@ function AgentPanelBody({ } const message = toErrorMessage(error); if (isNotFoundErrorMessage(message)) { + if (workspaceKey) { + resolvePendingAgent(workspaceKey, agentId); + } setLookupState({ tag: "not_found", message }); return; } setLookupState({ tag: "error", message }); }); - }, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]); + }, [ + agentId, + agentState.id, + client, + hasSession, + isConnected, + lookupState.tag, + resolvePendingAgent, + serverId, + workspaceKey, + ]); if (lookupState.tag === "not_found") { return ( @@ -894,6 +924,7 @@ function ChatAgentContent({ routeKey: `${serverId}:${agentId ?? ""}`, input: { agent: agent ?? null, + isArchived: agentState.archivedAt !== null, missingAgentState, isConnected, isArchivingCurrentAgent, @@ -947,6 +978,9 @@ function ChatAgentContent({ if (!agentId) { return; } + if (agentState.archivedAt) { + return; + } if (agentState.id && hasAppliedAuthoritativeHistory) { if ( missingAgentState.kind === "resolving" || @@ -1012,6 +1046,7 @@ function ChatAgentContent({ }); }, [ agentState.id, + agentState.archivedAt, hasAppliedAuthoritativeHistory, agentId, client, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 65f4333afff..f41bde08a4d 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -291,11 +291,13 @@ interface WorkspaceScreenProps { workspaceId: string; isRouteFocused?: boolean; recoveryRequested?: boolean; + recoveryAgentId?: string | null; } type WorkspaceScreenContentProps = WorkspaceScreenProps & { isRouteFocused: boolean; recoveryRequested: boolean; + recoveryAgentId: string | null; }; function trimNonEmpty(value: string | null | undefined): string | null { @@ -912,6 +914,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId, isRouteFocused, recoveryRequested, + recoveryAgentId, }: WorkspaceScreenProps) { const navigationFocused = useIsFocused(); return ( @@ -920,6 +923,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId={workspaceId} isRouteFocused={isRouteFocused ?? navigationFocused} recoveryRequested={recoveryRequested ?? false} + recoveryAgentId={recoveryAgentId ?? null} /> ); }); @@ -1715,6 +1719,7 @@ function WorkspaceScreenContent({ workspaceId, isRouteFocused, recoveryRequested, + recoveryAgentId, }: WorkspaceScreenContentProps) { const { t } = useTranslation(); const _insets = useSafeAreaInsets(); @@ -1791,6 +1796,7 @@ function WorkspaceScreenContent({ const workspaceRecovery = useWorkspaceRecovery({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, + agentId: recoveryAgentId, enabled: shouldInspectWorkspaceRecovery( hasHydratedWorkspaces, workspaceDescriptor, diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 3b06aa9c8c1..f7633b34ca2 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -194,6 +194,7 @@ interface ReorderPaneTabsInLayoutInput { export interface WorkspaceTabReconcileState { layout: WorkspaceLayout; pinnedAgentIds?: ReadonlySet | null; + pendingAgentIds?: ReadonlySet | null; hiddenAgentIds?: ReadonlySet | null; } @@ -1653,13 +1654,14 @@ interface EntityTabGroup { function applyPinnedAndHidden(input: { baseAgentIds: Set; pinnedAgentIds: Set; + pendingAgentIds: Set; hiddenAgentIds: Set; knownAgentIds: Set; }): Set { - const { baseAgentIds, pinnedAgentIds, hiddenAgentIds, knownAgentIds } = input; + const { baseAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds } = input; const result = new Set(baseAgentIds); for (const agentId of pinnedAgentIds) { - if (knownAgentIds.has(agentId)) { + if (knownAgentIds.has(agentId) || pendingAgentIds.has(agentId)) { result.add(agentId); } } @@ -1784,6 +1786,7 @@ export function reconcileWorkspaceTabs( findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null; let reconciledFocusedTabId = originalFocusedTabId; const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []); + const pendingAgentIds = new Set(state.pendingAgentIds ?? []); const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); @@ -1795,12 +1798,14 @@ export function reconcileWorkspaceTabs( const visibleAgentIds = applyPinnedAndHidden({ baseAgentIds: activeAgentIds, pinnedAgentIds, + pendingAgentIds, hiddenAgentIds, knownAgentIds, }); const autoOpenSet = applyPinnedAndHidden({ baseAgentIds: autoOpenAgentIds, pinnedAgentIds, + pendingAgentIds, hiddenAgentIds, knownAgentIds, }); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 546016b267c..b65eedd2e0a 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -1591,6 +1591,66 @@ describe("workspace-layout-store actions", () => { expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); }); + it("keeps an explicitly pinned archived agent before its detail is hydrated", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + + store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" }); + store.pinAgent(workspaceKey, "archived-agent"); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + knownAgentIds: [], + standaloneTerminalIds: [], + }); + + expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ + "agent_archived-agent", + ]); + + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + knownAgentIds: [], + standaloneTerminalIds: [], + }); + + expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ + "agent_archived-agent", + ]); + + store.resolvePendingAgent(workspaceKey, "archived-agent"); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + knownAgentIds: [], + standaloneTerminalIds: [], + }); + + expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([]); + + store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" }); + store.pinAgent(workspaceKey, "archived-agent"); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + knownAgentIds: [], + standaloneTerminalIds: [], + }); + + expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ + "agent_archived-agent", + ]); + }); + it("retargeting a tab to an agent clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 896be66a6f0..2f84c53b8b2 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -75,6 +75,7 @@ interface WorkspaceLayoutStore { layoutByWorkspace: Record; splitSizesByWorkspace: Record>; pinnedAgentIdsByWorkspace: Record>; + pendingAgentIdsByWorkspace: Record>; hiddenAgentIdsByWorkspace: Record>; focusRestorationByWorkspace: Record; openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; @@ -89,6 +90,7 @@ interface WorkspaceLayoutStore { retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null; convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null; reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void; + resolvePendingAgent: (workspaceKey: string, agentId: string) => void; reorderTabs: (workspaceKey: string, tabIds: string[]) => void; getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[]; splitPane: ( @@ -229,6 +231,7 @@ export function createWorkspaceLayoutStore( layoutByWorkspace: {}, splitSizesByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, + pendingAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, openTabFocused: (workspaceKey, target) => { @@ -467,6 +470,7 @@ export function createWorkspaceLayoutStore( { layout: currentLayout, pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, + pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, }, snapshot, @@ -483,6 +487,25 @@ export function createWorkspaceLayoutStore( }; }); }, + resolvePendingAgent: (workspaceKey, agentId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedWorkspaceKey || !normalizedAgentId) { + return; + } + + set((state) => { + const pendingAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( + state.pendingAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ); + if (pendingAgentIdsByWorkspace === state.pendingAgentIdsByWorkspace) { + return state; + } + return { pendingAgentIdsByWorkspace }; + }); + }, reorderTabs: (workspaceKey, tabIds) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -762,7 +785,12 @@ export function createWorkspaceLayoutStore( set((state) => { const currentPinnedAgentIds = state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; - if (currentPinnedAgentIds?.has(normalizedAgentId)) { + const currentPendingAgentIds = + state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; + if ( + currentPinnedAgentIds?.has(normalizedAgentId) && + currentPendingAgentIds?.has(normalizedAgentId) + ) { return state; } @@ -779,6 +807,11 @@ export function createWorkspaceLayoutStore( ...state.pinnedAgentIdsByWorkspace, [normalizedWorkspaceKey]: nextPinnedAgentIds, }, + pendingAgentIdsByWorkspace: addAgentIdToWorkspaceSet( + state.pendingAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ), }; }); }, @@ -803,6 +836,11 @@ export function createWorkspaceLayoutStore( delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey]; return { pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace, + pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( + state.pendingAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ), }; } @@ -814,6 +852,11 @@ export function createWorkspaceLayoutStore( ...state.pinnedAgentIdsByWorkspace, [normalizedWorkspaceKey]: nextPinnedAgentIds, }, + pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( + state.pendingAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ), }; }); }, @@ -872,6 +915,7 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.layoutByWorkspace || normalizedWorkspaceKey in state.splitSizesByWorkspace || normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || + normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace || normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || normalizedWorkspaceKey in state.focusRestorationByWorkspace; if (!hasAny) { @@ -883,6 +927,8 @@ export function createWorkspaceLayoutStore( state.splitSizesByWorkspace; const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } = state.pinnedAgentIdsByWorkspace; + const { [normalizedWorkspaceKey]: _pending, ...pendingAgentIdsByWorkspace } = + state.pendingAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } = state.hiddenAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = @@ -891,6 +937,7 @@ export function createWorkspaceLayoutStore( layoutByWorkspace, splitSizesByWorkspace, pinnedAgentIdsByWorkspace, + pendingAgentIdsByWorkspace, hiddenAgentIdsByWorkspace, focusRestorationByWorkspace, }; diff --git a/packages/app/src/workspace-recovery/model.test.ts b/packages/app/src/workspace-recovery/model.test.ts index c4a6de7a210..46f7aba8648 100644 --- a/packages/app/src/workspace-recovery/model.test.ts +++ b/packages/app/src/workspace-recovery/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveWorkspaceRecoveryModel } from "./model"; +import { recoverWorkspaceSelection, resolveWorkspaceRecoveryModel } from "./model"; describe("resolveWorkspaceRecoveryModel", () => { it("keeps newer recovery actions visible but non-actionable", () => { @@ -29,3 +29,24 @@ describe("resolveWorkspaceRecoveryModel", () => { }); }); }); + +describe("recoverWorkspaceSelection", () => { + it("restores the workspace and selected archived agent as one recovery action", async () => { + const operations: string[] = []; + + await recoverWorkspaceSelection({ + workspaceId: "workspace-1", + agentId: "agent-1", + client: { + restoreWorkspace: async (workspaceId) => { + operations.push(`workspace:${workspaceId}`); + }, + refreshAgent: async (agentId) => { + operations.push(`agent:${agentId}`); + }, + }, + }); + + expect(operations).toEqual(["workspace:workspace-1", "agent:agent-1"]); + }); +}); diff --git a/packages/app/src/workspace-recovery/model.ts b/packages/app/src/workspace-recovery/model.ts index 5d5ebf15e09..2c85a06f4a8 100644 --- a/packages/app/src/workspace-recovery/model.ts +++ b/packages/app/src/workspace-recovery/model.ts @@ -34,6 +34,22 @@ export interface WorkspaceRecoveryController { retryInspection: () => void; } +export interface WorkspaceSelectionRecoveryClient { + restoreWorkspace: (workspaceId: string) => Promise; + refreshAgent: (agentId: string) => Promise; +} + +export async function recoverWorkspaceSelection(input: { + client: WorkspaceSelectionRecoveryClient; + workspaceId: string; + agentId?: string | null; +}): Promise { + await input.client.restoreWorkspace(input.workspaceId); + if (input.agentId) { + await input.client.refreshAgent(input.agentId); + } +} + function resolveRecoveryPhase(input: { pending: boolean; error: string | null; diff --git a/packages/app/src/workspace-recovery/use-workspace-recovery.ts b/packages/app/src/workspace-recovery/use-workspace-recovery.ts index d91aa3ce6e3..ab199571904 100644 --- a/packages/app/src/workspace-recovery/use-workspace-recovery.ts +++ b/packages/app/src/workspace-recovery/use-workspace-recovery.ts @@ -4,7 +4,11 @@ import { useFetchQuery } from "@/data/query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { toErrorMessage } from "@/utils/error-messages"; -import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model"; +import { + recoverWorkspaceSelection, + resolveWorkspaceRecoveryModel, + type WorkspaceRecoveryController, +} from "./model"; export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model"; @@ -28,6 +32,7 @@ function waitForMinimumRecoveryLoadingTime(): Promise { export function useWorkspaceRecovery(input: { serverId: string; workspaceId: string; + agentId?: string | null; enabled: boolean; }): WorkspaceRecoveryController { const client = useHostRuntimeClient(input.serverId); @@ -56,7 +61,11 @@ export function useWorkspaceRecovery(input: { } await waitForRecoveryLoadingPresentation(); await waitForMinimumRecoveryLoadingTime(); - await client.restoreWorkspace(input.workspaceId); + await recoverWorkspaceSelection({ + client, + workspaceId: input.workspaceId, + agentId: input.agentId, + }); }, }); diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index e0e806b014c..6f54864b301 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -11,7 +11,12 @@ import { toAgentPersistenceHandle, } from "../persistence-hooks.js"; -const pendingAgentInitializations = new Map>(); +interface PendingAgentInitialization { + promise: Promise; + options: { broadcastTimeline: boolean }; +} + +const pendingAgentInitializations = new Map(); export type AgentLoaderManager = Pick< AgentManager, @@ -27,6 +32,7 @@ export interface EnsureAgentLoadedDeps { agentManager: AgentLoaderManager; agentStorage: AgentStorage; validProviders?: Iterable; + broadcastTimeline?: boolean; logger: Logger; } @@ -58,6 +64,13 @@ export async function ensureAgentLoaded( deps: EnsureAgentLoadedDeps, ): Promise { await deps.agentManager.waitForAgentClose?.(agentId); + + const inflight = pendingAgentInitializations.get(agentId); + if (inflight) { + inflight.options.broadcastTimeline ||= deps.broadcastTimeline === true; + return inflight.promise; + } + const existing = deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId); if (existing) { @@ -69,11 +82,15 @@ export async function ensureAgentLoaded( // before storage-backed resume begins. await deps.agentManager.waitForAgentClose?.(agentId); - const inflight = pendingAgentInitializations.get(agentId); - if (inflight) { - return inflight; + const laterInflight = pendingAgentInitializations.get(agentId); + if (laterInflight) { + laterInflight.options.broadcastTimeline ||= deps.broadcastTimeline === true; + return laterInflight.promise; } + const pendingOptions = { + broadcastTimeline: deps.broadcastTimeline === true, + }; const initPromise = (async () => { const record = await deps.agentStorage.get(agentId); if (!record) { @@ -111,17 +128,20 @@ export async function ensureAgentLoaded( deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); } - await deps.agentManager.hydrateTimelineFromProvider(agentId); + await deps.agentManager.hydrateTimelineFromProvider(agentId, { + broadcast: () => pendingOptions.broadcastTimeline, + }); return deps.agentManager.getAgent(agentId) ?? snapshot; })(); - pendingAgentInitializations.set(agentId, initPromise); + const pending: PendingAgentInitialization = { promise: initPromise, options: pendingOptions }; + pendingAgentInitializations.set(agentId, pending); try { return await initPromise; } finally { const current = pendingAgentInitializations.get(agentId); - if (current === initPromise) { + if (current === pending) { pendingAgentInitializations.delete(agentId); } } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 74d3a32f3a3..fa1d77f5c1d 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -7518,6 +7518,76 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share } }); +test("a shared agent load upgrades provider history hydration to broadcast", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shared-load-broadcast-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const historyStarted = deferred(); + const historyAllowed = deferred(); + const client = new (class extends TestAgentClient { + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new (class extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + historyStarted.resolve(); + await historyAllowed.promise; + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "Recovered history" }, + }; + } + })({ provider: "codex", cwd: config?.cwd ?? workdir }); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + await manager.deleteAgentState(agent.id); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { agentId: agent.id, replayState: false }); + + const quietLoad = ensureAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + await historyStarted.promise; + const broadcastingLoad = ensureAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + broadcastTimeline: true, + logger, + }); + historyAllowed.resolve(); + await Promise.all([quietLoad, broadcastingLoad]); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "agent_stream", + agentId: agent.id, + event: expect.objectContaining({ + type: "timeline", + item: { type: "assistant_message", text: "Recovered history" }, + }), + }), + ); + } finally { + historyAllowed.resolve(); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-")); const client = new (class extends TestAgentClient { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 32931c09716..cfda95c1bc9 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -185,7 +185,7 @@ export interface SubscribeOptions { interface HydrateTimelineOptions { force?: boolean; - broadcast?: boolean; + broadcast?: boolean | (() => boolean); } export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & { @@ -3177,12 +3177,17 @@ export class AgentManager { return; } + const broadcast = options?.broadcast ?? false; + if (options?.force) { - await this.forceHydrateTimelineFromLegacyProviderHistory(agent, options.broadcast === true); + await this.forceHydrateTimelineFromLegacyProviderHistory( + agent, + typeof broadcast === "function" ? broadcast() : broadcast, + ); return; } - await this.primeTimelineFromLegacyProviderHistory(agent, options?.broadcast === true); + await this.primeTimelineFromLegacyProviderHistory(agent, broadcast); } private async forceHydrateTimelineFromLegacyProviderHistory( @@ -3239,15 +3244,24 @@ export class AgentManager { private async primeTimelineFromLegacyProviderHistory( agent: ActiveManagedAgent, - broadcast: boolean, + broadcast: boolean | (() => boolean), ): Promise { + const deferredBroadcast = typeof broadcast === "function"; + const timelineEvents: Array<{ + event: Extract; + row: AgentTimelineRow; + }> = []; + const providerSubagentEvents: AgentManagerEvent[] = []; agent.historyPrimed = true; try { for await (const event of agent.session.streamHistory()) { if (event.type === "provider_subagent") { const update = this.providerSubagents.apply(agent.id, event.provider, event.event); - if (broadcast) { - this.dispatch({ type: "provider_subagent", event: update }); + const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update }; + if (deferredBroadcast) { + providerSubagentEvents.push(managerEvent); + } else if (broadcast) { + this.dispatch(managerEvent); } continue; } @@ -3257,15 +3271,38 @@ export class AgentManager { if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { continue; } - this.recordTimeline( + const row = this.recordTimeline( agent.id, event.item, event.timestamp ? { timestamp: event.timestamp } : undefined, ); + if (deferredBroadcast) { + timelineEvents.push({ event, row }); + } else if (broadcast) { + this.dispatchStream(agent.id, event, { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }); + } } } catch { // ignore history failures } + + if (typeof broadcast !== "function" || !broadcast()) { + return; + } + for (const event of providerSubagentEvents) { + this.dispatch(event); + } + for (const { event, row } of timelineEvents) { + this.dispatchStream(agent.id, event, { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }); + } } private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 53238981540..84c9df422f3 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -5632,6 +5632,7 @@ describe("agent snapshot MCP serialization", () => { expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled(); expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith( "archived-activity-agent", + { broadcast: expect.any(Function) }, ); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0ee41228677..2192ad69b94 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -30,12 +30,7 @@ import { CursorError } from "./pagination/cursor.js"; import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js"; -import { - buildConfigOverrides, - extractTimestamps, - isStoredAgentProviderAvailable, - toAgentPersistenceHandle, -} from "./persistence-hooks.js"; +import { isStoredAgentProviderAvailable, toAgentPersistenceHandle } from "./persistence-hooks.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js"; import { formatSystemNotificationPrompt, @@ -3183,16 +3178,18 @@ export class Session { if (!isStoredAgentProviderAvailable(record, registeredProviderIds)) { throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); } - const handle = toAgentPersistenceHandle(registeredProviderIds, record.persistence); - if (!handle) { + if (!toAgentPersistenceHandle(registeredProviderIds, record.persistence)) { throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`); } - snapshot = await this.agentManager.resumeAgentFromPersistence( - handle, - buildConfigOverrides(record), - agentId, - extractTimestamps(record), - ); + // Share the loader's per-agent in-flight operation with timeline fetches. + // Unarchiving publishes the record before provider resume finishes, so + // the agent pane can otherwise race this request and resume it twice. + snapshot = await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + broadcastTimeline: true, + logger: this.sessionLogger, + }); } await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true }); await this.agentUpdates.forwardLiveAgent(snapshot); From bd937850b3fd2db5c698e74ebc55ce14a90c1b4b Mon Sep 17 00:00:00 2001 From: nikuscs Date: Wed, 22 Jul 2026 14:58:24 +0100 Subject: [PATCH 011/420] feat: add workspace files to chat from Files and Changes * feat(app): open changed files from context menu * fix(app): preserve changed-file row behavior * feat(app): add changed files to open chats * feat(app): share file actions across explorer and changes * feat(app): attach workspace files to focused chat * feat(app): drag workspace files into chat * refactor(app): use file actions menu for changed files * fix(app): open changed-file actions on right click * test(app): cover direct file attachment flow * fix(app): enable add to chat on native * fix(app): align workspace file pane rows * refactor(app): simplify workspace file attachments * fix(app): preserve uploaded file draft attachments --------- Co-authored-by: Mohamed Boudra --- .../app/e2e/add-changed-file-to-chat.spec.ts | 49 ++++ packages/app/e2e/diff-row-alignment.spec.ts | 102 ++++++- packages/app/src/attachments/types.ts | 11 + .../use-workspace-file-drag-source.ts | 9 + .../use-workspace-file-drag-source.web.ts | 54 ++++ .../workspace-file-drag-source.types.ts | 10 + .../attachments/workspace-file-drag.test.ts | 62 ++++ .../src/attachments/workspace-file-drag.ts | 55 ++++ .../src/attachments/workspace-file.test.ts | 71 +++++ .../app/src/attachments/workspace-file.ts | 106 +++++++ packages/app/src/components/diff-stat.tsx | 5 +- .../app/src/components/explorer-sidebar.tsx | 93 +++++- .../app/src/components/file-actions-menu.tsx | 185 ++++++++++++ .../app/src/components/file-drop/types.ts | 2 + .../file-drop/use-drop-listeners.ts | 24 +- .../app/src/components/file-explorer-pane.tsx | 265 ++++++++---------- .../app/src/components/tree-primitives.tsx | 1 + packages/app/src/composer/actions.ts | 2 +- .../app/src/composer/attachments/submit.ts | 6 + .../composer/draft/input-draft.live.test.tsx | 40 ++- .../app/src/composer/draft/input-draft.ts | 149 ++++------ .../app/src/composer/draft/workspace-tab.tsx | 2 + .../src/composer/focused-chat-target.test.ts | 49 ++++ .../app/src/composer/focused-chat-target.ts | 48 ++++ packages/app/src/composer/index.tsx | 87 +++++- packages/app/src/git/diff-folder-row.tsx | 23 +- packages/app/src/git/diff-pane.tsx | 208 ++++++++++++-- packages/app/src/hooks/use-file-download.ts | 56 ++++ packages/app/src/i18n/resources/ar.ts | 9 +- packages/app/src/i18n/resources/en.ts | 9 +- packages/app/src/i18n/resources/es.ts | 9 +- packages/app/src/i18n/resources/fr.ts | 9 +- packages/app/src/i18n/resources/ja.ts | 9 +- packages/app/src/i18n/resources/pt-BR.ts | 9 +- packages/app/src/i18n/resources/ru.ts | 9 +- packages/app/src/i18n/resources/zh-CN.ts | 9 +- packages/app/src/panels/agent-panel.tsx | 26 +- packages/app/src/stores/draft-store/index.ts | 55 ++-- .../app/src/stores/draft-store/state.test.ts | 40 +++ packages/app/src/stores/draft-store/state.ts | 20 +- .../utils/file-mention-autocomplete.test.ts | 14 +- .../src/utils/file-mention-autocomplete.ts | 8 +- 42 files changed, 1666 insertions(+), 343 deletions(-) create mode 100644 packages/app/e2e/add-changed-file-to-chat.spec.ts create mode 100644 packages/app/src/attachments/use-workspace-file-drag-source.ts create mode 100644 packages/app/src/attachments/use-workspace-file-drag-source.web.ts create mode 100644 packages/app/src/attachments/workspace-file-drag-source.types.ts create mode 100644 packages/app/src/attachments/workspace-file-drag.test.ts create mode 100644 packages/app/src/attachments/workspace-file-drag.ts create mode 100644 packages/app/src/attachments/workspace-file.test.ts create mode 100644 packages/app/src/attachments/workspace-file.ts create mode 100644 packages/app/src/components/file-actions-menu.tsx create mode 100644 packages/app/src/composer/focused-chat-target.test.ts create mode 100644 packages/app/src/composer/focused-chat-target.ts create mode 100644 packages/app/src/hooks/use-file-download.ts diff --git a/packages/app/e2e/add-changed-file-to-chat.spec.ts b/packages/app/e2e/add-changed-file-to-chat.spec.ts new file mode 100644 index 00000000000..6b5f55001f7 --- /dev/null +++ b/packages/app/e2e/add-changed-file-to-chat.spec.ts @@ -0,0 +1,49 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { test, expect, type Page } from "./fixtures"; +import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent"; + +function visibleComposer(page: Page) { + return page.locator("textarea[data-composer-input]").filter({ visible: true }).first(); +} + +test("adds a changed file to the focused chat without replacing its composer draft", async ({ + page, +}) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "add-file-to-chat-", + title: "Target chat", + }); + const relativePath = "src/changed file.ts"; + + try { + await mkdir(path.join(workspace.cwd, "src"), { recursive: true }); + await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n"); + await workspace.client.checkoutRefresh(workspace.cwd); + + await page.setViewportSize({ width: 1400, height: 900 }); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + const agentComposer = visibleComposer(page); + await expect(agentComposer).toBeEditable({ timeout: 30_000 }); + await agentComposer.fill("Preserve this thought"); + + await page.getByRole("button", { name: "Open explorer" }).click(); + await page.getByTestId("explorer-tab-changes").click(); + const changedFile = page.getByText("changed file.ts", { exact: true }).first(); + await expect(changedFile).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await page.getByTestId("diff-file-0-add-to-chat").click(); + + const attachment = page.getByTestId("composer-workspace-file-attachment-pill"); + await expect(attachment).toContainText("changed file.ts"); + await expect(attachment).toContainText(relativePath); + await expect(agentComposer).toHaveValue("Preserve this thought"); + await expect(agentComposer).toBeFocused(); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/diff-row-alignment.spec.ts b/packages/app/e2e/diff-row-alignment.spec.ts index 60e1e91b461..fecb6529f1f 100644 --- a/packages/app/e2e/diff-row-alignment.spec.ts +++ b/packages/app/e2e/diff-row-alignment.spec.ts @@ -1,4 +1,4 @@ -import { writeFile } from "node:fs/promises"; +import { unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes"; @@ -12,6 +12,10 @@ interface DirtyWorkspace { id: string; } +interface WorkspaceFixtureOptions { + includeDeletedFile?: boolean; +} + interface CleanupTask { run: () => Promise; } @@ -218,6 +222,25 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) => }); }); +test("changes file actions open from the kebab and right-click", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts"); + await page.getByTestId("diff-file-1-actions").click(); + await expect(page.getByText("Copy path")).toBeVisible(); + await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible(); + await page.getByTestId("diff-file-0-open-file").click(); + + await expect(page.getByTestId("workspace-file-pane")).toBeVisible(); + await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible(); +}); + test("changes diff switches between flat and tree file lists", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); @@ -250,6 +273,53 @@ test("changes diff switches between flat and tree file lists", async ({ page }) await expectFlatFileList(page); }); +test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await openWorkspaceChanges(page, workspace); + + await page.getByTestId("changes-toggle-view-mode").click(); + await expect(page.getByTestId("diff-folder-src")).toBeVisible(); + + const changesRightRail = await Promise.all([ + readSvgRight(page, "explorer-close"), + readSvgRight(page, "changes-options-menu"), + readSvgRight(page, "diff-file-0-actions"), + ]); + expectAligned(changesRightRail); + + const [folderStat, fileStat] = await Promise.all([ + page.getByTestId("diff-folder-src-stat").boundingBox(), + page.getByTestId("diff-file-0-stat").boundingBox(), + ]); + expect(folderStat).not.toBeNull(); + expect(fileStat).not.toBeNull(); + expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0); + + await page.getByTestId("explorer-tab-files").click(); + await expect(page.getByTestId("file-explorer-row-0")).toBeVisible(); + + const filesRightRail = await Promise.all([ + readSvgRight(page, "explorer-close"), + readSvgRight(page, "files-refresh"), + readSvgRight(page, "file-explorer-row-0-actions"), + ]); + expectAligned(filesRightRail); + + const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([ + page.getByTestId("files-sort-label").boundingBox(), + page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(), + page.getByTestId("file-explorer-tree-scroll").boundingBox(), + page.getByTestId("file-explorer-row-0").boundingBox(), + ]); + expect(sortLabel).not.toBeNull(); + expect(firstRowIcon).not.toBeNull(); + expect(treeBounds).not.toBeNull(); + expect(rowBounds).not.toBeNull(); + expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0); + expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0); + expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0); +}); + test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({ page, }) => { @@ -399,10 +469,14 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{ }); } -async function createWorkspaceWithMountedTabDiff(): Promise { - const repo = await createTempGitRepo("diff-row-alignment-", { - files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }], - }); +async function createWorkspaceWithMountedTabDiff( + options: WorkspaceFixtureOptions = {}, +): Promise { + const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }]; + if (options.includeDeletedFile) { + files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" }); + } + const repo = await createTempGitRepo("diff-row-alignment-", { files }); const client = await connectSeedClient(); cleanupTasks.push({ run: async () => { @@ -412,6 +486,9 @@ async function createWorkspaceWithMountedTabDiff(): Promise { }); await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER); + if (options.includeDeletedFile) { + await unlink(path.join(repo.path, "src/zz-deleted.ts")); + } const createdWorkspace = await client.createWorkspace({ source: { kind: "directory", path: repo.path }, }); @@ -436,6 +513,21 @@ async function openChangesInVisibleExplorer(page: Page): Promise { await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 }); } +async function readSvgRight(page: Page, testID: string): Promise { + const box = await page.getByTestId(testID).locator("svg").first().boundingBox(); + if (!box) { + throw new Error(`Could not measure ${testID}`); + } + return box.x + box.width; +} + +function expectAligned(values: number[]): void { + const [first, ...rest] = values; + for (const value of rest) { + expect(value).toBeCloseTo(first, 0); + } +} + async function expectExpandedMountedTabDiff(page: Page): Promise { await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({ diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index b862a543acf..7e53567b23b 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -92,9 +92,20 @@ export interface ChatHistoryContextAttachment { export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker"; +export type WorkspaceFileSelection = + | { kind: "whole_file" } + | { kind: "line_range"; startLine: number; endLine: number }; + +export interface WorkspaceFileComposerAttachment { + kind: "workspace_file"; + path: string; + selection: WorkspaceFileSelection; +} + export type UserComposerAttachment = | { kind: "image"; metadata: AttachmentMetadata } | { kind: "file"; attachment: UploadedFileAttachment } + | WorkspaceFileComposerAttachment | { kind: "forge_issue"; item: ForgeSearchItem } | { kind: "forge_change_request"; item: ForgeSearchItem } // COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106 diff --git a/packages/app/src/attachments/use-workspace-file-drag-source.ts b/packages/app/src/attachments/use-workspace-file-drag-source.ts new file mode 100644 index 00000000000..1f8615d6c70 --- /dev/null +++ b/packages/app/src/attachments/use-workspace-file-drag-source.ts @@ -0,0 +1,9 @@ +import type { RefCallback } from "react"; +import type { View } from "react-native"; +import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types"; + +export function useWorkspaceFileDragSource( + _input: WorkspaceFileDragSourceInput, +): RefCallback | undefined { + return undefined; +} diff --git a/packages/app/src/attachments/use-workspace-file-drag-source.web.ts b/packages/app/src/attachments/use-workspace-file-drag-source.web.ts new file mode 100644 index 00000000000..e8de9ec2410 --- /dev/null +++ b/packages/app/src/attachments/use-workspace-file-drag-source.web.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useState, type RefCallback } from "react"; +import type { View } from "react-native"; +import { createWorkspaceFileAttachment } from "./workspace-file"; +import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag"; +import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types"; + +export function useWorkspaceFileDragSource({ + enabled, + disabled = false, + serverId, + workspaceId, + path, + selection, +}: WorkspaceFileDragSourceInput): RefCallback { + const [element, setElement] = useState(null); + const dragSourceRef = useCallback((node: View | null) => { + setElement(node as unknown as HTMLElement | null); + }, []); + + useEffect(() => { + if (!element || !enabled || disabled || !serverId || !workspaceId) { + if (element) { + element.draggable = false; + } + return; + } + + const sourceServerId = serverId; + const sourceWorkspaceId = workspaceId; + element.draggable = true; + function handleDragStart(event: DragEvent) { + if (!event.dataTransfer) { + return; + } + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData( + WORKSPACE_FILE_DRAG_MIME, + serializeWorkspaceFileDragPayload({ + version: 1, + serverId: sourceServerId, + workspaceId: sourceWorkspaceId, + attachment: createWorkspaceFileAttachment({ path, selection }), + }), + ); + } + element.addEventListener("dragstart", handleDragStart); + return () => { + element.draggable = false; + element.removeEventListener("dragstart", handleDragStart); + }; + }, [disabled, element, enabled, path, selection, serverId, workspaceId]); + + return dragSourceRef; +} diff --git a/packages/app/src/attachments/workspace-file-drag-source.types.ts b/packages/app/src/attachments/workspace-file-drag-source.types.ts new file mode 100644 index 00000000000..04b84b7b860 --- /dev/null +++ b/packages/app/src/attachments/workspace-file-drag-source.types.ts @@ -0,0 +1,10 @@ +import type { WorkspaceFileSelection } from "./types"; + +export interface WorkspaceFileDragSourceInput { + enabled: boolean; + disabled?: boolean; + serverId?: string; + workspaceId: string | null | undefined; + path: string; + selection?: WorkspaceFileSelection; +} diff --git a/packages/app/src/attachments/workspace-file-drag.test.ts b/packages/app/src/attachments/workspace-file-drag.test.ts new file mode 100644 index 00000000000..9c4597f0d33 --- /dev/null +++ b/packages/app/src/attachments/workspace-file-drag.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { createWorkspaceFileAttachment } from "./workspace-file"; +import { + parseWorkspaceFileDragPayload, + resolveWorkspaceFileDrop, + serializeWorkspaceFileDragPayload, + type WorkspaceFileDragPayload, +} from "./workspace-file-drag"; + +function payload(): WorkspaceFileDragPayload { + return { + version: 1, + serverId: "server-1", + workspaceId: "workspace-1", + attachment: createWorkspaceFileAttachment({ + path: "src/app.ts", + selection: { kind: "line_range", startLine: 12, endLine: 24 }, + }), + }; +} + +describe("workspace file drag payload", () => { + it("round-trips workspace identity and future line selections", () => { + expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual( + payload(), + ); + }); + + it("rejects malformed and invalid payloads", () => { + expect(parseWorkspaceFileDragPayload("not json")).toBeNull(); + expect( + parseWorkspaceFileDragPayload( + JSON.stringify({ + ...payload(), + attachment: { + kind: "workspace_file", + path: "src/app.ts", + selection: { kind: "line_range", startLine: 24, endLine: 12 }, + }, + }), + ), + ).toBeNull(); + }); + + it("accepts drops only within the originating server and workspace", () => { + const dragged = payload(); + expect( + resolveWorkspaceFileDrop({ + payload: dragged, + serverId: "server-1", + workspaceId: "workspace-1", + }), + ).toEqual(dragged.attachment); + expect( + resolveWorkspaceFileDrop({ + payload: dragged, + serverId: "server-1", + workspaceId: "workspace-2", + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/attachments/workspace-file-drag.ts b/packages/app/src/attachments/workspace-file-drag.ts new file mode 100644 index 00000000000..50904f98f65 --- /dev/null +++ b/packages/app/src/attachments/workspace-file-drag.ts @@ -0,0 +1,55 @@ +import type { WorkspaceFileComposerAttachment } from "./types"; +import { isWorkspaceFileComposerAttachment } from "./workspace-file"; + +export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json"; + +export interface WorkspaceFileDragPayload { + version: 1; + serverId: string; + workspaceId: string; + attachment: WorkspaceFileComposerAttachment; +} + +export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string { + return JSON.stringify(payload); +} + +export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null { + let value: unknown; + try { + value = JSON.parse(serialized); + } catch { + return null; + } + if (!value || typeof value !== "object") { + return null; + } + const record = value as Record; + if ( + record.version !== 1 || + typeof record.serverId !== "string" || + record.serverId.length === 0 || + typeof record.workspaceId !== "string" || + record.workspaceId.length === 0 || + !isWorkspaceFileComposerAttachment(record.attachment) + ) { + return null; + } + return { + version: 1, + serverId: record.serverId, + workspaceId: record.workspaceId, + attachment: record.attachment, + }; +} + +export function resolveWorkspaceFileDrop(input: { + payload: WorkspaceFileDragPayload; + serverId: string; + workspaceId: string; +}): WorkspaceFileComposerAttachment | null { + return input.payload.serverId === input.serverId && + input.payload.workspaceId === input.workspaceId + ? input.payload.attachment + : null; +} diff --git a/packages/app/src/attachments/workspace-file.test.ts b/packages/app/src/attachments/workspace-file.test.ts new file mode 100644 index 00000000000..44cc1e7f868 --- /dev/null +++ b/packages/app/src/attachments/workspace-file.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import type { UserComposerAttachment } from "@/attachments/types"; +import { + appendWorkspaceFileAttachment, + createWorkspaceFileAttachment, + getWorkspaceFileAttachmentKey, + getWorkspaceFileAttachmentSubtitle, + workspaceFileAttachmentToAgentAttachment, +} from "./workspace-file"; +import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; + +describe("workspace file attachments", () => { + it("models whole files and line ranges as distinct selections", () => { + const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" }); + const lineRange = createWorkspaceFileAttachment({ + path: "src/app.ts", + selection: { kind: "line_range", startLine: 12, endLine: 24 }, + }); + + expect(wholeFile).toEqual({ + kind: "workspace_file", + path: "src/app.ts", + selection: { kind: "whole_file" }, + }); + expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe( + getWorkspaceFileAttachmentKey(lineRange), + ); + expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts"); + expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24"); + }); + + it("deduplicates only identical paths and selections", () => { + const image = { + kind: "image" as const, + metadata: { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb" as const, + storageKey: "image-1", + createdAt: 1, + }, + }; + const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" }); + const range = createWorkspaceFileAttachment({ + path: "src/app.ts", + selection: { kind: "line_range", startLine: 1, endLine: 5 }, + }); + const current: UserComposerAttachment[] = [image, wholeFile]; + + expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current); + expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]); + }); + + it("submits a path reference without uploading or inserting prompt text", () => { + const attachment = createWorkspaceFileAttachment({ + path: "src/app.ts", + selection: { kind: "line_range", startLine: 12, endLine: 24 }, + }); + + expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({ + type: "text", + mimeType: "text/plain", + title: "app.ts", + text: "Workspace file: src/app.ts\nLines: 12-24", + }); + expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({ + images: [], + attachments: [workspaceFileAttachmentToAgentAttachment(attachment)], + }); + }); +}); diff --git a/packages/app/src/attachments/workspace-file.ts b/packages/app/src/attachments/workspace-file.ts new file mode 100644 index 00000000000..f1aa24ea99d --- /dev/null +++ b/packages/app/src/attachments/workspace-file.ts @@ -0,0 +1,106 @@ +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { + UserComposerAttachment, + WorkspaceFileComposerAttachment, + WorkspaceFileSelection, +} from "./types"; + +interface CreateWorkspaceFileAttachmentInput { + path: string; + selection?: WorkspaceFileSelection; +} + +function normalizePath(path: string): string { + return path.trim().replace(/^\.\//, ""); +} + +export function isWorkspaceFileComposerAttachment( + value: unknown, +): value is WorkspaceFileComposerAttachment { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + if ( + record.kind !== "workspace_file" || + typeof record.path !== "string" || + record.path.trim().length === 0 + ) { + return false; + } + const selection = record.selection; + if (!selection || typeof selection !== "object") { + return false; + } + const { kind, startLine, endLine } = selection as Record; + if (kind === "whole_file") { + return true; + } + return ( + kind === "line_range" && + typeof startLine === "number" && + Number.isInteger(startLine) && + typeof endLine === "number" && + Number.isInteger(endLine) && + startLine > 0 && + endLine >= startLine + ); +} + +export function createWorkspaceFileAttachment({ + path, + selection = { kind: "whole_file" }, +}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment { + return { + kind: "workspace_file", + path: normalizePath(path), + selection, + }; +} + +export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string { + const selection = attachment.selection; + const selectionKey = + selection.kind === "whole_file" + ? selection.kind + : `${selection.kind}:${selection.startLine}-${selection.endLine}`; + return `${normalizePath(attachment.path)}:${selectionKey}`; +} + +export function appendWorkspaceFileAttachment( + current: UserComposerAttachment[], + attachment: WorkspaceFileComposerAttachment, +): UserComposerAttachment[] { + const attachmentKey = getWorkspaceFileAttachmentKey(attachment); + const alreadyAttached = current.some( + (candidate) => + candidate.kind === "workspace_file" && + getWorkspaceFileAttachmentKey(candidate) === attachmentKey, + ); + return alreadyAttached ? current : [...current, attachment]; +} + +export function workspaceFileAttachmentToAgentAttachment( + attachment: WorkspaceFileComposerAttachment, +): Extract { + const fileName = attachment.path.split("/").pop() ?? attachment.path; + const lines = + attachment.selection.kind === "line_range" + ? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}` + : ""; + return { + type: "text", + mimeType: "text/plain", + title: fileName, + text: `Workspace file: ${attachment.path}${lines}`, + }; +} + +export function getWorkspaceFileAttachmentSubtitle( + attachment: WorkspaceFileComposerAttachment, +): string { + if (attachment.selection.kind === "whole_file") { + return attachment.path; + } + return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`; +} diff --git a/packages/app/src/components/diff-stat.tsx b/packages/app/src/components/diff-stat.tsx index 154a2162adc..d228793228e 100644 --- a/packages/app/src/components/diff-stat.tsx +++ b/packages/app/src/components/diff-stat.tsx @@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles"; interface DiffStatProps { additions: number; deletions: number; + testID?: string; } const compactFormatter = new Intl.NumberFormat("en-US", { @@ -15,9 +16,9 @@ export function formatDiffCount(value: number): string { return compactFormatter.format(value).toLowerCase(); } -export function DiffStat({ additions, deletions }: DiffStatProps) { +export function DiffStat({ additions, deletions, testID }: DiffStatProps) { return ( - + +{formatDiffCount(additions)} -{formatDiffCount(deletions)} diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 2d7209ae9a4..c233f358246 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -36,6 +36,13 @@ import { RetainedPanelActivity } from "@/components/retained-panel"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; +import { + buildWorkspaceTabPersistenceKey, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { resolveFocusedChatTarget } from "@/composer/focused-chat-target"; +import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; +import { useDraftStore } from "@/stores/draft-store"; function logExplorerSidebar(_event: string, _details: Record): void {} @@ -386,15 +393,16 @@ function ExplorerSidebarContent({ {/* Content based on active tab */} {resolvedTab === "changes" && ( - )} {resolvedTab === "files" && ( - ) { + const workspaceKey = workspaceId + ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) + : null; + const layout = useWorkspaceLayoutStore((state) => + workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, + ); + const focusTab = useWorkspaceLayoutStore((state) => state.focusTab); + const focusedChat = useMemo( + () => resolveFocusedChatTarget({ serverId, layout }), + [serverId, layout], + ); + const addFile = useCallback( + (filePath: string) => { + if (!focusedChat || !workspaceKey) { + return; + } + void useDraftStore.getState().attachWorkspaceFile({ + draftKey: focusedChat.draftKey, + attachment: createWorkspaceFileAttachment({ path: filePath }), + }); + focusTab(workspaceKey, focusedChat.tabId); + }, + [focusTab, focusedChat, workspaceKey], + ); + return { addFile, canAddToChat: focusedChat !== null }; +} + +function ChangedFilesPane({ + serverId, + workspaceId, + workspaceRoot, + isOpen, + onOpenFile, +}: Pick< + SidebarContentProps, + "serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile" +>) { + const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); + return ( + + ); +} + +function FilesPane({ + serverId, + workspaceId, + workspaceRoot, + onOpenFile, +}: Pick) { + const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); + return ( + + ); +} + interface PrTabContentProps { serverId: string; cwd: string; diff --git a/packages/app/src/components/file-actions-menu.tsx b/packages/app/src/components/file-actions-menu.tsx new file mode 100644 index 00000000000..061a47b2ef3 --- /dev/null +++ b/packages/app/src/components/file-actions-menu.tsx @@ -0,0 +1,185 @@ +import { useMemo, type ReactElement, type ReactNode } from "react"; +import { type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + Copy, + Download, + FileText, + MessageSquarePlus, + MoreVertical, + type LucideIcon, +} from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const ThemedMoreVertical = withUnistyles(MoreVertical); + +/** Width occupied by a file action trigger, including its visual padding. */ +export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1]; + +interface FileAction { + key: string; + label: string; + icon: LucideIcon; + onSelect: () => void; + testID?: string; +} + +interface FileActionsMenuProps { + fileKind: "file" | "directory"; + fileExists?: boolean; + onOpenFile?: () => void; + onCopyPath?: () => void; + onDownload?: () => void; + onAddToChat?: () => void; + /** Optional metadata block rendered above the actions (e.g. size/modified). */ + header?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + hitSlop?: number; + accessibilityLabel: string; + testIDPrefix?: string; +} + +// The menu lives inside pressable rows (diff header, explorer entry); stop the +// press so opening it doesn't also trigger the row. +function stopTriggerPropagation(event: { stopPropagation?: () => void }) { + event.stopPropagation?.(); +} + +function triggerStyle({ + hovered, + pressed, + open, +}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) { + return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive]; +} + +/** + * Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and + * git diff pane so both surfaces share action availability, ordering, and chrome. + */ +export function FileActionsMenu({ + fileKind, + fileExists = true, + onOpenFile, + onCopyPath, + onDownload, + onAddToChat, + header, + open, + onOpenChange, + hitSlop = 12, + accessibilityLabel, + testIDPrefix, +}: FileActionsMenuProps): ReactElement | null { + const { t } = useTranslation(); + const actions = useMemo(() => { + const availableFile = fileKind === "file" && fileExists; + const next: FileAction[] = []; + if (availableFile && onOpenFile) { + next.push({ + key: "open-file", + label: t("workspace.fileActions.openFile"), + icon: FileText, + onSelect: onOpenFile, + testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined, + }); + } + if (onCopyPath) { + next.push({ + key: "copy-path", + label: t("workspace.fileActions.copyPath"), + icon: Copy, + onSelect: onCopyPath, + }); + } + if (availableFile && onDownload) { + next.push({ + key: "download", + label: t("workspace.fileActions.download"), + icon: Download, + onSelect: onDownload, + }); + } + if (availableFile && onAddToChat) { + next.push({ + key: "add-to-chat", + label: t("workspace.fileActions.addToChat"), + icon: MessageSquarePlus, + onSelect: onAddToChat, + testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined, + }); + } + return next; + }, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]); + + if (actions.length === 0) { + return null; + } + return ( + + + + + + {header ? ( + <> + {header} + + + ) : null} + {actions.map((action) => ( + + ))} + + + ); +} + +function FileActionMenuItem({ action }: { action: FileAction }): ReactElement { + const Icon = action.icon; + const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]); + const leading = useMemo( + () => , + [ThemedIcon], + ); + return ( + + {action.label} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + // The hover box comes from padding, but an equal negative vertical margin + // cancels its height contribution so the trigger overlaps the row's natural + // line height instead of growing it. The comfortable tap target is `hitSlop`, + // never padding. + padding: theme.spacing[1], + width: FILE_ACTIONS_MENU_WIDTH, + marginVertical: -theme.spacing[1], + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + triggerActive: { + backgroundColor: theme.colors.surface2, + }, +})); diff --git a/packages/app/src/components/file-drop/types.ts b/packages/app/src/components/file-drop/types.ts index 11b44773d4d..89bbbca7c46 100644 --- a/packages/app/src/components/file-drop/types.ts +++ b/packages/app/src/components/file-drop/types.ts @@ -1,4 +1,5 @@ import type { ImageAttachment } from "@/composer/types"; +import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag"; export interface DroppedFileItem { kind: "web-file"; @@ -18,4 +19,5 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem; export interface FileDropSink { onFiles: (images: ImageAttachment[]) => void; onGenericFiles?: (items: DroppedItem[]) => void; + onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void; } diff --git a/packages/app/src/components/file-drop/use-drop-listeners.ts b/packages/app/src/components/file-drop/use-drop-listeners.ts index b8411b50cc6..6262109e71e 100644 --- a/packages/app/src/components/file-drop/use-drop-listeners.ts +++ b/packages/app/src/components/file-drop/use-drop-listeners.ts @@ -11,6 +11,10 @@ import { } from "@/attachments/file-types"; import { isWeb } from "@/constants/platform"; import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types"; +import { + parseWorkspaceFileDragPayload, + WORKSPACE_FILE_DRAG_MIME, +} from "@/attachments/workspace-file-drag"; type DesktopDragDropPayload = | { type: "enter"; paths: string[] } @@ -185,7 +189,11 @@ export function useDropListeners({ if (disabledRef.current) return; dragCounter.current++; - if (e.dataTransfer?.types.includes("Files")) { + if (suppressed.value || !hasSink.value) return; + const types = new Set(e.dataTransfer?.types ?? []); + const acceptsWorkspaceFile = + types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile); + if (types.has("Files") || acceptsWorkspaceFile) { isDragging.value = true; } } @@ -197,7 +205,11 @@ export function useDropListeners({ if (!e.dataTransfer) return; // Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't // promise a drop that the handler then discards (suppressed/archived/no consumer mounted). - const canAccept = !disabledRef.current && !suppressed.value && hasSink.value; + const types = new Set(e.dataTransfer.types); + const acceptsWorkspaceFile = + types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile); + const acceptsDrop = types.has("Files") || acceptsWorkspaceFile; + const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value; e.dataTransfer.dropEffect = canAccept ? "copy" : "none"; } @@ -225,6 +237,14 @@ export function useDropListeners({ const sink = getSink(); if (!sink) return; + const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME); + if (serializedWorkspaceFile && sink.onWorkspaceFile) { + const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile); + if (payload) { + sink.onWorkspaceFile(payload); + } + } + const files = Array.from(e.dataTransfer?.files ?? []); const genericItems: DroppedItem[] = files.map((file) => ({ kind: "web-file", diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 206474503c2..d0f1c227e88 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -15,35 +15,26 @@ import { import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import * as Clipboard from "expo-clipboard"; -import { - ChevronDown, - Copy, - Download, - Eye, - EyeOff, - MoreVertical, - RotateCw, -} from "lucide-react-native"; +import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native"; import { MaterialFileIcon } from "@/components/material-file-icon"; -import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives"; +import { + TreeChevron, + TreeIndentGuides, + treeRowPaddingLeft, + WORKSPACE_FILE_ROW_VERTICAL_PADDING, +} from "@/components/tree-primitives"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; -import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; -import { useDownloadStore } from "@/stores/download-store"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { FileActionsMenu } from "@/components/file-actions-menu"; +import { useFileDownload } from "@/hooks/use-file-download"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; 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 { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; const SORT_OPTIONS: { value: SortOption }[] = [ { value: "name" }, @@ -62,6 +53,8 @@ function formatFileSize({ size }: { size: number }): string { } interface TreeRowItemProps { + serverId: string; + workspaceId?: string | null; entry: ExplorerEntry; depth: number; isExpanded: boolean; @@ -70,21 +63,8 @@ interface TreeRowItemProps { onEntryPress: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void; onDownloadEntry: (entry: ExplorerEntry) => void; -} - -function stopPressInPropagation(event: { stopPropagation?: () => void }) { - event.stopPropagation?.(); -} - -function menuButtonStyle({ - hovered, - pressed, - open, -}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) { - return [ - styles.menuButton, - (Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive, - ]; + onAddToChat?: (path: string) => void; + testID?: string; } function sortTriggerStyle({ @@ -103,6 +83,8 @@ function treeRowKeyExtractor(row: TreeRow) { } function TreeRowItem({ + serverId, + workspaceId, entry, depth, isExpanded, @@ -111,10 +93,17 @@ function TreeRowItem({ onEntryPress, onCopyPath, onDownloadEntry, + onAddToChat, + testID, }: TreeRowItemProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const isDirectory = entry.kind === "directory"; + const dragSourceRef = useWorkspaceFileDragSource({ + enabled: !isDirectory, + serverId, + workspaceId, + path: entry.path, + }); const handlePress = useCallback(() => { onEntryPress(entry); @@ -123,10 +112,10 @@ function TreeRowItem({ const pressableStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.entryRow, - { paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL }, + { paddingLeft: treeRowPaddingLeft(depth) }, (Boolean(hovered) || pressed || isSelected) && styles.entryRowActive, ], - [depth, isSelected, theme.spacing], + [depth, isSelected], ); const handleCopy = useCallback(() => { @@ -137,19 +126,38 @@ function TreeRowItem({ onDownloadEntry(entry); }, [onDownloadEntry, entry]); - const copyLeading = useMemo( - () => , - [theme.colors.foregroundMuted], - ); - const downloadLeading = useMemo( - () => , - [theme.colors.foregroundMuted], + const handleAddToChat = useCallback(() => { + onAddToChat?.(entry.path); + }, [onAddToChat, entry.path]); + + const metaHeader = useMemo( + () => ( + + + + {t("workspace.fileExplorer.context.size")} + + + {formatFileSize({ size: entry.size })} + + + + + {t("workspace.fileExplorer.context.modified")} + + + {formatTimeAgo(new Date(entry.modifiedAt))} + + + + ), + [entry.modifiedAt, entry.size, t], ); return ( - + - + {(() => { if (!isDirectory) { @@ -163,40 +171,15 @@ function TreeRowItem({ {entry.name} - - - - - - - - - {t("workspace.fileExplorer.context.size")} - - - {formatFileSize({ size: entry.size })} - - - - - {t("workspace.fileExplorer.context.modified")} - - - {formatTimeAgo(new Date(entry.modifiedAt))} - - - - - - {t("workspace.fileExplorer.context.copyPath")} - - {entry.kind === "file" ? ( - - {t("workspace.fileExplorer.context.download")} - - ) : null} - - + ); } @@ -206,6 +189,7 @@ interface FileExplorerPaneProps { workspaceId?: string | null; workspaceRoot: string; onOpenFile?: (filePath: string) => void; + onAddToChat?: (path: string) => void; } interface TreeRow { @@ -218,14 +202,10 @@ export function FileExplorerPane({ workspaceId, workspaceRoot, onOpenFile, + onAddToChat, }: FileExplorerPaneProps) { const { t } = useTranslation(); - const daemons = useHosts(); - const daemonProfile = useMemo( - () => daemons.find((daemon) => daemon.serverId === serverId), - [daemons, serverId], - ); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const workspaceStateKey = useMemo( () => @@ -235,10 +215,6 @@ export function FileExplorerPane({ }), [normalizedWorkspaceRoot, workspaceId], ); - const workspaceScopeId = useMemo( - () => workspaceId?.trim() || normalizedWorkspaceRoot, - [normalizedWorkspaceRoot, workspaceId], - ); const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot); const explorerState = useSessionStore((state) => workspaceStateKey && state.sessions[serverId] @@ -246,12 +222,16 @@ export function FileExplorerPane({ : undefined, ); - const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } = - useFileExplorerActions({ - serverId, - workspaceId, - workspaceRoot: normalizedWorkspaceRoot, - }); + const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({ + serverId, + workspaceId, + workspaceRoot: normalizedWorkspaceRoot, + }); + const downloadFile = useFileDownload({ + serverId, + workspaceId, + workspaceRoot: normalizedWorkspaceRoot, + }); const sortOption = usePanelStore((state) => state.explorerSortOption); const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles); const setSortOption = usePanelStore((state) => state.setExplorerSortOption); @@ -346,18 +326,14 @@ export function FileExplorerPane({ [normalizedWorkspaceRoot], ); - const startDownload = useDownloadStore((state) => state.startDownload); const handleDownloadEntry = useCallback( - (entry: ExplorerEntry) => - downloadExplorerEntry({ - entry, - workspaceScopeId, - serverId, - daemonProfile, - startDownload, - requestFileDownloadToken, - }), - [daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId], + (entry: ExplorerEntry) => { + if (entry.kind !== "file") { + return; + } + downloadFile({ fileName: entry.name, path: entry.path }); + }, + [downloadFile], ); const handleSortCycle = useCallback(() => { @@ -419,6 +395,8 @@ export function FileExplorerPane({ const renderTreeRow = useCallback( (info: ListRenderItemInfo) => ( ), [ @@ -435,6 +414,9 @@ export function FileExplorerPane({ handleDownloadEntry, isDirectoryLoading, selectedEntryPath, + onAddToChat, + serverId, + workspaceId, ], ); @@ -576,8 +558,14 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { return ( - - {currentSortLabel} + + + {currentSortLabel} + @@ -588,6 +576,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { accessibilityRole="button" accessibilityLabel={hiddenFilesToggleAccessibilityLabel} accessibilityState={hiddenFilesToggleAccessibilityState} + testID="files-hidden-toggle" > {showHiddenFiles ? ( @@ -606,6 +595,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { ? t("workspace.fileExplorer.actions.refreshing") : t("workspace.fileExplorer.actions.refresh") } + testID="files-refresh" > {isRefreshFetching ? ( @@ -778,39 +768,6 @@ function resolveTreeRows({ }); } -type StartDownloadFn = ReturnType["startDownload"]; -type StartDownloadParams = Parameters[0]; - -function downloadExplorerEntry({ - entry, - workspaceScopeId, - serverId, - daemonProfile, - startDownload, - requestFileDownloadToken, -}: { - entry: ExplorerEntry; - workspaceScopeId: string | undefined; - serverId: string; - daemonProfile: StartDownloadParams["daemonProfile"]; - startDownload: StartDownloadFn; - requestFileDownloadToken: ( - targetPath: string, - ) => ReturnType; -}): void { - if (!workspaceScopeId || entry.kind !== "file") { - return; - } - startDownload({ - serverId, - scopeId: workspaceScopeId, - fileName: entry.name, - path: entry.path, - daemonProfile, - requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath), - }); -} - function toggleDirectory({ entry, workspaceStateKey, @@ -850,6 +807,8 @@ function toggleDirectory({ } function TreeRowDispatcher({ + serverId, + workspaceId, info, expandedPaths, selectedEntryPath, @@ -857,7 +816,10 @@ function TreeRowDispatcher({ onEntryPress, onCopyPath, onDownloadEntry, + onAddToChat, }: { + serverId: string; + workspaceId?: string | null; info: ListRenderItemInfo; expandedPaths: Set; selectedEntryPath: string | null; @@ -865,6 +827,7 @@ function TreeRowDispatcher({ onEntryPress: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void | Promise; onDownloadEntry: (entry: ExplorerEntry) => void; + onAddToChat?: (path: string) => void; }) { const entry = info.item.entry; const depth = info.item.depth; @@ -875,6 +838,8 @@ function TreeRowDispatcher({ return ( ); } @@ -1061,7 +1028,6 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, }, entriesContent: { - paddingHorizontal: theme.spacing[2], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, @@ -1111,9 +1077,8 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingVertical: 2, - paddingRight: theme.spacing[2], - borderRadius: theme.borderRadius.md, + paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, + paddingRight: theme.spacing[3], }, entryRowActive: { backgroundColor: theme.colors.surfaceSidebarHover, @@ -1133,16 +1098,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, - menuButton: { - width: 30, - height: 30, - borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", - }, - menuButtonActive: { - backgroundColor: theme.colors.surface2, - }, contextMetaBlock: { paddingVertical: theme.spacing[1], }, diff --git a/packages/app/src/components/tree-primitives.tsx b/packages/app/src/components/tree-primitives.tsx index 11949c14052..920243425d5 100644 --- a/packages/app/src/components/tree-primitives.tsx +++ b/packages/app/src/components/tree-primitives.tsx @@ -11,6 +11,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; // indentation, guide lines, and chevron. Keep those here so the two trees can't // drift apart. export const TREE_INDENT_PER_LEVEL = 16; +export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5]; /** Left padding for a tree row at `depth`. Shared by folder rows and file headers * in the Changes tree so their indentation can't drift apart. */ diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 8fd16f37c5c..382d9f77557 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -338,7 +338,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void input.setLightboxMetadata(input.attachment.metadata); return; } - if (input.attachment.kind === "file") { + if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") { return; } if (isWorkspaceAttachment(input.attachment)) { diff --git a/packages/app/src/composer/attachments/submit.ts b/packages/app/src/composer/attachments/submit.ts index f814d06c0ed..c1c15bb80c4 100644 --- a/packages/app/src/composer/attachments/submit.ts +++ b/packages/app/src/composer/attachments/submit.ts @@ -9,6 +9,7 @@ import { buildForgeAttachmentFromSearchItem, buildLegacyGitHubAttachmentFromSearchItem, } from "@/utils/review-attachments"; +import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file"; export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github"; @@ -49,6 +50,11 @@ export function splitComposerAttachmentsForSubmit( continue; } + if (attachment.kind === "workspace_file") { + agentAttachments.push(workspaceFileAttachmentToAgentAttachment(attachment)); + continue; + } + if (isWorkspaceAttachment(attachment)) { if (attachment.kind === "browser_element" && attachment.attachment.screenshot) { images.push(attachment.attachment.screenshot); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 64b684ea03f..ff3797466b9 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { useDraftStore } from "@/stores/draft-store"; import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; +import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; const { asyncStorage } = vi.hoisted(() => ({ asyncStorage: new Map(), @@ -159,7 +160,11 @@ describe("useAgentInputDraft live contract", () => { configurable: true, }); - useDraftStore.setState({ drafts: {}, createModalDraft: null }); + useDraftStore.setState({ + drafts: {}, + createModalDraft: null, + attachmentFocusRequestByDraftKey: {}, + }); }); it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => { @@ -424,6 +429,39 @@ describe("useAgentInputDraft live contract", () => { }); }); + it("attaches to an unmounted legacy draft without losing its input", async () => { + const image: AttachmentMetadata = { + id: "legacy-image", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/legacy-image", + createdAt: 10, + }; + useDraftStore.setState({ + drafts: { + "draft:legacy-workspace-file": { + input: { text: "legacy text", images: [image] }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + } as unknown as DraftRecordForTest, + }, + }); + + await useDraftStore.getState().attachWorkspaceFile({ + draftKey: "draft:legacy-workspace-file", + attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }), + }); + + expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({ + text: "legacy text", + attachments: [ + { kind: "image", metadata: image }, + createWorkspaceFileAttachment({ path: "src/app.ts" }), + ], + }); + }); + it("clear resets text and attachments", async () => { let latest: ReturnType | null = null; const image: AttachmentMetadata = { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 2309ad4455e..995a5382f3f 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { UserComposerAttachment } from "@/attachments/types"; import type { DraftAgentControlsProps } from "@/composer/agent-controls"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; @@ -9,7 +9,6 @@ import { } from "@/hooks/use-agent-form-state"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { - areAttachmentsEqual, buildDraftAgentControls, hasDraftContent, resolveDraftKey, @@ -22,6 +21,7 @@ import { type ProviderSelectionState, } from "@/provider-selection/provider-selection"; import { useDraftStore } from "@/stores/draft-store"; +import { toDraftInputIfReady } from "@/stores/draft-store/state"; type AttachmentUpdater = | UserComposerAttachment[] @@ -57,6 +57,7 @@ export interface AgentInputDraft { setAttachments: (updater: AttachmentUpdater) => void; clear: (lifecycle: "sent" | "abandoned") => void; isHydrated: boolean; + attachmentFocusRequestId: number; composerState: DraftComposerState | null; } @@ -77,67 +78,66 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }), [formState.selectedServerId, input.draftKey], ); - const [text, setText] = useState(""); - const [attachments, setAttachmentsState] = useState([]); - const [isHydrated, setIsHydrated] = useState(false); - const draftGenerationRef = useRef(0); - const hydratedGenerationRef = useRef(0); - - const setAttachments = useCallback((updater: AttachmentUpdater) => { - setAttachmentsState((previousAttachments) => { - if (typeof updater === "function") { - return updater(previousAttachments); + const draftRecord = useDraftStore((state) => state.drafts[draftKey]); + const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]); + const attachmentFocusRequestId = useDraftStore( + (state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0, + ); + const [hydratedDraftKey, setHydratedDraftKey] = useState(null); + const text = draft?.text ?? ""; + const attachments = draft?.attachments ?? []; + const isHydrated = hydratedDraftKey === draftKey; + + const saveDraft = useCallback( + ( + update: (draft: { text: string; attachments: UserComposerAttachment[] }) => { + text: string; + attachments: UserComposerAttachment[]; + }, + ) => { + const store = useDraftStore.getState(); + const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] }; + const next = update(current); + if (!hasDraftContent(next)) { + store.clearDraftInput({ draftKey, lifecycle: "abandoned" }); + return; } - return updater; - }); - }, []); + store.saveDraftInput({ draftKey, draft: next }); + }, + [draftKey], + ); - const clear = useCallback( - (lifecycle: "sent" | "abandoned") => { - const store = useDraftStore.getState(); - store.clearDraftInput({ draftKey, lifecycle }); + const setText = useCallback( + (nextText: string) => { + saveDraft((current) => ({ ...current, text: nextText })); + }, + [saveDraft], + ); - const generation = store.beginDraftGeneration(draftKey); - draftGenerationRef.current = generation; - hydratedGenerationRef.current = generation; + const setAttachments = useCallback( + (updater: AttachmentUpdater) => { + saveDraft((current) => ({ + ...current, + attachments: typeof updater === "function" ? updater(current.attachments) : updater, + })); + }, + [saveDraft], + ); - setText(""); - setAttachmentsState([]); - setIsHydrated(true); + const clear = useCallback( + (lifecycle: "sent" | "abandoned") => { + useDraftStore.getState().clearDraftInput({ draftKey, lifecycle }); }, [draftKey], ); useEffect(() => { - const store = useDraftStore.getState(); - const generation = store.beginDraftGeneration(draftKey); - draftGenerationRef.current = generation; - hydratedGenerationRef.current = 0; - - setText(""); - setAttachmentsState([]); - setIsHydrated(false); - let cancelled = false; - void (async () => { - const draft = await store.hydrateDraftInput({ - draftKey, - }); - if (cancelled) { - return; - } - if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) { - return; - } - - if (draft) { - setText(draft.text); - setAttachmentsState(draft.attachments); + await useDraftStore.getState().hydrateDraftInput({ draftKey }); + if (!cancelled) { + setHydratedDraftKey(draftKey); } - - hydratedGenerationRef.current = generation; - setIsHydrated(true); })(); return () => { @@ -145,52 +145,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }; }, [draftKey]); - useEffect(() => { - const currentGeneration = draftGenerationRef.current; - if (currentGeneration <= 0) { - return; - } - - const store = useDraftStore.getState(); - const isCurrentGeneration = store.isDraftGenerationCurrent({ - draftKey, - generation: currentGeneration, - }); - if (!isCurrentGeneration) { - return; - } - if (hydratedGenerationRef.current !== currentGeneration) { - return; - } - - const existing = store.getDraftInput(draftKey); - const isSameDraft = - existing !== undefined && - existing.text === text && - areAttachmentsEqual({ - left: existing.attachments, - right: attachments, - }); - if (isSameDraft) { - return; - } - - if (!hasDraftContent({ text, attachments })) { - if (existing) { - store.clearDraftInput({ draftKey, lifecycle: "abandoned" }); - } - return; - } - - store.saveDraftInput({ - draftKey, - draft: { - text, - attachments, - }, - }); - }, [attachments, draftKey, text]); - const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? ""; useEffect(() => { if (!composerOptions || !lockedWorkingDir) { @@ -304,6 +258,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr setAttachments, clear, isHydrated, + attachmentFocusRequestId, composerState, }; } diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 7ac29491516..d75e9e940d8 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -751,6 +751,7 @@ export function WorkspaceDraftAgentTab({ { + it("targets the focused agent draft", () => { + expect( + resolveFocusedChatTarget({ + serverId: "server-1", + layout: layoutWithTarget({ kind: "agent", agentId: "agent-1" }), + }), + ).toEqual({ tabId: "focused-tab", draftKey: "agent:server-1:agent-1" }); + }); + + it("targets the focused unsent draft", () => { + expect( + resolveFocusedChatTarget({ + serverId: "server-1", + layout: layoutWithTarget({ kind: "draft", draftId: "draft-1" }), + }), + ).toEqual({ tabId: "focused-tab", draftKey: "draft:server-1:draft-1" }); + }); + + it("does not guess when the focused tab is not a chat", () => { + expect( + resolveFocusedChatTarget({ + serverId: "server-1", + layout: layoutWithTarget({ kind: "terminal", terminalId: "terminal-1" }), + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/composer/focused-chat-target.ts b/packages/app/src/composer/focused-chat-target.ts new file mode 100644 index 00000000000..479fcf0888c --- /dev/null +++ b/packages/app/src/composer/focused-chat-target.ts @@ -0,0 +1,48 @@ +import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { + collectAllTabs, + findPaneById, + type WorkspaceLayout, +} from "@/stores/workspace-layout-store"; + +export interface FocusedChatTarget { + tabId: string; + draftKey: string; +} + +export function resolveFocusedChatTarget(input: { + serverId: string; + layout: WorkspaceLayout | undefined; +}): FocusedChatTarget | null { + if (!input.layout) { + return null; + } + const pane = findPaneById(input.layout.root, input.layout.focusedPaneId); + const focusedTabId = pane?.focusedTabId; + if (!focusedTabId) { + return null; + } + const tab = collectAllTabs(input.layout.root).find( + (candidate) => candidate.tabId === focusedTabId, + ); + if (!tab) { + return null; + } + if (tab.target.kind === "agent") { + return { + tabId: tab.tabId, + draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }), + }; + } + if (tab.target.kind === "draft") { + return { + tabId: tab.tabId, + draftKey: buildDraftStoreKey({ + serverId: input.serverId, + agentId: tab.tabId, + draftId: tab.target.draftId, + }), + }; + } + return null; +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index bd778776044..a920cbc99e6 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -99,6 +99,7 @@ import type { AttachmentMetadata, ComposerAttachment, UserComposerAttachment, + WorkspaceFileComposerAttachment, WorkspaceComposerAttachment, } from "@/attachments/types"; import type { PickedFile } from "@/attachments/picked-file"; @@ -119,6 +120,15 @@ import { getForgePresentation } from "@/git/forge"; import { ForgeBrandIcon } from "@/git/forge-icon"; import { useComposerGithubAutoAttach } from "./github/auto-attach"; import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands"; +import { + appendWorkspaceFileAttachment, + getWorkspaceFileAttachmentKey, + getWorkspaceFileAttachmentSubtitle, +} from "@/attachments/workspace-file"; +import { + resolveWorkspaceFileDrop, + type WorkspaceFileDragPayload, +} from "@/attachments/workspace-file-drag"; type QueuedMessage = QueuedComposerMessage; @@ -399,6 +409,18 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R /> ); } + if (attachment.kind === "workspace_file") { + return ( + + ); + } if (composerWorkspaceAttachment.is(attachment)) { return composerWorkspaceAttachment.renderPill({ attachment, @@ -711,6 +733,43 @@ function FileAttachmentPill({ ); } +interface WorkspaceFileAttachmentPillProps { + attachment: WorkspaceFileComposerAttachment; + index: number; + disabled: boolean; + onRemove: (index: number) => void; + removeLabel: string; +} + +function WorkspaceFileAttachmentPill({ + attachment, + index, + disabled, + onRemove, + removeLabel, +}: WorkspaceFileAttachmentPillProps) { + const handleRemove = useCallback(() => { + onRemove(index); + }, [index, onRemove]); + const fileName = attachment.path.split("/").pop() ?? attachment.path; + return ( + + + + ); +} + interface GithubPickerOptionProps { label: string; testID: string; @@ -755,6 +814,7 @@ function GithubPickerOption({ interface ComposerProps { agentId: string; serverId: string; + workspaceId?: string | null; isPaneFocused: boolean; onSubmitMessage?: (payload: MessagePayload) => Promise; onClientSlashCommand?: (command: ClientSlashCommand) => Promise; @@ -786,6 +846,8 @@ interface ComposerProps { clearDraft: (lifecycle: "sent" | "abandoned") => void; /** When true, auto-focuses the text input on web. */ autoFocus?: boolean; + /** Changing this value requests focus again while autoFocus remains true. */ + autoFocusKey?: string; /** Callback to expose a focus function to parent components (desktop only). */ onFocusInput?: (focus: () => void) => void; /** Optional draft context for listing commands before an agent exists. */ @@ -977,6 +1039,7 @@ function ComposerVoiceModeButton({ export function Composer({ agentId, serverId, + workspaceId, isPaneFocused, onSubmitMessage, onClientSlashCommand, @@ -1000,6 +1063,7 @@ export function Composer({ cwd, clearDraft, autoFocus = false, + autoFocusKey, onFocusInput, commandDraftConfig, onMessageSent, @@ -1195,6 +1259,21 @@ export function Composer({ }); }, []); + const handleWorkspaceFileDropped = useCallback( + (payload: WorkspaceFileDragPayload) => { + if (!workspaceId) { + return; + } + const attachment = resolveWorkspaceFileDrop({ payload, serverId, workspaceId }); + if (!attachment) { + return; + } + setSelectedAttachments((current) => appendWorkspaceFileAttachment(current, attachment)); + focusInput(); + }, + [focusInput, serverId, setSelectedAttachments, workspaceId], + ); + useEffect(() => { onFocusInput?.(focusInput); }, [focusInput, onFocusInput]); @@ -1969,7 +2048,11 @@ export function Composer({ // so a drop in that window would be lost or land on a locked draft. `disabled` hides the // backdrop and rejects the drop atomically, instead of accepting a drop with no feedback. useFileDrop( - { onFiles: addImages, onGenericFiles: handleGenericFilesDropped }, + { + onFiles: addImages, + onGenericFiles: handleGenericFilesDropped, + onWorkspaceFile: handleWorkspaceFileDropped, + }, { disabled: isSubmitBusy }, ); @@ -2030,7 +2113,7 @@ export function Composer({ isReadyForDictation={isDictationReady} placeholder={messagePlaceholder} autoFocus={messageInputAutoFocus} - autoFocusKey={`${serverId}:${agentId}`} + autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`} disabled={isSubmitLoading} isPaneFocused={isPaneFocused} leftContent={leftContent} diff --git a/packages/app/src/git/diff-folder-row.tsx b/packages/app/src/git/diff-folder-row.tsx index cd0cbe5f5b9..101d34e2a5c 100644 --- a/packages/app/src/git/diff-folder-row.tsx +++ b/packages/app/src/git/diff-folder-row.tsx @@ -8,7 +8,13 @@ import { } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { DiffStat } from "@/components/diff-stat"; -import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives"; +import { FILE_ACTIONS_MENU_WIDTH } from "@/components/file-actions-menu"; +import { + TreeChevron, + TreeIndentGuides, + treeRowPaddingLeft, + WORKSPACE_FILE_ROW_VERTICAL_PADDING, +} from "@/components/tree-primitives"; import { type Theme } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; @@ -80,7 +86,12 @@ export function DiffFolderRow({ - + + @@ -94,8 +105,8 @@ const styles = StyleSheet.create((theme: Theme) => ({ folderRow: { flexDirection: "row", alignItems: "center", - paddingRight: theme.spacing[2], - paddingVertical: theme.spacing[2], + paddingRight: theme.spacing[3], + paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, gap: theme.spacing[1], minWidth: 0, }, @@ -113,6 +124,10 @@ const styles = StyleSheet.create((theme: Theme) => ({ flexDirection: "row", alignItems: "center", flexShrink: 0, + gap: theme.spacing[1], + }, + actionSlot: { + width: FILE_ACTIONS_MENU_WIDTH, }, folderName: { fontSize: theme.fontSize.sm, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 5a1d2e007dc..2048e774245 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -57,7 +57,11 @@ import { import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items"; import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree"; import { DiffFolderRow } from "@/git/diff-folder-row"; -import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives"; +import { + TreeIndentGuides, + treeRowPaddingLeft, + WORKSPACE_FILE_ROW_VERTICAL_PADDING, +} from "@/components/tree-primitives"; import { SvgXml } from "react-native-svg"; import { getFileIconSvg } from "@/components/material-file-icons"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; @@ -83,6 +87,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import * as Clipboard from "expo-clipboard"; +import { FILE_ACTIONS_MENU_WIDTH, FileActionsMenu } from "@/components/file-actions-menu"; +import { useFileDownload } from "@/hooks/use-file-download"; +import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { GitActionsSplitButton } from "@/git/actions-split-button"; @@ -106,6 +114,7 @@ import { hasVisibleDiffTokens, } from "@/utils/diff-rendering"; import { isWeb, isNative } from "@/constants/platform"; +import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; import { buildWorkspaceAttachmentScopeKey, useWorkspaceAttachmentsStore, @@ -202,6 +211,7 @@ function HighlightedText({ interface DiffFileSectionProps { file: ParsedDiffFile; + workspaceFileDragScope?: { serverId: string; workspaceId: string }; isExpanded: boolean; /** Tree indentation level (0 on the flat/mobile path). */ depth?: number; @@ -209,6 +219,10 @@ interface DiffFileSectionProps { showDir?: boolean; interactive?: boolean; onToggle?: (path: string) => void; + onOpenFile?: (path: string) => void; + onAddToChat?: (path: string) => void; + onCopyPath?: (path: string) => void; + onDownload?: (path: string) => void; onHeaderHeightChange?: (path: string, height: number) => void; testID?: string; } @@ -905,18 +919,31 @@ function SplitDiffColumn({ const DiffFileHeader = memo(function DiffFileHeader({ file, + workspaceFileDragScope, isExpanded, depth = 0, showDir = true, interactive = true, onToggle, + onOpenFile, + onAddToChat, + onCopyPath, + onDownload, onHeaderHeightChange, testID, }: DiffFileSectionProps) { const { t } = useTranslation(); + const dragSourceRef = useWorkspaceFileDragSource({ + enabled: interactive, + disabled: file.isDeleted, + workspaceId: null, + path: file.path, + ...workspaceFileDragScope, + }); const layoutYRef = useRef(null); const pressHandledRef = useRef(false); const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null); + const [isActionsOpen, setIsActionsOpen] = useState(false); const toggleExpanded = useCallback(() => { if (!interactive) { @@ -926,6 +953,31 @@ const DiffFileHeader = memo(function DiffFileHeader({ onToggle?.(file.path); }, [file.path, interactive, onToggle]); + const handleOpenFile = useCallback(() => { + onOpenFile?.(file.path); + }, [file.path, onOpenFile]); + + const handleAddToChat = useCallback(() => { + onAddToChat?.(file.path); + }, [file.path, onAddToChat]); + + const handleCopyPath = useCallback(() => { + onCopyPath?.(file.path); + }, [file.path, onCopyPath]); + + const handleDownload = useCallback(() => { + onDownload?.(file.path); + }, [file.path, onDownload]); + + const handleContextMenu = useCallback( + (event: { preventDefault: () => void; stopPropagation: () => void }) => { + event.preventDefault(); + event.stopPropagation(); + setIsActionsOpen(true); + }, + [], + ); + const handleLayout = useCallback( (event: LayoutChangeEvent) => { layoutYRef.current = event.nativeEvent.layout.y; @@ -983,7 +1035,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ const fileName = file.path.split("/").pop() ?? file.path; const headerContent = ( <> - + {showDir ? null : ( @@ -1013,38 +1065,55 @@ const DiffFileHeader = memo(function DiffFileHeader({ )} - + + {interactive ? ( + + ) : null} ); + let trigger: ReactElement; + if (!interactive) { + trigger = ( + {headerContent} + ); + } else { + trigger = ( + + {headerContent} + + ); + } return ( - - - {interactive ? ( - - {headerContent} - - ) : ( - - {headerContent} - - )} - - - {file.path} - - + {trigger} ); }); @@ -1242,6 +1311,8 @@ interface GitDiffPaneProps { workspaceId?: string | null; cwd: string; enabled?: boolean; + onOpenFile?: (path: string) => void; + onAddToChat?: (path: string) => void; } type PressableStyleFn = ( @@ -1639,6 +1710,11 @@ interface SharedDiffViewProps { expandedPaths: string[]; collapsedFolders: string[]; reviewActions?: InlineReviewActions; + workspaceFileDragScope?: { serverId: string; workspaceId: string }; + onOpenFile?: (path: string) => void; + onAddToChat?: (path: string) => void; + onCopyPath?: (path: string) => void; + onDownload?: (path: string) => void; onExpandedPathsChange: (paths: string[]) => void; onCollapsedFoldersChange: (paths: string[]) => void; } @@ -1671,6 +1747,12 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi const stickyHeaders = mode.kind === "working_tree"; const interactive = mode.kind === "working_tree"; const reviewActions = mode.kind === "working_tree" ? mode.reviewActions : undefined; + const onOpenFile = mode.kind === "working_tree" ? mode.onOpenFile : undefined; + const onAddToChat = mode.kind === "working_tree" ? mode.onAddToChat : undefined; + const workspaceFileDragScope = + mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined; + const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined; + const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined; const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]); const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]); @@ -1917,11 +1999,16 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi return ( @@ -1949,10 +2036,15 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi handleToggleFolder, layout, reviewActions, + workspaceFileDragScope, textMetricsStyle, viewMode, wrapLines, interactive, + onOpenFile, + onAddToChat, + onCopyPath, + onDownload, ], ); @@ -1982,6 +2074,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi viewMode, wrapLines, reviewActions, + workspaceFileDragScope, }), [ expandedPathsArray, @@ -1991,6 +2084,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi reviewActions, typographyKey, viewMode, + workspaceFileDragScope, wrapLines, ], ); @@ -2160,6 +2254,13 @@ function buildExpandAllButtonStyle(): PressableStyleFn { ]; } +function buildOverflowButtonStyle(): PressableStyleFn { + return ({ hovered, pressed }) => [ + styles.overflowButton, + (Boolean(hovered) || pressed) && styles.toggleButtonSelected, + ]; +} + function buildToggleButtonStyle( selected: boolean, baseStyles: StyleProp | StyleProp[], @@ -2174,7 +2275,14 @@ function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean return input.paneEnabled && input.isGit; } -export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPaneProps) { +export function GitDiffPane({ + serverId, + workspaceId, + cwd, + enabled, + onOpenFile, + onAddToChat, +}: GitDiffPaneProps) { const { settings: appSettings } = useAppSettings(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); @@ -2214,7 +2322,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); - const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); + const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []); const toast = useToast(); const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); @@ -2461,6 +2569,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane }, [setDiffCollapsedFoldersForWorkspace, workspaceStateKey], ); + const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: cwd }); + const handleCopyPath = useCallback( + (path: string) => { + void Clipboard.setStringAsync( + buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }), + ); + }, + [cwd], + ); + const handleDownloadPath = useCallback( + (path: string) => { + downloadFile({ fileName: path.split("/").pop() ?? path, path }); + }, + [downloadFile], + ); const workingTreeMode = useMemo( () => ({ kind: "working_tree" as const, @@ -2468,6 +2591,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane expandedPaths: stableExpandedPathsArray, collapsedFolders: stableCollapsedFoldersArray, reviewActions, + workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined, + onOpenFile, + onAddToChat, + onCopyPath: handleCopyPath, + onDownload: handleDownloadPath, onExpandedPathsChange: handleExpandedPathsChange, onCollapsedFoldersChange: handleCollapsedFoldersChange, }), @@ -2476,6 +2604,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane stableExpandedPathsArray, stableCollapsedFoldersArray, reviewActions, + serverId, + workspaceId, + onOpenFile, + onAddToChat, + handleCopyPath, + handleDownloadPath, handleExpandedPathsChange, handleCollapsedFoldersChange, ], @@ -2742,6 +2876,18 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.base, flexShrink: 0, }, + overflowButton: { + width: FILE_ACTIONS_MENU_WIDTH, + height: { + xs: 32, + sm: 32, + md: 24, + }, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.base, + flexShrink: 0, + }, actionErrorText: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[1], @@ -2830,8 +2976,8 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", paddingLeft: theme.spacing[3], - paddingRight: theme.spacing[2], - paddingVertical: theme.spacing[2], + paddingRight: theme.spacing[3], + paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, gap: theme.spacing[1], minWidth: 0, zIndex: 2, diff --git a/packages/app/src/hooks/use-file-download.ts b/packages/app/src/hooks/use-file-download.ts new file mode 100644 index 00000000000..91a88170b86 --- /dev/null +++ b/packages/app/src/hooks/use-file-download.ts @@ -0,0 +1,56 @@ +import { useCallback, useMemo } from "react"; +import { useHosts } from "@/runtime/host-runtime"; +import { useDownloadStore } from "@/stores/download-store"; +import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; + +interface UseFileDownloadParams { + serverId: string; + workspaceId?: string | null; + workspaceRoot: string; +} + +/** + * Returns a stable callback that downloads a single workspace file by its + * workspace-relative path. Shared by the file explorer tree and the git diff + * pane so both surfaces download through the same host token + download-store + * pipeline instead of duplicating the plumbing. + */ +export function useFileDownload({ + serverId, + workspaceId, + workspaceRoot, +}: UseFileDownloadParams): (input: { fileName: string; path: string }) => void { + const daemons = useHosts(); + const daemonProfile = useMemo( + () => daemons.find((daemon) => daemon.serverId === serverId), + [daemons, serverId], + ); + const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); + const workspaceScopeId = useMemo( + () => workspaceId?.trim() || normalizedWorkspaceRoot, + [normalizedWorkspaceRoot, workspaceId], + ); + const { requestFileDownloadToken } = useFileExplorerActions({ + serverId, + workspaceId, + workspaceRoot: normalizedWorkspaceRoot, + }); + const startDownload = useDownloadStore((state) => state.startDownload); + + return useCallback( + ({ fileName, path }) => { + if (!workspaceScopeId) { + return; + } + void startDownload({ + serverId, + scopeId: workspaceScopeId, + fileName, + path, + daemonProfile, + requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath), + }); + }, + [daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId], + ); +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b82eef681bf..87dd6f583e9 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -366,6 +366,13 @@ export const ar: TranslationResources = { copyBranchName: "نسخ اسم الفرع", copied: "تم النسخ", }, + fileActions: { + openFile: "افتح الملف", + copyPath: "نسخ المسار", + download: "تحميل", + addToChat: "إضافة إلى الدردشة…", + moreActions: "المزيد من الإجراءات", + }, fileExplorer: { sort: { name: "اسم", @@ -375,8 +382,6 @@ export const ar: TranslationResources = { context: { size: "مقاس", modified: "معدل", - copyPath: "نسخ المسار", - download: "تحميل", }, actions: { back: "خلف", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index cb3e413d7e3..db5921e04a8 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -365,6 +365,13 @@ export const en = { copyBranchName: "Copy branch name", copied: "Copied", }, + fileActions: { + openFile: "Open file", + copyPath: "Copy path", + download: "Download", + addToChat: "Add to chat…", + moreActions: "More actions", + }, fileExplorer: { sort: { name: "Name", @@ -374,8 +381,6 @@ export const en = { context: { size: "Size", modified: "Modified", - copyPath: "Copy path", - download: "Download", }, actions: { back: "Back", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2a2187ed9c8..4edc7bce967 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -370,6 +370,13 @@ export const es: TranslationResources = { copyBranchName: "Copiar nombre de rama", copied: "Copiado", }, + fileActions: { + openFile: "Abrir archivo", + copyPath: "Copiar ruta", + download: "Descargar", + addToChat: "Añadir al chat…", + moreActions: "Más acciones", + }, fileExplorer: { sort: { name: "Nombre", @@ -379,8 +386,6 @@ export const es: TranslationResources = { context: { size: "Tamaño", modified: "Modificado", - copyPath: "Copiar ruta", - download: "Descargar", }, actions: { back: "Atrás", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 81d37267985..2c7e67bd076 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -370,6 +370,13 @@ export const fr: TranslationResources = { copyBranchName: "Copier le nom de la branche", copied: "Copié", }, + fileActions: { + openFile: "Ouvrir le fichier", + copyPath: "Copier le chemin", + download: "Télécharger", + addToChat: "Ajouter au chat…", + moreActions: "Plus de propositions", + }, fileExplorer: { sort: { name: "Nom", @@ -379,8 +386,6 @@ export const fr: TranslationResources = { context: { size: "Taille", modified: "Modifié", - copyPath: "Copier le chemin", - download: "Télécharger", }, actions: { back: "Dos", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6be03847cdf..2029d9da0b2 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -370,6 +370,13 @@ export const ja: TranslationResources = { copyBranchName: "ブランチ名をコピー", copied: "コピーしました", }, + fileActions: { + openFile: "ファイルを開く", + copyPath: "パスをコピー", + download: "ダウンロード", + addToChat: "チャットに追加…", + moreActions: "その他のアクション", + }, fileExplorer: { sort: { name: "名前", @@ -379,8 +386,6 @@ export const ja: TranslationResources = { context: { size: "サイズ", modified: "更新日時", - copyPath: "パスをコピー", - download: "ダウンロード", }, actions: { back: "戻る", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index d83390d3208..61030df178d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -370,6 +370,13 @@ export const ptBR: TranslationResources = { copyBranchName: "Copiar nome da branch", copied: "Copiado", }, + fileActions: { + openFile: "Abrir arquivo", + copyPath: "Copiar caminho", + download: "Baixar", + addToChat: "Adicionar ao chat…", + moreActions: "Mais ações", + }, fileExplorer: { sort: { name: "Nome", @@ -379,8 +386,6 @@ export const ptBR: TranslationResources = { context: { size: "Tamanho", modified: "Modificado", - copyPath: "Copiar caminho", - download: "Baixar", }, actions: { back: "Voltar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 07453f94977..179ef702549 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -369,6 +369,13 @@ export const ru: TranslationResources = { copyBranchName: "Копировать имя ветки", copied: "Скопировано", }, + fileActions: { + openFile: "Открыть файл", + copyPath: "Копировать путь", + download: "Скачать", + addToChat: "Добавить в чат…", + moreActions: "Дополнительные действия", + }, fileExplorer: { sort: { name: "Имя", @@ -378,8 +385,6 @@ export const ru: TranslationResources = { context: { size: "Размер", modified: "Модифицированный", - copyPath: "Копировать путь", - download: "Скачать", }, actions: { back: "Назад", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e845d819ffc..fbb97630fbb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -366,6 +366,13 @@ export const zhCN: TranslationResources = { copyBranchName: "复制分支名称", copied: "已复制", }, + fileActions: { + openFile: "打开文件", + copyPath: "复制路径", + download: "下载", + addToChat: "添加到聊天…", + moreActions: "更多操作", + }, fileExplorer: { sort: { name: "名称", @@ -375,8 +382,6 @@ export const zhCN: TranslationResources = { context: { size: "大小", modified: "修改时间", - copyPath: "复制路径", - download: "下载", }, actions: { back: "返回", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ed3a1689a6f..e34c6aa4f0c 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1160,8 +1160,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ }); // Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out // when only toast state changes (which does not affect any draft field). - const { text, setText, attachments, setAttachments, clear, isHydrated, composerState } = - rawAgentInputDraft; + const { + text, + setText, + attachments, + setAttachments, + clear, + isHydrated, + attachmentFocusRequestId, + composerState, + } = rawAgentInputDraft; const agentInputDraft = useMemo( (): AgentInputDraft => ({ text, @@ -1170,9 +1178,19 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ setAttachments, clear, isHydrated, + attachmentFocusRequestId, composerState, }), - [text, setText, attachments, setAttachments, clear, isHydrated, composerState], + [ + text, + setText, + attachments, + setAttachments, + clear, + isHydrated, + attachmentFocusRequestId, + composerState, + ], ); const streamSection = ( @@ -1537,6 +1555,7 @@ function ActiveAgentComposer({ ; }) => void; + attachWorkspaceFile: (input: { + draftKey: string; + attachment: WorkspaceFileComposerAttachment; + }) => Promise; getCreateModalDraft: () => DraftInput | null; saveCreateModalDraft: (draft: DraftInput | null) => void; - beginDraftGeneration: (draftKey: string) => number; - isDraftGenerationCurrent: (input: { draftKey: string; generation: number }) => boolean; collectActiveAttachmentIds: () => string[]; } -type DraftStore = DraftStoreState & DraftStoreActions; +interface DraftStoreRuntimeState { + attachmentFocusRequestByDraftKey: Record; +} + +type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions; -const draftGenerations = new Map(); let gcScheduled = false; const draftPersistStorage = createDraftPersistStorage( createJSONStorage(() => AsyncStorage), @@ -237,6 +243,7 @@ export const useDraftStore = create()( (set, get) => ({ drafts: {}, createModalDraft: null, + attachmentFocusRequestByDraftKey: {}, getDraftInput: (draftKey) => { const record = get().drafts[draftKey]; @@ -344,7 +351,32 @@ export const useDraftStore = create()( return { drafts: nextDrafts }; }); - draftGenerations.delete(draftKey); + scheduleAttachmentGc(); + }, + + attachWorkspaceFile: async ({ draftKey, attachment }) => { + await get().hydrateDraftInput({ draftKey }); + set((state) => { + const existing = state.drafts[draftKey]; + const draft = toDraftInputIfReady(existing) ?? { text: "", attachments: [] }; + return { + drafts: { + ...state.drafts, + [draftKey]: createDraftRecord({ + draft: { + ...draft, + attachments: appendWorkspaceFileAttachment(draft.attachments, attachment), + }, + lifecycle: "active", + previousVersion: existing?.version, + }), + }, + attachmentFocusRequestByDraftKey: { + ...state.attachmentFocusRequestByDraftKey, + [draftKey]: (state.attachmentFocusRequestByDraftKey[draftKey] ?? 0) + 1, + }, + }; + }); scheduleAttachmentGc(); }, @@ -369,16 +401,6 @@ export const useDraftStore = create()( scheduleAttachmentGc(); }, - beginDraftGeneration: (draftKey) => { - const next = (draftGenerations.get(draftKey) ?? 0) + 1; - draftGenerations.set(draftKey, next); - return next; - }, - - isDraftGenerationCurrent: ({ draftKey, generation }) => { - return (draftGenerations.get(draftKey) ?? 0) === generation; - }, - collectActiveAttachmentIds: () => { return Array.from(collectReferencedAttachmentIdsFromState(get()).values()); }, @@ -387,6 +409,7 @@ export const useDraftStore = create()( name: "paseo-drafts", version: DRAFT_STORE_VERSION, storage: draftPersistStorage, + partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }), migrate: (persistedState) => { return migratePersistedState(persistedState, { migrateLegacyImages, diff --git a/packages/app/src/stores/draft-store/state.test.ts b/packages/app/src/stores/draft-store/state.test.ts index c4ff8f6647c..56807d085a2 100644 --- a/packages/app/src/stores/draft-store/state.test.ts +++ b/packages/app/src/stores/draft-store/state.test.ts @@ -71,6 +71,46 @@ describe("draft-store lifecycle", () => { }); describe("draft-store normalization", () => { + it("preserves uploaded file attachments when hydrating a draft", () => { + const attachment = { + kind: "file" as const, + attachment: { + type: "uploaded_file" as const, + id: "file-1", + fileName: "context.json", + mimeType: "application/json", + size: 42, + path: "/tmp/context.json", + }, + }; + + expect( + toDraftInputIfReady({ + input: { text: "Review this", attachments: [attachment] }, + lifecycle: "active", + updatedAt: 1, + version: 1, + }), + ).toEqual({ text: "Review this", attachments: [attachment] }); + }); + + it("preserves workspace file selections when hydrating a draft", () => { + const attachment = { + kind: "workspace_file" as const, + path: "src/app.ts", + selection: { kind: "line_range" as const, startLine: 12, endLine: 24 }, + }; + + expect( + toDraftInputIfReady({ + input: { text: "Review this", attachments: [attachment] }, + lifecycle: "active", + updatedAt: 1, + version: 1, + }), + ).toEqual({ text: "Review this", attachments: [attachment] }); + }); + it("preserves New Workspace picker ownership when hydrating a draft", () => { const pickerAttachment = { kind: "github_pr" as const, diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index 1515a537f6a..a2314ec3a18 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -3,7 +3,12 @@ import { type AttachmentMetadata, type UserComposerAttachment, } from "@/attachments/types"; -import { ForgeSearchItemSchema, GitHubSearchItemSchema } from "@getpaseo/protocol/messages"; +import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file"; +import { + ForgeSearchItemSchema, + GitHubSearchItemSchema, + UploadedFileAttachmentSchema, +} from "@getpaseo/protocol/messages"; export const DRAFT_STORE_VERSION = 5; export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; @@ -83,6 +88,12 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA const metadata = record.metadata; return isAttachmentMetadata(metadata); } + if (record.kind === "workspace_file") { + return isWorkspaceFileComposerAttachment(value); + } + if (record.kind === "file") { + return UploadedFileAttachmentSchema.safeParse(record.attachment).success; + } if ( record.kind !== "forge_issue" && record.kind !== "forge_change_request" && @@ -113,6 +124,13 @@ export function normalizeComposerAttachment( metadata: normalizeAttachmentMetadata(attachment.metadata), }; } + if (attachment.kind === "workspace_file") { + return { + kind: "workspace_file", + path: attachment.path.trim().replace(/^\.\//, ""), + selection: attachment.selection, + }; + } if (attachment.kind === "github_pr") { const item = (attachment.item as { kind: string }).kind === "pr" diff --git a/packages/app/src/utils/file-mention-autocomplete.test.ts b/packages/app/src/utils/file-mention-autocomplete.test.ts index e5e2ec50685..4f818e25e21 100644 --- a/packages/app/src/utils/file-mention-autocomplete.test.ts +++ b/packages/app/src/utils/file-mention-autocomplete.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { applyFileMentionReplacement, findActiveFileMention } from "./file-mention-autocomplete"; +import { + applyFileMentionReplacement, + findActiveFileMention, + formatQuotedFileMentionPath, +} from "./file-mention-autocomplete"; describe("findActiveFileMention", () => { it("detects mentions at the start of input", () => { @@ -46,6 +50,14 @@ describe("findActiveFileMention", () => { }); }); +describe("formatQuotedFileMentionPath", () => { + it("quotes workspace-relative paths using file mention escaping", () => { + expect(formatQuotedFileMentionPath('src/changed "file".ts')).toBe( + '"src/changed \\"file\\".ts"', + ); + }); +}); + describe("applyFileMentionReplacement", () => { it("replaces only the active @query segment with a quoted relative path", () => { const text = "open @src/com next"; diff --git a/packages/app/src/utils/file-mention-autocomplete.ts b/packages/app/src/utils/file-mention-autocomplete.ts index 748a13458bd..d19424cfe4d 100644 --- a/packages/app/src/utils/file-mention-autocomplete.ts +++ b/packages/app/src/utils/file-mention-autocomplete.ts @@ -40,9 +40,13 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe return null; } +export function formatQuotedFileMentionPath(relativePath: string): string { + const safePath = relativePath.replace(/"/g, '\\"'); + return `"${safePath}"`; +} + export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string { - const safePath = input.relativePath.replace(/"/g, '\\"'); const before = input.text.slice(0, input.mention.start); const after = input.text.slice(input.mention.end); - return `${before}"${safePath}"${after}`; + return `${before}${formatQuotedFileMentionPath(input.relativePath)}${after}`; } From 9952615c33d9b962a5f058b085115c1bbbef8483 Mon Sep 17 00:00:00 2001 From: nikuscs Date: Wed, 22 Jul 2026 15:49:44 +0100 Subject: [PATCH 012/420] fix: stop stale checkout diff subscriptions (#2317) * Fix pending checkout diff subscriptions * Reduce checkout status Git spawns * fix(checkout): own pending diff cancellation Checkout sessions could invalidate a subscription before its shared watch had finished opening. Register targets synchronously and let the diff manager honor cancellation so pending and concurrent subscriptions share one teardown path. --------- Co-authored-by: Mohamed Boudra --- .../src/server/checkout-diff-manager.test.ts | 98 +++++++++++++++++ .../src/server/checkout-diff-manager.ts | 102 ++++++++++++------ .../session/checkout/checkout-session.test.ts | 23 ++-- .../session/checkout/checkout-session.ts | 66 +++++++----- .../server/src/utils/checkout-git.test.ts | 91 ++++++++++++++-- packages/server/src/utils/checkout-git.ts | 87 +++++++-------- packages/server/src/utils/worktree.ts | 20 ++-- 7 files changed, 355 insertions(+), 132 deletions(-) diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index fc3f6b1b872..0f674a755ab 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -14,6 +14,56 @@ import type pino from "pino"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; +interface Deferred { + promise: Promise; + resolve(value: T): void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function createPendingManager() { + const watches: Array<{ + cwd: string; + onChange: () => void; + unsubscribeCalls: number; + resolve(): void; + }> = []; + const workspaceGitService = { + getCheckoutDiff: async () => ({ diff: "", structured: [] }), + requestWorkingTreeWatch: (cwd: string, onChange: () => void) => { + const pending = createDeferred<{ repoRoot: string | null; unsubscribe: () => void }>(); + const watch = { + cwd, + onChange, + unsubscribeCalls: 0, + resolve: () => { + pending.resolve({ + repoRoot: "/tmp/repo", + unsubscribe: () => { + watch.unsubscribeCalls += 1; + }, + }); + }, + }; + watches.push(watch); + return pending.promise; + }, + }; + const logger = { child: () => logger, warn: () => {} }; + const manager = new CheckoutDiffManager({ + logger: logger as unknown as pino.Logger, + paseoHome: "/tmp/paseo-test", + workspaceGitService, + }); + return { manager, watches }; +} + describe("CheckoutDiffManager", () => { beforeEach(() => { vi.useFakeTimers(); @@ -103,6 +153,54 @@ describe("CheckoutDiffManager", () => { expect(unsubscribe).toHaveBeenCalledTimes(1); }); + test("cancels a subscription while its working tree watch is still opening", async () => { + const { manager, watches } = createPendingManager(); + const abort = new AbortController(); + + const pendingSubscription = manager.subscribe( + { + cwd: "/tmp/repo/packages/server", + compare: { mode: "uncommitted" }, + signal: abort.signal, + }, + () => {}, + ); + abort.abort(); + watches[0].resolve(); + await pendingSubscription; + + expect(watches[0].unsubscribeCalls).toBe(1); + expect(manager.getMetrics()).toEqual({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }); + }); + + test("shares one opening target between concurrent subscriptions", async () => { + const { manager, watches } = createPendingManager(); + + const firstSubscription = manager.subscribe( + { cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" } }, + () => {}, + ); + const secondSubscription = manager.subscribe( + { cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" } }, + () => {}, + ); + + expect(watches).toHaveLength(1); + watches[0].resolve(); + const [first, second] = await Promise.all([firstSubscription, secondSubscription]); + expect(manager.getMetrics().checkoutDiffSubscriptionCount).toBe(2); + + first.unsubscribe(); + expect(watches[0].unsubscribeCalls).toBe(0); + second.unsubscribe(); + expect(watches[0].unsubscribeCalls).toBe(1); + }); + test("diffCwd uses repoRoot from the working tree watch result", async () => { const { manager, workspaceGitService } = createManager({ repoRoot: "/tmp/repo" }); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 65f445bbf76..b4f90a62a23 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -6,6 +6,11 @@ import { toCheckoutError } from "./checkout-git-utils.js"; const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; +type CheckoutDiffWorkspace = Pick< + WorkspaceGitService, + "getCheckoutDiff" | "requestWorkingTreeWatch" +>; + export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; export type CheckoutDiffSnapshotPayload = Omit< @@ -32,45 +37,70 @@ interface CheckoutDiffWatchTarget { refreshQueued: boolean; latestPayload: CheckoutDiffSnapshotPayload | null; latestFingerprint: string | null; + openPromise: Promise | null; +} + +export interface CheckoutDiffSubscriptionRequest { + cwd: string; + compare: CheckoutDiffCompareInput; + signal?: AbortSignal; +} + +export interface CheckoutDiffSubscription { + initial: CheckoutDiffSnapshotPayload; + unsubscribe: () => void; } export class CheckoutDiffManager { - private readonly workspaceGitService: WorkspaceGitService; + private readonly workspaceGitService: CheckoutDiffWorkspace; private readonly targets = new Map(); constructor(options: { logger: pino.Logger; paseoHome: string; - workspaceGitService: WorkspaceGitService; + workspaceGitService: CheckoutDiffWorkspace; }) { this.workspaceGitService = options.workspaceGitService; } async subscribe( - params: { - cwd: string; - compare: CheckoutDiffCompareInput; - }, + params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, - ): Promise<{ initial: CheckoutDiffSnapshotPayload; unsubscribe: () => void }> { + ): Promise { const cwd = params.cwd; const compare = this.normalizeCompare(params.compare); - const target = await this.ensureTarget(cwd, compare); + const target = this.ensureTarget(cwd, compare); target.listeners.add(listener); + target.openPromise ??= this.openTarget(target); - const initial = - target.latestPayload ?? - (await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { - diffCwd: target.diffCwd, - })); - target.latestPayload = initial; - target.latestFingerprint = JSON.stringify(initial); - return { - initial, - unsubscribe: () => { - this.removeListener(target.key, listener); - }, + let isSubscribed = true; + const unsubscribe = () => { + if (!isSubscribed) { + return; + } + isSubscribed = false; + params.signal?.removeEventListener("abort", unsubscribe); + this.removeListener(target, listener); }; + params.signal?.addEventListener("abort", unsubscribe, { once: true }); + if (params.signal?.aborted) { + unsubscribe(); + } + + try { + await target.openPromise; + const initial = + target.latestPayload ?? + (await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { + diffCwd: target.diffCwd, + })); + target.latestPayload = initial; + target.latestFingerprint = JSON.stringify(initial); + return { initial, unsubscribe }; + } catch (error) { + unsubscribe(); + throw error; + } } scheduleRefreshForCwd(cwd: string): void { @@ -136,19 +166,17 @@ export class CheckoutDiffManager { } private removeListener( - targetKey: string, + target: CheckoutDiffWatchTarget, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, ): void { - const target = this.targets.get(targetKey); - if (!target) { - return; - } target.listeners.delete(listener); if (target.listeners.size > 0) { return; } this.closeTarget(target); - this.targets.delete(targetKey); + if (this.targets.get(target.key) === target) { + this.targets.delete(target.key); + } } private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void { @@ -231,10 +259,7 @@ export class CheckoutDiffManager { } } - private async ensureTarget( - cwd: string, - compare: CheckoutDiffCompareInput, - ): Promise { + private ensureTarget(cwd: string, compare: CheckoutDiffCompareInput): CheckoutDiffWatchTarget { const targetKey = this.buildTargetKey(cwd, compare); const existing = this.targets.get(targetKey); if (existing) { @@ -253,15 +278,22 @@ export class CheckoutDiffManager { refreshQueued: false, latestPayload: null, latestFingerprint: null, + openPromise: null, }; + this.targets.set(targetKey, target); + return target; + } + + private async openTarget(target: CheckoutDiffWatchTarget): Promise { const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch( - cwd, + target.cwd, () => this.scheduleTargetRefresh(target), ); - target.diffCwd = repoRoot ?? cwd; + target.diffCwd = repoRoot ?? target.cwd; + if (this.targets.get(target.key) !== target || target.listeners.size === 0) { + unsubscribe(); + return; + } target.workingTreeWatchUnsubscribe = unsubscribe; - - this.targets.set(targetKey, target); - return target; } } diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index 36781031328..bebc5e7a99c 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -36,7 +36,7 @@ function isTimelineResponse(msg: SessionOutboundMessage): boolean { interface FakeDiffSubscription { cwd: string; compare: CheckoutDiffCompareInput; - listener: (snapshot: CheckoutDiffSnapshotPayload) => void; + emit(snapshot: CheckoutDiffSnapshotPayload): void; unsubscribeCalls: number; } @@ -45,18 +45,29 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { const refreshedCwds: string[] = []; const subscriber: CheckoutDiffSubscriber = { subscribe: async (params, listener) => { + let isSubscribed = true; const subscription: FakeDiffSubscription = { cwd: params.cwd, compare: params.compare, - listener, unsubscribeCalls: 0, + emit: (snapshot) => { + if (isSubscribed) { + listener(snapshot); + } + }, + }; + const unsubscribe = () => { + if (!isSubscribed) { + return; + } + isSubscribed = false; + subscription.unsubscribeCalls += 1; }; + params.signal?.addEventListener("abort", unsubscribe, { once: true }); subscriptions.push(subscription); return { initial: { ...initial, cwd: params.cwd }, - unsubscribe: () => { - subscription.unsubscribeCalls += 1; - }, + unsubscribe, }; }, scheduleRefreshForCwd: (cwd) => { @@ -453,7 +464,7 @@ describe("CheckoutSession", () => { ]); expect(subscriptions).toHaveLength(1); - subscriptions[0].listener({ + subscriptions[0].emit({ cwd: "/repo", files: [], error: { code: "UNKNOWN", message: "transient" }, diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 3505a5b121b..f3b3ed4dec5 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -17,8 +17,9 @@ import type { ValidateBranchRequest, } from "../../messages.js"; import type { - CheckoutDiffCompareInput, CheckoutDiffSnapshotPayload, + CheckoutDiffSubscription, + CheckoutDiffSubscriptionRequest, } from "../../checkout-diff-manager.js"; import { toCheckoutError } from "../../checkout-git-utils.js"; import { @@ -107,9 +108,9 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub */ export interface CheckoutDiffSubscriber { subscribe( - params: { cwd: string; compare: CheckoutDiffCompareInput }, + params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, - ): Promise<{ initial: CheckoutDiffSnapshotPayload; unsubscribe: () => void }>; + ): Promise; scheduleRefreshForCwd(cwd: string): void; } @@ -400,34 +401,45 @@ export class CheckoutSession { async handleSubscribeDiffRequest(msg: SubscribeCheckoutDiffRequest): Promise { const cwd = expandTilde(msg.cwd); this.diffSubscriptions.get(msg.subscriptionId)?.(); - this.diffSubscriptions.delete(msg.subscriptionId); - const subscription = await this.checkoutDiffManager.subscribe( - { cwd, compare: msg.compare }, - (snapshot) => { - this.host.emit({ - type: "checkout_diff_update", - payload: { - subscriptionId: msg.subscriptionId, - ...snapshot, - }, - }); - }, - ); - this.diffSubscriptions.set(msg.subscriptionId, subscription.unsubscribe); - - this.host.emit({ - type: "subscribe_checkout_diff_response", - payload: { - subscriptionId: msg.subscriptionId, - ...subscription.initial, - requestId: msg.requestId, - }, - }); + const abort = new AbortController(); + const unsubscribe = () => abort.abort(); + this.diffSubscriptions.set(msg.subscriptionId, unsubscribe); + + try { + const subscription = await this.checkoutDiffManager.subscribe( + { cwd, compare: msg.compare, signal: abort.signal }, + (snapshot) => { + this.host.emit({ + type: "checkout_diff_update", + payload: { + subscriptionId: msg.subscriptionId, + ...snapshot, + }, + }); + }, + ); + + this.host.emit({ + type: "subscribe_checkout_diff_response", + payload: { + subscriptionId: msg.subscriptionId, + ...subscription.initial, + requestId: msg.requestId, + }, + }); + } catch (error) { + if (this.diffSubscriptions.get(msg.subscriptionId) === unsubscribe) { + this.diffSubscriptions.delete(msg.subscriptionId); + } + unsubscribe(); + throw error; + } } handleUnsubscribeDiffRequest(msg: UnsubscribeCheckoutDiffRequest): void { - this.diffSubscriptions.get(msg.subscriptionId)?.(); + const unsubscribe = this.diffSubscriptions.get(msg.subscriptionId); this.diffSubscriptions.delete(msg.subscriptionId); + unsubscribe?.(); } async handleRefreshRequest(msg: CheckoutRefreshRequest): Promise { diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 9c319ca5480..877e2dddeec 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -510,6 +510,45 @@ describe("checkout git utilities", () => { expect(message).toBe("update file"); }); + it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + + startGitCommandMetrics(); + const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome }); + const metrics = stopGitCommandMetrics(); + const originUrlCommands = metrics.commands.filter( + (command) => command.args.join(" ") === "config --get remote.origin.url", + ); + + expect(facts.isGit).toBe(true); + expect(originUrlCommands).toHaveLength(1); + }); + + it("reads a non-origin branch remote without replacing it with the origin URL", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + execFileSync("git", ["remote", "set-url", "origin", "git@github.com:upstream/repo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["remote", "add", "fork", "git@github.com:contributor/repo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.main.remote", "fork"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.main.merge", "refs/heads/main"], { cwd: repoDir }); + + startGitCommandMetrics(); + const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome }); + const metrics = stopGitCommandMetrics(); + const commands = metrics.commands.map((command) => command.args.join(" ")); + + expect(facts.isGit).toBe(true); + expect(commands.filter((command) => command === "config --get remote.origin.url")).toHaveLength( + 1, + ); + expect(commands.filter((command) => command === "config --get remote.fork.url")).toHaveLength( + 1, + ); + }); + it("reuses checkout snapshot facts across status, shortstat, and PR status reads", async () => { setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature/facts"], { cwd: repoDir }); @@ -685,19 +724,34 @@ const x = 1; expect(behindStatus.aheadOfOrigin).toBe(0); expect(behindStatus.behindOfOrigin).toBe(1); - writeFileSync(join(repoDir, "local.txt"), "local\n"); - execFileSync("git", ["add", "local.txt"], { cwd: repoDir }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "local update"], { - cwd: repoDir, - }); + commitFile(repoDir, "local-1.txt", "local 1\n", "local update 1"); + commitFile(repoDir, "local-2.txt", "local 2\n", "local update 2"); + commitFile(repoDir, "local-3.txt", "local 3\n", "local update 3"); + commitFile(cloneDir, "remote-2.txt", "remote 2\n", "remote update 2"); + execFileSync("git", ["push"], { cwd: cloneDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); + + const facts = await getCheckoutSnapshotFacts(repoDir); + startGitCommandMetrics(); + const divergedStatus = await getCheckoutStatus(repoDir, { facts }); + const metrics = stopGitCommandMetrics(); + const upstreamCountCommands = metrics.commands.filter( + (command) => command.args[0] === "rev-list" && command.args.join(" ").includes("main"), + ); - const divergedStatus = await getCheckoutStatus(repoDir); expect(divergedStatus.isGit).toBe(true); if (!divergedStatus.isGit) { return; } - expect(divergedStatus.aheadOfOrigin).toBe(1); - expect(divergedStatus.behindOfOrigin).toBe(1); + expect(divergedStatus.aheadOfOrigin).toBe(3); + expect(divergedStatus.behindOfOrigin).toBe(2); + expect(upstreamCountCommands).toHaveLength(1); + expect(upstreamCountCommands[0]?.args).toEqual([ + "rev-list", + "--left-right", + "--count", + "main...origin/main", + ]); }); it("reports a PR worktree as not ahead when its branch is pushed to the configured PR remote", async () => { @@ -800,6 +854,7 @@ const x = 1; return; } expect(status.aheadOfOrigin).toBeNull(); + expect(status.behindOfOrigin).toBeNull(); }); it("does not report full history as unpushed for fresh no-track Paseo worktrees", async () => { @@ -1419,6 +1474,26 @@ const x = 1; expect(diff.diff).toContain("# untracked-large.txt: diff too large omitted"); }); + it("resolves the Git common directory once when reading Paseo worktree facts", async () => { + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "common-dir", + paseoHome, + }); + + startGitCommandMetrics(); + const facts = await getCheckoutSnapshotFacts(result.worktreePath, { paseoHome }); + const metrics = stopGitCommandMetrics(); + const commonDirCommands = metrics.commands.filter( + (command) => command.args.join(" ") === "rev-parse --git-common-dir", + ); + + expect(facts.isGit).toBe(true); + expect(commonDirCommands).toHaveLength(1); + }); + it("handles status/diff/commit in a .paseo worktree", async () => { const result = await createLegacyWorktreeForTest({ branchName: "main", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index e0f507259d1..ffac0e5f258 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1052,10 +1052,15 @@ type PaseoWorktreeForCwd = | { isPaseoOwnedWorktree: false } | { isPaseoOwnedWorktree: true; worktreeRoot: string }; +interface PaseoWorktreeLookupOptions { + context?: CheckoutContext; + knownWorktreeRoot?: string | null; + knownGitCommonDir?: string | null; +} + async function getPaseoWorktreeForCwd( cwd: string, - context?: CheckoutContext, - knownWorktreeRoot?: string | null, + options: PaseoWorktreeLookupOptions = {}, ): Promise { // Fast-path reject: non-worktree paths do not need expensive ownership checks. if (!/[\\/]worktrees[\\/]/.test(cwd)) { @@ -1063,8 +1068,9 @@ async function getPaseoWorktreeForCwd( } const ownership = await isPaseoOwnedWorktreeCwd(cwd, { - paseoHome: context?.paseoHome, - worktreesRoot: context?.worktreesRoot, + paseoHome: options.context?.paseoHome, + worktreesRoot: options.context?.worktreesRoot, + knownGitCommonDir: options.knownGitCommonDir, }); if (!ownership.allowed) { return { isPaseoOwnedWorktree: false }; @@ -1072,7 +1078,7 @@ async function getPaseoWorktreeForCwd( return { isPaseoOwnedWorktree: true, - worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd, context)) ?? cwd, + worktreeRoot: options.knownWorktreeRoot ?? (await getWorktreeRoot(cwd, options.context)) ?? cwd, }; } @@ -1087,7 +1093,7 @@ async function getStoredBaseRefForCwd( if (context?.facts?.isGit) { return context.facts.storedBaseRef; } - const paseoWorktree = await getPaseoWorktreeForCwd(cwd, context); + const paseoWorktree = await getPaseoWorktreeForCwd(cwd, { context }); if (!paseoWorktree.isPaseoOwnedWorktree) { return null; } @@ -1492,30 +1498,6 @@ async function getAheadBehind( return { ahead, behind }; } -async function getAheadOfOrigin( - cwd: string, - currentBranch: string, - context?: CheckoutContext, -): Promise { - if (!currentBranch) { - return null; - } - const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context); - if (!upstreamRef) { - return null; - } - try { - const { stdout } = await runGitCommand( - ["rev-list", "--count", `${upstreamRef}..${currentBranch}`], - { cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger }, - ); - const count = Number.parseInt(stdout.trim(), 10); - return Number.isNaN(count) ? null : count; - } catch { - return null; - } -} - async function getConfiguredUpstreamRef( cwd: string, currentBranch: string, @@ -1537,11 +1519,11 @@ async function getConfiguredUpstreamRef( return upstreamBranch ? `${remoteName}/${upstreamBranch}` : null; } -async function getBehindOfOrigin( +async function getOriginAheadBehind( cwd: string, currentBranch: string, context?: CheckoutContext, -): Promise { +): Promise { if (!currentBranch) { return null; } @@ -1551,11 +1533,13 @@ async function getBehindOfOrigin( } try { const { stdout } = await runGitCommand( - ["rev-list", "--count", `${currentBranch}..${upstreamRef}`], + ["rev-list", "--left-right", "--count", `${currentBranch}...${upstreamRef}`], { cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger }, ); - const count = Number.parseInt(stdout.trim(), 10); - return Number.isNaN(count) ? null : count; + const [aheadRaw, behindRaw] = stdout.trim().split(/\s+/); + const ahead = Number.parseInt(aheadRaw ?? "", 10); + const behind = Number.parseInt(behindRaw ?? "", 10); + return Number.isNaN(ahead) || Number.isNaN(behind) ? null : { ahead, behind }; } catch { return null; } @@ -1579,15 +1563,17 @@ async function inspectCheckoutContext( return null; } - const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] = await Promise.all( - [ - getCurrentBranch(cwd), - getOriginRemoteUrl(cwd), - resolveAbsoluteGitDir(cwd), - resolveGitCommonDir(cwd), - getPaseoWorktreeForCwd(cwd, context, root), - ], - ); + const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir] = await Promise.all([ + getCurrentBranch(cwd), + getOriginRemoteUrl(cwd), + resolveAbsoluteGitDir(cwd), + resolveGitCommonDir(cwd), + ]); + const paseoWorktree = await getPaseoWorktreeForCwd(cwd, { + context, + knownWorktreeRoot: root, + knownGitCommonDir: gitCommonDir, + }); return { worktreeRoot: root, @@ -1776,7 +1762,9 @@ export async function getCheckoutSnapshotFacts( if (branchRemoteName) { [branchMergeRef, branchRemoteUrl] = await Promise.all([ getGitConfigValue(cwd, `branch.${inspected.currentBranch}.merge`, context), - getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context), + branchRemoteName === "origin" + ? inspected.remoteUrl + : getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context), ]); } } @@ -1955,17 +1943,16 @@ export async function getCheckoutStatus( const baseRef = facts.resolvedBaseRef; const mainRepoRoot = facts.mainRepoRoot; const factsContext = { ...context, facts }; - const [aheadBehind, aheadOfOrigin, behindOfOrigin] = await Promise.all([ + const [aheadBehind, originAheadBehind] = await Promise.all([ baseRef && currentBranch ? getAheadBehind(cwd, baseRef, currentBranch, factsContext) : Promise.resolve(null), hasRemote && currentBranch - ? getAheadOfOrigin(cwd, currentBranch, factsContext) - : Promise.resolve(null), - hasRemote && currentBranch - ? getBehindOfOrigin(cwd, currentBranch, factsContext) + ? getOriginAheadBehind(cwd, currentBranch, factsContext) : Promise.resolve(null), ]); + const aheadOfOrigin = originAheadBehind?.ahead ?? null; + const behindOfOrigin = originAheadBehind?.behind ?? null; if (paseoWorktree.isPaseoOwnedWorktree && baseRef) { return { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 5943d5ddf08..4ab641806b9 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -155,6 +155,10 @@ export interface PaseoWorktreeOwnership { worktreePath?: string; } +export interface PaseoWorktreeOwnershipOptions extends WorktreeRootOptions { + knownGitCommonDir?: string | null; +} + export interface WorktreeRootOptions { paseoHome?: string; worktreesRoot?: string; @@ -913,7 +917,7 @@ function resolveRepoRootFromGitCommonDir(commonDir: string): string { export async function isPaseoOwnedWorktreeCwd( cwd: string, - options?: WorktreeRootOptions, + options?: PaseoWorktreeOwnershipOptions, ): Promise { const resolvedCwd = normalizePathForOwnership(cwd); @@ -921,11 +925,15 @@ export async function isPaseoOwnedWorktreeCwd( // previous archive attempt removed the admin dir before the working tree // could be fully cleaned up). We still want to allow archiving in that case. let repoRoot: string | undefined; - try { - const gitCommonDir = await getGitCommonDir(cwd); - repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); - } catch { - // ignore + if (options?.knownGitCommonDir) { + repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir); + } else if (options?.knownGitCommonDir === undefined) { + try { + const gitCommonDir = await getGitCommonDir(cwd); + repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); + } catch { + // ignore + } } const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options); From 76a5edb020c8d5efc1da95a9834d84d3ebe50b2f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 18:31:12 +0200 Subject: [PATCH 013/420] Open existing agents from links and the CLI (#2324) * feat(desktop): open existing agents from links Register a stable agent deep link and route it through the existing Desktop window. Add a matching CLI command that resolves the local server and activates the requested agent without creating or messaging it. * fix(desktop): recover agent link delivery --- docs/development.md | 6 + packages/app/src/app/_layout.tsx | 2 + packages/app/src/desktop/agent-navigation.tsx | 69 +++++++++ packages/app/src/desktop/host.ts | 5 + packages/cli/src/cli-surface.test.ts | 8 ++ packages/cli/src/commands/agent/index.ts | 5 + packages/cli/src/commands/agent/open.ts | 82 +++++++++++ packages/cli/src/commands/open.ts | 51 ++++--- packages/desktop/electron-builder.yml | 4 + packages/desktop/src/agent-navigation.test.ts | 33 +++++ packages/desktop/src/agent-navigation.ts | 40 ++++++ .../src/daemon/desktop-packaging.test.ts | 7 + packages/desktop/src/desktop-startup.test.ts | 8 +- packages/desktop/src/desktop-startup.ts | 4 +- packages/desktop/src/main.ts | 132 ++++++++++++++++-- packages/desktop/src/preload.ts | 7 + packages/protocol/src/agent-deep-link.test.ts | 25 ++++ packages/protocol/src/agent-deep-link.ts | 56 ++++++++ 18 files changed, 503 insertions(+), 41 deletions(-) create mode 100644 packages/app/src/desktop/agent-navigation.tsx create mode 100644 packages/cli/src/commands/agent/open.ts create mode 100644 packages/desktop/src/agent-navigation.test.ts create mode 100644 packages/desktop/src/agent-navigation.ts create mode 100644 packages/protocol/src/agent-deep-link.test.ts create mode 100644 packages/protocol/src/agent-deep-link.ts diff --git a/docs/development.md b/docs/development.md index 81e43cb3b8b..9eed78a00ec 100644 --- a/docs/development.md +++ b/docs/development.md @@ -390,6 +390,7 @@ npm run cli -- ls -a -g # List all agents globally npm run cli -- ls -a -g --json # Same, as JSON npm run cli -- inspect # Show detailed agent info npm run cli -- logs # View agent timeline +npm run cli -- agent open # Focus an existing agent in Paseo Desktop npm run cli -- daemon status # Check daemon status npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project ``` @@ -400,6 +401,11 @@ Use `--host ` to point the CLI at a different daemon: npm run cli -- --host localhost:7777 ls -a ``` +Desktop integrations can focus an existing agent without creating one or +sending a message. Use `paseo://h//agent/`, or run +`paseo agent open `. The CLI reads the local daemon's server ID by +default; pass `--server ` when targeting another server. + ## Agent state Agent data lives at: diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 5c3909798c6..c3346e9279f 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -66,6 +66,7 @@ import { import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; import { ThemedStack } from "@/navigation/themed-stack"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { AgentNavigationListener } from "@/desktop/agent-navigation"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { getDesktopHost } from "@/desktop/host"; @@ -908,6 +909,7 @@ function AppShell() { + diff --git a/packages/app/src/desktop/agent-navigation.tsx b/packages/app/src/desktop/agent-navigation.tsx new file mode 100644 index 00000000000..f85d09cacd3 --- /dev/null +++ b/packages/app/src/desktop/agent-navigation.tsx @@ -0,0 +1,69 @@ +import { useEffect } from "react"; +import { listenToDesktopEvent } from "@/desktop/electron/events"; +import { getDesktopHost } from "@/desktop/host"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; + +interface OpenAgentEventPayload { + serverId?: unknown; + agentId?: unknown; +} + +export function AgentNavigationListener() { + const openAgent = useStableEvent((payload: OpenAgentEventPayload | null) => { + const serverId = typeof payload?.serverId === "string" ? payload.serverId.trim() : ""; + const agentId = typeof payload?.agentId === "string" ? payload.agentId.trim() : ""; + if (!serverId || !agentId) { + return; + } + navigateToAgent({ serverId, agentId }); + }); + + useEffect(() => { + const host = getDesktopHost(); + const ready = host?.agentNavigation?.ready; + if (typeof host?.events?.on !== "function" || typeof ready !== "function") { + return; + } + + let disposed = false; + let unlisten: (() => void) | null = null; + let retryTimer: ReturnType | null = null; + + const connect = async () => { + let dispose: (() => void) | null = null; + try { + dispose = await listenToDesktopEvent("open-agent", openAgent); + if (disposed) { + dispose(); + return; + } + unlisten = dispose; + const pending = await ready(); + if (!disposed && pending) { + openAgent(pending); + } + } catch { + dispose?.(); + if (unlisten === dispose) { + unlisten = null; + } + if (!disposed) { + retryTimer = setTimeout(() => void connect(), 1_000); + } + } + }; + + void connect(); + + return () => { + disposed = true; + if (retryTimer) { + clearTimeout(retryTimer); + } + unlisten?.(); + }; + }, [openAgent]); + + return null; +} diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index cb750af12c3..152f32abc92 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -123,6 +123,10 @@ export interface DesktopEventsBridge { on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void); } +export interface DesktopAgentNavigationBridge { + ready?: () => Promise<{ serverId: string; agentId: string } | null>; +} + export type DesktopBrowserShortcutEvent = | { browserId?: string; action: "focus-url" } | { browserId: string; action: "new-tab" }; @@ -169,6 +173,7 @@ export interface DesktopHostBridge { platform?: string; invoke?: DesktopInvokeBridge["invoke"]; getPendingOpenProject?: () => Promise; + agentNavigation?: DesktopAgentNavigationBridge; events?: DesktopEventsBridge; window?: DesktopWindowModuleBridge; dialog?: DesktopDialogBridge; diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index 769d2e4a654..9011c23b32a 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -34,4 +34,12 @@ describe("canonical CLI surface", () => { expect(run?.helpInformation()).toContain("--background"); expect(run?.helpInformation()).not.toContain("--detach"); }); + + it("offers opening an existing agent in the desktop app", () => { + const agent = createCli().commands.find((command) => command.name() === "agent"); + const open = agent?.commands.find((command) => command.name() === "open"); + + expect(open?.helpInformation()).toContain(""); + expect(open?.helpInformation()).toContain("--server "); + }); }); diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index 37d8f69498a..a0fd5d5def6 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -14,6 +14,7 @@ import { addReloadOptions, runReloadCommand } from "./reload.js"; import { addImportOptions, runImportCommand } from "./import.js"; import { runUpdateCommand } from "./update.js"; import { runDetachCommand } from "./detach.js"; +import { addOpenOptions, runOpenCommand } from "./open.js"; import { withOutput } from "../../output/index.js"; import { addDaemonHostOption, @@ -39,6 +40,10 @@ export function createAgentCommand(): Command { addDaemonHostOption(addLogsOptions(agent.command("logs"))).action(runLogsCommand); + addJsonAndDaemonHostOptions(addOpenOptions(agent.command("open"))).action( + withOutput(runOpenCommand), + ); + addJsonAndDaemonHostOptions(addStopOptions(agent.command("stop"))).action( withOutput(runStopCommand), ); diff --git a/packages/cli/src/commands/agent/open.ts b/packages/cli/src/commands/agent/open.ts new file mode 100644 index 00000000000..333ec61b4ea --- /dev/null +++ b/packages/cli/src/commands/agent/open.ts @@ -0,0 +1,82 @@ +import type { Command } from "commander"; +import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js"; +import { openDesktopWithAgent } from "../open.js"; +import type { + CommandError, + CommandOptions, + OutputSchema, + SingleResult, +} from "../../output/index.js"; + +interface OpenAgentResult { + agentId: string; + serverId: string; + status: "opened"; +} + +const openAgentSchema: OutputSchema = { + idField: "agentId", + columns: [ + { header: "AGENT ID", field: "agentId" }, + { header: "SERVER ID", field: "serverId" }, + { header: "STATUS", field: "status" }, + ], +}; + +export function addOpenOptions(command: Command): Command { + return command + .description("Open an existing agent in Paseo Desktop") + .argument("", "Existing agent ID") + .option("--server ", "Server ID (defaults to the local daemon)"); +} + +async function resolveServerId(options: CommandOptions): Promise { + const explicitServerId = typeof options.server === "string" ? options.server.trim() : ""; + if (explicitServerId) { + return explicitServerId; + } + + let client; + try { + client = await connectToDaemon({ host: options.host }); + } catch (error) { + throw buildDaemonConnectionCommandError({ host: options.host, error }); + } + try { + const serverId = client.getLastServerInfoMessage()?.serverId.trim(); + if (!serverId) { + const error: CommandError = { + code: "SERVER_ID_UNAVAILABLE", + message: "The daemon did not report a server ID.", + }; + throw error; + } + return serverId; + } finally { + await client.close().catch(() => {}); + } +} + +export async function runOpenCommand( + agentIdArg: string, + options: CommandOptions, + _command: Command, +): Promise> { + const agentId = agentIdArg.trim(); + if (!agentId) { + const error: CommandError = { + code: "MISSING_AGENT_ID", + message: "Agent ID is required.", + }; + throw error; + } + + const serverId = await resolveServerId(options); + await openDesktopWithAgent({ serverId, agentId }); + + return { + type: "single", + data: { agentId, serverId, status: "opened" }, + schema: openAgentSchema, + }; +} diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index 87ff16c9d02..c815dd36f85 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { spawnProcess } from "@getpaseo/server"; +import { buildAgentDeepLink, type AgentDeepLinkTarget } from "@getpaseo/protocol/agent-deep-link"; function findDesktopApp(): string | null { if (process.platform === "darwin") { @@ -67,35 +68,39 @@ function spawnDetached(command: string, args: string[]): void { }).unref(); } -export async function openDesktopWithProject(projectPath: string): Promise { - try { - if (process.env.PASEO_DESKTOP_CLI === "1") { - throw new Error( - "Cannot open a desktop project while running in desktop CLI passthrough mode.", - ); - } +function launchDesktop(args: string[]): void { + if (process.env.PASEO_DESKTOP_CLI === "1") { + throw new Error("Cannot open Paseo Desktop while running in desktop CLI passthrough mode."); + } - const desktopApp = findDesktopApp(); - if (!desktopApp) { - throw new Error( - "Paseo desktop app not found. Install it from https://github.com/getpaseo/paseo/releases", - ); - } + const desktopApp = findDesktopApp(); + if (!desktopApp) { + throw new Error( + "Paseo desktop app not found. Install it from https://github.com/getpaseo/paseo/releases", + ); + } - if (process.platform === "darwin") { - // -n forces a new instance even if the app is already running. - // The new instance hits requestSingleInstanceLock(), fails, and relays - // the argv to the first instance via the second-instance event. - // -g keeps the terminal in the foreground (better CLI UX). - // Without -n, macOS just activates the existing window and drops --args. - spawnDetached("open", ["-n", "-g", "-a", desktopApp, "--args", projectPath]); - return; - } + if (process.platform === "darwin") { + // -n forces a new instance even if the app is already running. The new + // instance relays its argv to the existing one through Electron's + // single-instance lock. -g keeps the terminal in the foreground. + spawnDetached("open", ["-n", "-g", "-a", desktopApp, "--args", ...args]); + return; + } + + spawnDetached(desktopApp, args); +} - spawnDetached(desktopApp, [projectPath]); +export async function openDesktopWithProject(projectPath: string): Promise { + try { + launchDesktop([projectPath]); } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); process.exitCode = 1; } } + +export async function openDesktopWithAgent(target: AgentDeepLinkTarget): Promise { + launchDesktop([buildAgentDeepLink(target)]); +} diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 53583c8e90e..bbee5e034d9 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -2,6 +2,10 @@ npmRebuild: false appId: sh.paseo.desktop productName: Paseo executableName: Paseo +protocols: + - name: Paseo agent link + schemes: + - paseo afterPack: ./scripts/after-pack.js afterSign: ./scripts/after-sign.js directories: diff --git a/packages/desktop/src/agent-navigation.test.ts b/packages/desktop/src/agent-navigation.test.ts new file mode 100644 index 00000000000..4be345421a5 --- /dev/null +++ b/packages/desktop/src/agent-navigation.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { AgentNavigationInbox, parseAgentDeepLinkFromArgv } from "./agent-navigation.js"; + +describe("desktop agent navigation", () => { + it("finds an agent deep link among Electron launch arguments", () => { + expect( + parseAgentDeepLinkFromArgv([ + "/Applications/Paseo.app/Contents/MacOS/Paseo", + "--no-sandbox", + "paseo://h/server-1/agent/agent-2", + ]), + ).toEqual({ serverId: "server-1", agentId: "agent-2" }); + }); + + it("holds navigation until the existing renderer is ready", () => { + const inbox = new AgentNavigationInbox(); + const target = { serverId: "server-1", agentId: "agent-2" }; + + expect(inbox.deliverOrQueue(7, target)).toBeNull(); + expect(inbox.windowReady(7)).toEqual(target); + expect(inbox.deliverOrQueue(7, target)).toEqual(target); + }); + + it("returns only the newest navigation queued during startup", () => { + const inbox = new AgentNavigationInbox(); + + inbox.deliverOrQueue(7, { serverId: "server-1", agentId: "agent-1" }); + inbox.deliverOrQueue(7, { serverId: "server-1", agentId: "agent-2" }); + + expect(inbox.windowReady(7)).toEqual({ serverId: "server-1", agentId: "agent-2" }); + expect(inbox.windowReady(7)).toBeNull(); + }); +}); diff --git a/packages/desktop/src/agent-navigation.ts b/packages/desktop/src/agent-navigation.ts new file mode 100644 index 00000000000..9ebc0823ac1 --- /dev/null +++ b/packages/desktop/src/agent-navigation.ts @@ -0,0 +1,40 @@ +import { parseAgentDeepLink, type AgentDeepLinkTarget } from "@getpaseo/protocol/agent-deep-link"; + +export function parseAgentDeepLinkFromArgv(argv: string[]): AgentDeepLinkTarget | null { + for (const arg of argv) { + const target = parseAgentDeepLink(arg); + if (target) { + return target; + } + } + return null; +} + +export class AgentNavigationInbox { + private readonly readyWindows = new Set(); + private readonly pendingByWindow = new Map(); + + windowLoading(webContentsId: number): void { + this.readyWindows.delete(webContentsId); + } + + windowReady(webContentsId: number): AgentDeepLinkTarget | null { + this.readyWindows.add(webContentsId); + const pending = this.pendingByWindow.get(webContentsId) ?? null; + this.pendingByWindow.delete(webContentsId); + return pending; + } + + deliverOrQueue(webContentsId: number, target: AgentDeepLinkTarget): AgentDeepLinkTarget | null { + if (this.readyWindows.has(webContentsId)) { + return target; + } + this.pendingByWindow.set(webContentsId, target); + return null; + } + + removeWindow(webContentsId: number): void { + this.readyWindows.delete(webContentsId); + this.pendingByWindow.delete(webContentsId); + } +} diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index b0272d93ace..8eac5ecc124 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -89,6 +89,13 @@ describe("desktop packaging", () => { expect(config).toContain("!node_modules/@getpaseo/server/dist/server/web-ui/**"); }); + it("registers Paseo agent links with the operating system", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + + expect(config).toContain("name: Paseo agent link"); + expect(config).toContain("- paseo"); + }); + // electron-builder packs production dependencies declared in package.json into // app.asar. Runtime code in runtime-paths.ts and bin/paseo dynamically resolves // these workspace packages by string, so static analysis (TypeScript, Knip) cannot diff --git a/packages/desktop/src/desktop-startup.test.ts b/packages/desktop/src/desktop-startup.test.ts index c447c216dbc..a820efecbf8 100644 --- a/packages/desktop/src/desktop-startup.test.ts +++ b/packages/desktop/src/desktop-startup.test.ts @@ -5,7 +5,7 @@ describe("desktop startup", () => { it("runs CLI passthrough before GUI login-shell env inheritance", async () => { const calls: string[] = []; await runDesktopStartup({ - hasPendingOpenProjectPath: false, + hasPendingGuiLaunchRequest: false, runCliPassthroughIfRequested: vi.fn(async () => { calls.push("cli"); return true; @@ -22,7 +22,7 @@ describe("desktop startup", () => { it("keeps login-shell env inheritance on normal GUI startup", async () => { const calls: string[] = []; await runDesktopStartup({ - hasPendingOpenProjectPath: false, + hasPendingGuiLaunchRequest: false, runCliPassthroughIfRequested: vi.fn(async () => { calls.push("cli"); return false; @@ -39,7 +39,7 @@ describe("desktop startup", () => { it("starts skills auto-update after GUI startup", async () => { const calls: string[] = []; await runDesktopStartup({ - hasPendingOpenProjectPath: false, + hasPendingGuiLaunchRequest: false, runCliPassthroughIfRequested: vi.fn(async () => { calls.push("cli"); return false; @@ -59,7 +59,7 @@ describe("desktop startup", () => { const calls: string[] = []; await runDesktopStartup({ - hasPendingOpenProjectPath: true, + hasPendingGuiLaunchRequest: true, runCliPassthroughIfRequested, inheritLoginShellEnv: vi.fn(() => calls.push("env")), bootstrapGui: vi.fn(async () => { diff --git a/packages/desktop/src/desktop-startup.ts b/packages/desktop/src/desktop-startup.ts index 25943e342c1..d1be6921737 100644 --- a/packages/desktop/src/desktop-startup.ts +++ b/packages/desktop/src/desktop-startup.ts @@ -1,5 +1,5 @@ export interface DesktopStartupDependencies { - hasPendingOpenProjectPath: boolean; + hasPendingGuiLaunchRequest: boolean; runCliPassthroughIfRequested: () => Promise; inheritLoginShellEnv: () => void; bootstrapGui: () => Promise; @@ -7,7 +7,7 @@ export interface DesktopStartupDependencies { } export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise { - if (!deps.hasPendingOpenProjectPath && (await deps.runCliPassthroughIfRequested())) { + if (!deps.hasPendingGuiLaunchRequest && (await deps.runCliPassthroughIfRequested())) { return; } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index ef4e8a82e9c..5509fdd0e73 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -90,6 +90,12 @@ import { autoUpdateInstalledSkills } from "./integrations/skills/index.js"; import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js"; import { BrowserKeyboard } from "./features/browser-keyboard/index.js"; import { installAppUpdateOnQuit } from "./features/auto-updater.js"; +import { + buildAgentDeepLinkRoute, + parseAgentDeepLink, + type AgentDeepLinkTarget, +} from "@getpaseo/protocol/agent-deep-link"; +import { AgentNavigationInbox, parseAgentDeepLinkFromArgv } from "./agent-navigation.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; @@ -98,6 +104,16 @@ const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_L const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo"; const UPDATE_QUIT_DEADLINE_MS = 5_000; const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests(); +const agentNavigationInbox = new AgentNavigationInbox(); + +// A second-instance launch can arrive before the packaged protocol handler, +// IPC handlers, and first window exist. Wait for full bootstrap, not just +// app.whenReady(), before delivering navigation to the renderer. +let resolveBootstrapComplete: () => void; +const bootstrapComplete = new Promise((resolve) => { + resolveBootstrapComplete = resolve; +}); +let bootstrapIsComplete = false; app.setName(APP_NAME); @@ -316,6 +332,7 @@ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({ argv: process.argv, isDefaultApp: process.defaultApp, }); +let pendingAgentNavigation = parseAgentDeepLinkFromArgv(process.argv); // Each window pulls its own pending open-project path on mount, keyed by // webContents id, so deep-linked windows (second-instance launches, the @@ -341,6 +358,10 @@ ipcMain.handle("paseo:get-pending-open-project", (event) => { return result; }); +ipcMain.handle("paseo:agent-navigation:ready", (event) => { + return agentNavigationInbox.windowReady(event.sender.id); +}); + function normalizeBrowserCaptureRect( rect: unknown, ): { x: number; y: number; width: number; height: number } | null { @@ -653,6 +674,7 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] { async function createWindow( options: { + initialRoute?: string | null; pendingOpenProjectPath?: string | null; restoreWindowState?: boolean; } = {}, @@ -694,8 +716,14 @@ async function createWindow( const webContentsId = mainWindow.webContents.id; pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath); + mainWindow.webContents.on("did-start-navigation", (_event, _url, isSameDocument, isMainFrame) => { + if (isMainFrame && !isSameDocument) { + agentNavigationInbox.windowLoading(webContentsId); + } + }); mainWindow.on("closed", () => { pendingOpenProjectStore.delete(webContentsId); + agentNavigationInbox.removeWindow(webContentsId); unregisterPaseoBrowserHost(webContentsId); browserKeyboard.detachHost(webContentsId); }); @@ -759,11 +787,14 @@ async function createWindow( if (!app.isPackaged) { const { loadReactDevTools } = await import("./features/react-devtools.js"); await loadReactDevTools(); - await mainWindow.loadURL(DEV_SERVER_URL); + const initialUrl = options.initialRoute + ? new URL(options.initialRoute, `${DEV_SERVER_URL}/`).toString() + : DEV_SERVER_URL; + await mainWindow.loadURL(initialUrl); return mainWindow; } - await mainWindow.loadURL(`${APP_SCHEME}://app/`); + await mainWindow.loadURL(`${APP_SCHEME}://app${options.initialRoute ?? "/"}`); return mainWindow; } @@ -771,14 +802,72 @@ async function createWindow( // App lifecycle // --------------------------------------------------------------------------- -// Resolves once bootstrap() has registered the custom protocol handler and IPC -// handlers and created the first window. second-instance window creation waits -// on this rather than app.whenReady(): in packaged mode createWindow loads -// `paseo://app/`, which fails if the protocol handler isn't registered yet, and -// a second instance can arrive mid-cold-start. -let resolveBootstrapComplete: () => void; -const bootstrapComplete = new Promise((resolve) => { - resolveBootstrapComplete = resolve; +let agentNavigationWindowCreation: Promise | null = null; + +function focusExistingWindowOnAgent(target: AgentDeepLinkTarget): void { + const windows = BrowserWindow.getAllWindows(); + const mainWindow = + BrowserWindow.getFocusedWindow() ?? windows.find((window) => window.isVisible()) ?? windows[0]; + if (!mainWindow || mainWindow.isDestroyed()) { + if (!agentNavigationWindowCreation) { + const creation = createWindow({ + initialRoute: buildAgentDeepLinkRoute(target), + restoreWindowState: true, + }); + agentNavigationWindowCreation = creation; + void creation + .catch((error) => log.error("[window] failed to create window for agent link", error)) + .finally(() => { + if (agentNavigationWindowCreation === creation) { + agentNavigationWindowCreation = null; + } + }); + return; + } + + void agentNavigationWindowCreation + .then(() => focusExistingWindowOnAgent(target)) + .catch((error) => log.error("[window] failed to deliver queued agent link", error)); + return; + } + + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.show(); + mainWindow.focus(); + + const deliverable = agentNavigationInbox.deliverOrQueue(mainWindow.webContents.id, target); + if (deliverable) { + mainWindow.webContents.send("paseo:event:open-agent", deliverable); + } +} + +function receiveAgentDeepLink(input: string): void { + const target = parseAgentDeepLink(input); + if (!target) { + return; + } + + if (bootstrapIsComplete) { + focusExistingWindowOnAgent(target); + return; + } + + pendingAgentNavigation = target; + void bootstrapComplete.then(() => { + if (pendingAgentNavigation !== target) { + return undefined; + } + pendingAgentNavigation = null; + focusExistingWindowOnAgent(target); + return undefined; + }); +} + +app.on("open-url", (event, url) => { + event.preventDefault(); + receiveAgentDeepLink(url); }); function setupSingleInstanceLock(): boolean { @@ -794,6 +883,12 @@ function setupSingleInstanceLock(): boolean { } app.on("second-instance", (_event, commandLine) => { + const agentTarget = parseAgentDeepLinkFromArgv(commandLine); + if (agentTarget) { + void bootstrapComplete.then(() => focusExistingWindowOnAgent(agentTarget)); + return; + } + log.info("[open-project] second-instance commandLine:", commandLine); const openProjectPath = parseOpenProjectPathFromArgv({ argv: commandLine, @@ -895,13 +990,26 @@ async function bootstrap(): Promise { }); // The first window of the session restores and persists saved geometry. - await createWindow({ pendingOpenProjectPath, restoreWindowState: true }); + const initialAgentNavigation = pendingAgentNavigation; + pendingAgentNavigation = null; + await createWindow({ + initialRoute: initialAgentNavigation ? buildAgentDeepLinkRoute(initialAgentNavigation) : null, + pendingOpenProjectPath, + restoreWindowState: true, + }); pendingOpenProjectPath = null; // Protocol + IPC handlers and the first window now exist: release any // second-instance launches that arrived during cold start. + bootstrapIsComplete = true; resolveBootstrapComplete(); + if (pendingAgentNavigation) { + const target = pendingAgentNavigation; + pendingAgentNavigation = null; + focusExistingWindowOnAgent(target); + } + app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { await createWindow({ restoreWindowState: true }); @@ -910,7 +1018,7 @@ async function bootstrap(): Promise { } void runDesktopStartup({ - hasPendingOpenProjectPath: Boolean(pendingOpenProjectPath), + hasPendingGuiLaunchRequest: Boolean(pendingOpenProjectPath || pendingAgentNavigation), runCliPassthroughIfRequested, inheritLoginShellEnv, bootstrapGui: bootstrap, diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index b409de37c34..1267d223325 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -23,6 +23,13 @@ contextBridge.exposeInMainWorld("paseoDesktop", { ipcRenderer.invoke("paseo:invoke", command, args), getPendingOpenProject: () => ipcRenderer.invoke("paseo:get-pending-open-project") as Promise, + agentNavigation: { + ready: () => + ipcRenderer.invoke("paseo:agent-navigation:ready") as Promise<{ + serverId: string; + agentId: string; + } | null>, + }, events: { on: (event: string, handler: EventHandler): Promise<() => void> => { const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => { diff --git a/packages/protocol/src/agent-deep-link.test.ts b/packages/protocol/src/agent-deep-link.test.ts new file mode 100644 index 00000000000..a8f6ca96f1c --- /dev/null +++ b/packages/protocol/src/agent-deep-link.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentDeepLink, + buildAgentDeepLinkRoute, + parseAgentDeepLink, +} from "./agent-deep-link.js"; + +describe("agent deep links", () => { + it("round-trips an existing agent target", () => { + const target = { serverId: "server/main", agentId: "agent 123" }; + + const link = buildAgentDeepLink(target); + + expect(link).toBe("paseo://h/server%2Fmain/agent/agent%20123"); + expect(buildAgentDeepLinkRoute(target)).toBe("/h/server%2Fmain/agent/agent%20123"); + expect(parseAgentDeepLink(link)).toEqual(target); + }); + + it("rejects links outside the exact agent route", () => { + expect(parseAgentDeepLink("https://h/server/agent/agent-1")).toBeNull(); + expect(parseAgentDeepLink("paseo://app/h/server/agent/agent-1")).toBeNull(); + expect(parseAgentDeepLink("paseo://h/server/agent/agent-1?message=hello")).toBeNull(); + expect(parseAgentDeepLink("paseo://h/server/agent/agent-1/extra")).toBeNull(); + }); +}); diff --git a/packages/protocol/src/agent-deep-link.ts b/packages/protocol/src/agent-deep-link.ts new file mode 100644 index 00000000000..bb4980ffe3e --- /dev/null +++ b/packages/protocol/src/agent-deep-link.ts @@ -0,0 +1,56 @@ +export interface AgentDeepLinkTarget { + serverId: string; + agentId: string; +} + +function normalizeSegment(value: string): string { + return value.trim(); +} + +export function buildAgentDeepLink(target: AgentDeepLinkTarget): string { + const serverId = normalizeSegment(target.serverId); + const agentId = normalizeSegment(target.agentId); + if (!serverId || !agentId) { + throw new Error("Agent deep links require a server ID and agent ID."); + } + return `paseo://h/${encodeURIComponent(serverId)}/agent/${encodeURIComponent(agentId)}`; +} + +export function buildAgentDeepLinkRoute(target: AgentDeepLinkTarget): string { + const link = new URL(buildAgentDeepLink(target)); + return `/h${link.pathname}`; +} + +export function parseAgentDeepLink(input: string): AgentDeepLinkTarget | null { + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + + if ( + url.protocol !== "paseo:" || + url.hostname !== "h" || + url.username || + url.password || + url.port || + url.search || + url.hash + ) { + return null; + } + + const segments = url.pathname.split("/").filter(Boolean); + if (segments.length !== 3 || segments[1] !== "agent") { + return null; + } + + try { + const serverId = normalizeSegment(decodeURIComponent(segments[0] ?? "")); + const agentId = normalizeSegment(decodeURIComponent(segments[2] ?? "")); + return serverId && agentId ? { serverId, agentId } : null; + } catch { + return null; + } +} From 512c9b31a8f022d93a9d1cbf3da0c4a21c7e8e28 Mon Sep 17 00:00:00 2001 From: nikuscs Date: Wed, 22 Jul 2026 19:57:18 +0100 Subject: [PATCH 014/420] feat: open Changes as a workspace tab (#2298) Open the complete working comparison in a desktop workspace tab while preserving inline sidebar diffs when the tab is closed. Working and commit comparisons share one diff panel, and workspace layout is the sole owner of live tab persistence and identity. Refs #1520 --- packages/app/e2e/commit-diff-panel.spec.ts | 18 +- packages/app/e2e/diff-row-alignment.spec.ts | 82 +- packages/app/src/agent-stream/view.tsx | 2 +- .../workspace/[workspaceId]/index.tsx | 2 +- .../src/browser-automation/handler.test.ts | 6 +- .../app/src/browser-automation/handler.ts | 7 +- .../app/src/client-slash-commands/index.ts | 2 +- .../compact-explorer-sidebar-host-state.ts | 2 +- .../app/src/components/explorer-sidebar.tsx | 6 +- .../app/src/components/split-container.tsx | 2 +- .../app/src/composer/draft/workspace-tab.tsx | 2 +- .../src/composer/focused-chat-target.test.ts | 2 +- packages/app/src/git/diff-pane.tsx | 939 +++++++++++------- packages/app/src/git/use-diff-query.ts | 17 +- packages/app/src/git/use-working-diff.ts | 169 ++++ packages/app/src/i18n/resources/ar.ts | 4 + packages/app/src/i18n/resources/en.ts | 4 + packages/app/src/i18n/resources/es.ts | 4 + packages/app/src/i18n/resources/fr.ts | 4 + packages/app/src/i18n/resources/ja.ts | 4 + packages/app/src/i18n/resources/pt-BR.ts | 4 + packages/app/src/i18n/resources/ru.ts | 4 + packages/app/src/i18n/resources/zh-CN.ts | 4 + packages/app/src/panels/agent-panel.tsx | 2 +- packages/app/src/panels/commit-diff-panel.tsx | 184 ---- packages/app/src/panels/diff-panel.tsx | 409 ++++++++ packages/app/src/panels/pane-context.tsx | 2 +- packages/app/src/panels/panel-registry.ts | 2 +- packages/app/src/panels/register-panels.ts | 3 +- packages/app/src/panels/setup-panel.tsx | 2 +- .../app/src/screens/new-workspace-screen.tsx | 2 +- .../workspace/visible-agent-ids.test.ts | 2 +- .../screens/workspace/visible-agent-ids.ts | 2 +- .../workspace/workspace-desktop-tabs-row.tsx | 6 +- .../workspace/workspace-file-open-command.ts | 2 +- .../workspace/workspace-pane-state.test.ts | 2 +- .../screens/workspace/workspace-pane-state.ts | 2 +- .../screens/workspace/workspace-screen.tsx | 17 +- .../workspace/workspace-tab-menu.test.ts | 36 +- .../screens/workspace/workspace-tab-menu.ts | 3 + .../workspace/workspace-tab-model.test.ts | 2 +- .../screens/workspace/workspace-tab-model.ts | 2 +- .../screens/workspace/workspace-tabs-types.ts | 2 +- .../navigation.test.ts | 2 +- .../navigation.ts | 2 +- .../workspace-draft-submission-store.ts | 2 +- .../src/stores/workspace-layout-actions.ts | 11 +- .../src/stores/workspace-layout-store.test.ts | 72 +- .../app/src/stores/workspace-layout-store.ts | 7 +- .../app/src/stores/workspace-setup-store.ts | 2 +- .../workspace-subagents-integration.test.ts | 3 +- .../src/stores/workspace-tabs-store/index.ts | 124 --- .../stores/workspace-tabs-store/state.test.ts | 432 -------- .../src/stores/workspace-tabs-store/state.ts | 799 --------------- .../app/src/utils/prepare-workspace-tab.ts | 5 +- .../src/utils/workspace-navigation.test.ts | 2 +- .../app/src/workspace-tabs/identity.test.ts | 36 + packages/app/src/workspace-tabs/identity.ts | 38 +- packages/app/src/workspace-tabs/model.test.ts | 18 + packages/app/src/workspace-tabs/model.ts | 46 + .../src/workspace/use-workspace-archive.ts | 8 +- 61 files changed, 1576 insertions(+), 2007 deletions(-) create mode 100644 packages/app/src/git/use-working-diff.ts delete mode 100644 packages/app/src/panels/commit-diff-panel.tsx create mode 100644 packages/app/src/panels/diff-panel.tsx delete mode 100644 packages/app/src/stores/workspace-tabs-store/index.ts delete mode 100644 packages/app/src/stores/workspace-tabs-store/state.test.ts delete mode 100644 packages/app/src/stores/workspace-tabs-store/state.ts create mode 100644 packages/app/src/workspace-tabs/model.test.ts create mode 100644 packages/app/src/workspace-tabs/model.ts diff --git a/packages/app/e2e/commit-diff-panel.spec.ts b/packages/app/e2e/commit-diff-panel.spec.ts index ca554040094..25d2ed7d2a6 100644 --- a/packages/app/e2e/commit-diff-panel.spec.ts +++ b/packages/app/e2e/commit-diff-panel.spec.ts @@ -28,26 +28,20 @@ test("commit history shows dates and shares diff layout preferences", async ({ const panel = page.getByTestId("commit-diff-panel").filter({ visible: true }); await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 }); - await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute( - "aria-selected", - "true", - ); + const layoutToggle = panel.getByTestId("commit-diff-toggle-layout"); + await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff"); await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 }); - await panel.getByTestId("commit-diff-layout-split").click(); - await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute( - "aria-selected", - "true", - ); + await layoutToggle.click(); + await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff"); await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0); await expect(panel.getByTestId("diff-file-0-body")).toBeVisible(); await page.getByTestId(/^workspace-commit-diff-close-/).click(); await expect(panel).toHaveCount(0); await commitRow.click(); - await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute( - "aria-selected", - "true", + await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName( + "Switch to unified diff", { timeout: 30_000 }, ); diff --git a/packages/app/e2e/diff-row-alignment.spec.ts b/packages/app/e2e/diff-row-alignment.spec.ts index fecb6529f1f..aff4789a098 100644 --- a/packages/app/e2e/diff-row-alignment.spec.ts +++ b/packages/app/e2e/diff-row-alignment.spec.ts @@ -10,6 +10,7 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; interface DirtyWorkspace { id: string; + repoPath: string; } interface WorkspaceFixtureOptions { @@ -241,6 +242,80 @@ test("changes file actions open from the kebab and right-click", async ({ page } await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible(); }); +test("Changes switches between inline and full-tab navigation", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + const changesTabToggle = page.getByTestId("changes-open-tab"); + await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab"); + await changesTabToggle.click(); + await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab"); + + const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true }); + await expect(visiblePanel).toBeVisible(); + await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible(); + await expect(visiblePanel).toContainText("zz-deleted.ts"); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0); + await visiblePanel.getByTestId("diff-file-0-toggle").click(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0); + await visiblePanel.getByTestId("diff-file-0-toggle").click(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout"); + await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff"); + await workingDiffLayoutToggle.click(); + await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff"); + await visiblePanel.getByTestId("working-diff-options-menu").click(); + await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace"); + await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines"); + await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh"); + await page.getByTestId("working-diff-toggle-wrap-lines").click(); + await visiblePanel.getByTestId("working-diff-options-menu").click(); + await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText( + "Scroll long lines", + ); + await page.keyboard.press("Escape"); + await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); + await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0); + await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + + await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); + await expect( + page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), + ).toHaveCount(0); + await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); + + await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); + await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, { + timeout: 30_000, + }); + await expect(visiblePanel).toContainText("zz-deleted.ts"); + await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER); + await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText( + "zz-deleted.ts", + ); + await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click(); + await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); + await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible(); + await expect(visiblePanel).toContainText("Deleted"); + + await changesTabToggle.click(); + await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0); + await expect( + page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), + ).toBeVisible(); + await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); + await expect( + page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), + ).toHaveCount(0); +}); + test("changes diff switches between flat and tree file lists", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); @@ -266,6 +341,11 @@ test("changes diff switches between flat and tree file lists", async ({ page }) await expect(page.getByTestId("diff-folder-src")).toBeVisible(); await expect(page.getByTestId("diff-file-0")).toBeVisible(); + await page.getByRole("button", { name: "Collapse all" }).click(); + await expect(page.getByTestId("diff-file-0")).toHaveCount(0); + await page.getByRole("button", { name: "Expand all" }).click(); + await expect(page.getByTestId("diff-file-0-body")).toBeVisible(); + await page.getByTestId("diff-folder-src-toggle").click(); await expect(page.getByTestId("diff-file-0")).toHaveCount(0); @@ -495,7 +575,7 @@ async function createWorkspaceWithMountedTabDiff( if (!createdWorkspace.workspace) { throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`); } - return { id: createdWorkspace.workspace.id }; + return { id: createdWorkspace.workspace.id, repoPath: repo.path }; } async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise { diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index c6709202955..3ff24bcc897 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -99,7 +99,7 @@ import { useWorkspaceAttachmentsStore, } from "@/attachments/workspace-attachments-store"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; -import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { toErrorMessage } from "@/utils/error-messages"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index f51e1335b69..6ecc445e7e7 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -9,7 +9,7 @@ import { useActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; import { diff --git a/packages/app/src/browser-automation/handler.test.ts b/packages/app/src/browser-automation/handler.test.ts index 6a7d3573a91..4af65a17ff7 100644 --- a/packages/app/src/browser-automation/handler.test.ts +++ b/packages/app/src/browser-automation/handler.test.ts @@ -4,10 +4,8 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware"; import { mountBrowserAutomationHandler } from "./handler"; import type { DesktopHostBridge } from "@/desktop/host"; import { useBrowserStore } from "@/stores/browser-store"; -import { - buildWorkspaceTabPersistenceKey, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; type BrowserAutomationExecuteRequest = Extract< SessionOutboundMessage, diff --git a/packages/app/src/browser-automation/handler.ts b/packages/app/src/browser-automation/handler.ts index 6f8ab8ad8a1..b7cf3d7cdde 100644 --- a/packages/app/src/browser-automation/handler.ts +++ b/packages/app/src/browser-automation/handler.ts @@ -6,11 +6,8 @@ import { resizeResidentBrowserWebview, } from "@/components/browser-webview-resident"; import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store"; -import { - buildWorkspaceTabPersistenceKey, - collectAllTabs, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; +import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; type BrowserAutomationExecuteRequest = Extract< SessionOutboundMessage, diff --git a/packages/app/src/client-slash-commands/index.ts b/packages/app/src/client-slash-commands/index.ts index 99c4bd202d4..5d1a31f7d2f 100644 --- a/packages/app/src/client-slash-commands/index.ts +++ b/packages/app/src/client-slash-commands/index.ts @@ -1,5 +1,5 @@ import type { Agent } from "@/stores/session-store"; -import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft"; export type ClientSlashCommandExecution = "immediate" | "insert"; diff --git a/packages/app/src/components/compact-explorer-sidebar-host-state.ts b/packages/app/src/components/compact-explorer-sidebar-host-state.ts index c0d201623cf..8d44a9d7670 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host-state.ts +++ b/packages/app/src/components/compact-explorer-sidebar-host-state.ts @@ -1,4 +1,4 @@ -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { WorkspaceDescriptor } from "@/stores/session-store"; diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index c233f358246..f4fd089fdaa 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -36,10 +36,8 @@ import { RetainedPanelActivity } from "@/components/retained-panel"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; -import { - buildWorkspaceTabPersistenceKey, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { resolveFocusedChatTarget } from "@/composer/focused-chat-target"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; import { useDraftStore } from "@/stores/draft-store"; diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 0d81321d4ae..59e684968f5 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -76,7 +76,7 @@ import { type SplitPane, type WorkspaceLayout, } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; import { RenderProfile } from "@/utils/render-profiler"; import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { isNative } from "@/constants/platform"; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index d75e9e940d8..975b51dde17 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -50,7 +50,7 @@ import { useIsCompactFormFactor, } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; -import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_ONLINE_SERVER_IDS: string[] = []; diff --git a/packages/app/src/composer/focused-chat-target.test.ts b/packages/app/src/composer/focused-chat-target.test.ts index cd8f8014eeb..a76ae6f64a3 100644 --- a/packages/app/src/composer/focused-chat-target.test.ts +++ b/packages/app/src/composer/focused-chat-target.test.ts @@ -3,7 +3,7 @@ import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; import { resolveFocusedChatTarget } from "./focused-chat-target"; function layoutWithTarget( - target: import("@/stores/workspace-tabs-store").WorkspaceTab["target"], + target: import("@/workspace-tabs/model").WorkspaceTab["target"], ): WorkspaceLayout { return { root: { diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 2048e774245..5be0ddd5591 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -42,18 +42,14 @@ import { List, ListChevronsDownUp, ListChevronsUpDown, + Maximize2, Pilcrow, RefreshCcw, RotateCw, Upload, WrapText, } from "lucide-react-native"; -import { - useCheckoutDiffQuery, - type ParsedDiffFile, - type DiffLine, - type HighlightToken, -} from "@/git/use-diff-query"; +import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query"; import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items"; import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree"; import { DiffFolderRow } from "@/git/diff-folder-row"; @@ -64,7 +60,6 @@ import { } from "@/components/tree-primitives"; import { SvgXml } from "react-native-svg"; import { getFileIconSvg } from "@/components/material-file-icons"; -import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { CommitsSection } from "@/git/commits-section/commits-section"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; @@ -105,8 +100,8 @@ import { useSessionStore } from "@/stores/session-store"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { usePanelStore } from "@/stores/panel-store"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { formatDiffContentText, @@ -116,27 +111,25 @@ import { import { isWeb, isNative } from "@/constants/platform"; import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; import { - buildWorkspaceAttachmentScopeKey, - useWorkspaceAttachmentsStore, -} from "@/attachments/workspace-attachments-store"; -import { - buildReviewDraftScopeKey, - buildReviewDraftKey, - useReviewAttachmentSnapshot, - useResolvedDiffMode, - useSetDiffModeOverride, type ReviewDraftComment, getInlineReviewThreadState, getSplitInlineReviewThreadState, InlineReviewGutterCell, InlineReviewThread, isInlineReviewEditorForTarget, - useInlineReviewController, type InlineReviewActions, } from "@/review"; +import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; +export function resolveDiffLayout( + layout: "unified" | "split", + canUseSplitLayout: boolean, +): "unified" | "split" { + return canUseSplitLayout ? layout : "unified"; +} + function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) { return [styles.fileHeader, pressed && styles.fileHeaderPressed]; } @@ -1110,10 +1103,16 @@ const DiffFileHeader = memo(function DiffFileHeader({ ); } + return ( - {trigger} + + {trigger} + + {file.path} + + ); }); @@ -1330,6 +1329,7 @@ const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp); const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown); const ThemedFolderTree = withUnistyles(FolderTree); const ThemedList = withUnistyles(List); +const ThemedMaximize2 = withUnistyles(Maximize2); const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); const ThemedDownload = withUnistyles(Download); const ThemedUpload = withUnistyles(Upload); @@ -1338,7 +1338,6 @@ const ThemedGitMerge = withUnistyles(GitMerge); const ThemedRefreshCcw = withUnistyles(RefreshCcw); const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); - const DIFF_OPTIONS_WHITESPACE_ICON = ( ); @@ -1349,11 +1348,22 @@ const DIFF_OPTIONS_WRAP_ICON = ( interface DiffLayoutToggleProps { layout: "unified" | "split"; isMobile: boolean; - toggleStyle: PressableStyleFn; + testID?: string; + toggleStyle?: PressableStyleFn; onToggle: () => void; } -function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayoutToggleProps) { +export function DiffLayoutToggle({ + layout, + isMobile, + testID = "changes-toggle-layout", + toggleStyle, + onToggle, +}: DiffLayoutToggleProps) { + const defaultToggleStyle = useMemo( + () => buildToggleButtonStyle(false, styles.expandAllButton), + [], + ); const { t } = useTranslation(); const label = layout === "unified" @@ -1365,9 +1375,9 @@ function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayou {layout === "unified" ? ( @@ -1386,6 +1396,98 @@ function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayou ); } +interface ChangesTabToggleProps { + isMobile: boolean; + selected: boolean; + onPress: () => void; +} + +interface DiffModeMenuProps { + diffMode: "uncommitted" | "base"; + committedDescription?: string; + testIDPrefix?: string; + onSelectUncommitted: () => void; + onSelectBase: () => void; +} + +export function DiffModeMenu({ + diffMode, + committedDescription, + testIDPrefix = "changes-diff", + onSelectUncommitted, + onSelectBase, +}: DiffModeMenuProps) { + const { t } = useTranslation(); + const triggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []); + const uncommittedLabel = t("workspace.git.diff.uncommitted"); + const committedLabel = t("workspace.git.diff.committed"); + return ( + + + + {diffMode === "uncommitted" ? uncommittedLabel : committedLabel} + + + + + + {uncommittedLabel} + + + + {committedLabel} + + + + ); +} + +function ChangesTabToggle({ isMobile, selected, onPress }: ChangesTabToggleProps) { + const { t } = useTranslation(); + const buttonStyle = useMemo( + () => buildToggleButtonStyle(selected, styles.expandAllButton), + [selected], + ); + const label = t( + selected ? "workspace.git.diff.closeChangesTab" : "workspace.git.diff.openChangesTab", + ); + if (isMobile) { + return null; + } + return ( + + + + + + + + {label} + + + ); +} + interface DiffViewModeToggleProps { viewMode: "flat" | "tree"; isMobile: boolean; @@ -1434,16 +1536,19 @@ function DiffViewModeToggle({ interface DiffFilesToolbarProps { allFileDiffsExpanded: boolean; isMobile: boolean; - expandAllToggleStyle: PressableStyleFn; + testID?: string; + expandAllToggleStyle?: PressableStyleFn; onToggleExpandAll: () => void; } -function DiffFilesToolbar({ +export function DiffFilesToolbar({ allFileDiffsExpanded, isMobile, + testID, expandAllToggleStyle, onToggleExpandAll, }: DiffFilesToolbarProps) { + const defaultToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); const { t } = useTranslation(); const expandAllLabel = allFileDiffsExpanded ? t("workspace.git.diff.collapseAll") @@ -1455,7 +1560,8 @@ function DiffFilesToolbar({ {allFileDiffsExpanded ? ( @@ -1480,31 +1586,34 @@ function DiffFilesToolbar({ } interface DiffOptionsMenuProps { - brand: string; + brand?: string; hideWhitespace: boolean; isMobile: boolean; - isRefreshing: boolean; - overflowToggleStyle: PressableStyleFn; - refreshSupported: boolean; + isRefreshing?: boolean; + overflowToggleStyle?: PressableStyleFn; + refreshSupported?: boolean; + testIDPrefix?: string; wrapLines: boolean; - onRefresh: () => void; + onRefresh?: () => void; onToggleHideWhitespace: () => void; onToggleWrapLines: () => void; } -function DiffOptionsMenu({ +export function DiffOptionsMenu({ brand, hideWhitespace, isMobile, - isRefreshing, + isRefreshing = false, overflowToggleStyle, - refreshSupported, + refreshSupported = false, + testIDPrefix = "changes", wrapLines, onRefresh, onToggleHideWhitespace, onToggleWrapLines, }: DiffOptionsMenuProps) { const { t } = useTranslation(); + const defaultToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); const whitespaceLabel = hideWhitespace ? t("workspace.git.diff.showWhitespace") : t("workspace.git.diff.hideWhitespace"); @@ -1512,6 +1621,12 @@ function DiffOptionsMenu({ ? t("workspace.git.diff.scrollLongLines") : t("workspace.git.diff.wrapLongLines"); const optionsLabel = t("workspace.git.diff.options"); + let refreshLabel = t("workspace.git.diff.refresh"); + if (isRefreshing) { + refreshLabel = t("workspace.git.diff.refreshing"); + } else if (brand) { + refreshLabel = t("workspace.git.diff.refreshState", { brand }); + } const refreshIcon = useMemo( () => isRefreshing ? ( @@ -1529,8 +1644,8 @@ function DiffOptionsMenu({ {optionsLabel} - + {whitespaceLabel} @@ -1554,25 +1669,21 @@ function DiffOptionsMenu({ {wrapLinesLabel} - {refreshSupported ? ( + {refreshSupported && onRefresh ? ( <> - {isRefreshing - ? t("workspace.git.diff.refreshing") - : t("workspace.git.diff.refreshState", { - brand, - })} + {refreshLabel} ) : null} @@ -1587,22 +1698,38 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); type DiffFlatItemLayoutGetter = NonNullable["getItemLayout"]>; const EMPTY_PATH_LIST: string[] = []; -function getUnifiedDiffLineCount(file: ParsedDiffFile): number { - let lineCount = 0; - for (const hunk of file.hunks) { - lineCount += hunk.lines.length; - } - return lineCount; +interface DiffFileMetrics { + contentLength: number; + splitLineCount?: number; + unifiedLineCount: number; } -function getDiffContentLength(file: ParsedDiffFile): number { +const diffFileMetricsCache = new WeakMap(); + +function getDiffFileMetrics(file: ParsedDiffFile): DiffFileMetrics { + const cached = diffFileMetricsCache.get(file); + if (cached) { + return cached; + } let contentLength = 0; + let unifiedLineCount = 0; for (const hunk of file.hunks) { + unifiedLineCount += hunk.lines.length; for (const line of hunk.lines) { contentLength += line.content.length; } } - return contentLength; + const metrics = { contentLength, unifiedLineCount }; + diffFileMetricsCache.set(file, metrics); + return metrics; +} + +function getSplitDiffLineCount(file: ParsedDiffFile): number { + const metrics = getDiffFileMetrics(file); + if (metrics.splitLineCount === undefined) { + metrics.splitLineCount = buildSplitDiffRows(file).length; + } + return metrics.splitLineCount; } function computeEmptyMessage( @@ -1710,6 +1837,7 @@ interface SharedDiffViewProps { expandedPaths: string[]; collapsedFolders: string[]; reviewActions?: InlineReviewActions; + onFilePress?: (path: string) => void; workspaceFileDragScope?: { serverId: string; workspaceId: string }; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; @@ -1718,6 +1846,14 @@ interface SharedDiffViewProps { onExpandedPathsChange: (paths: string[]) => void; onCollapsedFoldersChange: (paths: string[]) => void; } + | { + kind: "working_tab"; + expandedPaths: string[] | null; + reviewActions: InlineReviewActions; + focusPath?: string; + focusRequestId?: number; + onExpandedPathsChange: (paths: string[]) => void; + } | { kind: "commit"; }; @@ -1736,17 +1872,25 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi }; }, [codeFontSize, diffBodyLineHeight, monoFontFamily]); const viewMode = mode.kind === "working_tree" ? mode.viewMode : "flat"; - const expandedPathsArray = useMemo( - () => (mode.kind === "working_tree" ? mode.expandedPaths : files.map((file) => file.path)), - [files, mode], - ); + const expandedPathsArray = useMemo(() => { + if (mode.kind === "working_tree") { + return mode.expandedPaths; + } + if (mode.kind === "working_tab" && mode.expandedPaths !== null) { + return mode.expandedPaths; + } + return files.map((file) => file.path); + }, [files, mode]); const expandedPaths = useMemo(() => new Set(expandedPathsArray), [expandedPathsArray]); const collapsedFoldersArray = mode.kind === "working_tree" ? mode.collapsedFolders : EMPTY_PATH_LIST; const collapsedFolders = useMemo(() => new Set(collapsedFoldersArray), [collapsedFoldersArray]); - const stickyHeaders = mode.kind === "working_tree"; - const interactive = mode.kind === "working_tree"; - const reviewActions = mode.kind === "working_tree" ? mode.reviewActions : undefined; + const stickyHeaders = mode.kind !== "commit"; + const interactive = mode.kind !== "commit"; + const reviewActions = mode.kind === "commit" ? undefined : mode.reviewActions; + const onFilePress = mode.kind === "working_tree" ? mode.onFilePress : undefined; + const focusPath = mode.kind === "working_tab" ? mode.focusPath : undefined; + const focusRequestId = mode.kind === "working_tab" ? mode.focusRequestId : undefined; const onOpenFile = mode.kind === "working_tree" ? mode.onOpenFile : undefined; const onAddToChat = mode.kind === "working_tree" ? mode.onAddToChat : undefined; const workspaceFileDragScope = @@ -1761,6 +1905,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [allFolderPathSet, collapsedFolders], ); const diffListRef = useRef>(null); + const consumedFocusRequestRef = useRef(null); + const pendingFocusRequestRef = useRef(null); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); @@ -1768,6 +1914,24 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi const folderRowHeightRef = useRef(0); const defaultHeaderHeightRef = useRef(44); const [heightVersion, setHeightVersion] = useState(0); + const heightVersionFrameRef = useRef(null); + const scheduleHeightVersionUpdate = useCallback(() => { + if (heightVersionFrameRef.current !== null) { + return; + } + heightVersionFrameRef.current = requestAnimationFrame(() => { + heightVersionFrameRef.current = null; + setHeightVersion((version) => version + 1); + }); + }, []); + useEffect( + () => () => { + if (heightVersionFrameRef.current !== null) { + cancelAnimationFrame(heightVersionFrameRef.current); + } + }, + [], + ); const diffBodyChromeHeight = BORDER_WIDTH[1] * 2; const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight; @@ -1791,6 +1955,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi return `${layout}:${wrapLines ? "wrap" : "scroll"}:${typographyKey}:${file.path}:${file.status}`; } + const metrics = getDiffFileMetrics(file); return [ layout, wrapLines ? "wrap" : "scroll", @@ -1800,8 +1965,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi file.additions, file.deletions, file.hunks.length, - getUnifiedDiffLineCount(file), - getDiffContentLength(file), + metrics.unifiedLineCount, + metrics.contentLength, ].join(":"); }, [layout, typographyKey, wrapLines], @@ -1814,7 +1979,9 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi } const lineCount = - layout === "split" ? buildSplitDiffRows(file).length : getUnifiedDiffLineCount(file); + layout === "split" + ? getSplitDiffLineCount(file) + : getDiffFileMetrics(file).unifiedLineCount; return diffBodyChromeHeight + lineCount * diffBodyLineHeight; }, [diffBodyChromeHeight, diffBodyLineHeight, layout, statusBodyHeightEstimate], @@ -1834,33 +2001,39 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [estimateBodyHeight, getBodyHeightKey], ); - const handleFolderRowHeightChange = useCallback((height: number) => { - if (!Number.isFinite(height) || height <= 0) { - return; - } - const previousHeight = folderRowHeightRef.current; - if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) { - return; - } - folderRowHeightRef.current = height; - setHeightVersion((version) => version + 1); - }, []); + const handleFolderRowHeightChange = useCallback( + (height: number) => { + if (!Number.isFinite(height) || height <= 0) { + return; + } + const previousHeight = folderRowHeightRef.current; + if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) { + return; + } + folderRowHeightRef.current = height; + scheduleHeightVersionUpdate(); + }, + [scheduleHeightVersionUpdate], + ); - const handleHeaderHeightChange = useCallback((path: string, height: number) => { - if (!Number.isFinite(height) || height <= 0) { - return; - } - const previousHeight = headerHeightByPathRef.current[path]; - if ( - previousHeight !== undefined && - Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON - ) { - return; - } - headerHeightByPathRef.current[path] = height; - defaultHeaderHeightRef.current = height; - setHeightVersion((version) => version + 1); - }, []); + const handleHeaderHeightChange = useCallback( + (path: string, height: number) => { + if (!Number.isFinite(height) || height <= 0) { + return; + } + const previousHeight = headerHeightByPathRef.current[path]; + if ( + previousHeight !== undefined && + Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON + ) { + return; + } + headerHeightByPathRef.current[path] = height; + defaultHeaderHeightRef.current = height; + scheduleHeightVersionUpdate(); + }, + [scheduleHeightVersionUpdate], + ); const handleBodyHeightChange = useCallback( (file: ParsedDiffFile, height: number) => { @@ -1876,9 +2049,9 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi return; } bodyHeightByKeyRef.current[heightKey] = height; - setHeightVersion((version) => version + 1); + scheduleHeightVersionUpdate(); }, - [getBodyHeightKey], + [getBodyHeightKey, scheduleHeightVersionUpdate], ); const handleDiffListScroll = useCallback((event: NativeSyntheticEvent) => { @@ -1910,9 +2083,43 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [computeItemOffset], ); + useEffect(() => { + if (!focusPath) { + return; + } + const focusRequestKey = `${focusRequestId ?? "initial"}:${focusPath}`; + if ( + consumedFocusRequestRef.current === focusRequestKey || + pendingFocusRequestRef.current === focusRequestKey + ) { + return; + } + const hasTarget = flatItems.some( + (item) => item.type === "header" && item.file.path === focusPath, + ); + if (!hasTarget) { + return; + } + pendingFocusRequestRef.current = focusRequestKey; + const frame = requestAnimationFrame(() => { + diffListRef.current?.scrollToOffset({ + offset: computeHeaderOffset(focusPath), + animated: false, + }); + consumedFocusRequestRef.current = focusRequestKey; + pendingFocusRequestRef.current = null; + }); + return () => { + cancelAnimationFrame(frame); + if (pendingFocusRequestRef.current === focusRequestKey) { + pendingFocusRequestRef.current = null; + } + }; + }, [computeHeaderOffset, flatItems, focusPath, focusRequestId]); + const handleToggleExpanded = useCallback( (path: string) => { - if (mode.kind !== "working_tree") { + if (mode.kind === "commit") { return; } const isCurrentlyExpanded = expandedPaths.has(path); @@ -2004,7 +2211,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi depth={item.depth} showDir={viewMode === "flat"} interactive={interactive} - onToggle={interactive ? handleToggleExpanded : undefined} + onToggle={interactive ? (onFilePress ?? handleToggleExpanded) : undefined} onOpenFile={onOpenFile} onAddToChat={onAddToChat} onCopyPath={onCopyPath} @@ -2041,6 +2248,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi viewMode, wrapLines, interactive, + onFilePress, onOpenFile, onAddToChat, onCopyPath, @@ -2113,53 +2321,6 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ); } -interface DeriveStatusStateInputs { - status: ReturnType["status"]; - isStatusLoading: boolean; - isStatusError: boolean; - statusError: unknown; -} - -interface DerivedStatusState { - gitStatus: NonNullable["status"]> | null; - isGit: boolean; - notGit: boolean; - statusErrorMessage: string | null; - baseRef: string | undefined; - hasUncommittedChanges: boolean; - actionsDisabled: boolean; - currentBranchName: string | null; -} - -function deriveStatusState({ - status, - isStatusLoading, - isStatusError, - statusError, -}: DeriveStatusStateInputs): DerivedStatusState { - const gitStatus = status && status.isGit ? status : null; - const isGit = Boolean(gitStatus); - const notGit = status !== null && !status.isGit && !status.error; - const statusErrorMessage = - status?.error?.message ?? - (isStatusError && statusError instanceof Error ? statusError.message : null); - const baseRef = gitStatus?.baseRef ?? undefined; - const hasUncommittedChanges = Boolean(gitStatus?.isDirty); - const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; - const currentBranchName = - gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null; - return { - gitStatus, - isGit, - notGit, - statusErrorMessage, - baseRef, - hasUncommittedChanges, - actionsDisabled, - currentBranchName, - }; -} - function computeBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): string { if (!baseRef) return fallbackLabel; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); @@ -2221,11 +2382,14 @@ function parseForgeHost(url: string | null | undefined): string | null { } function buildForgeSetupMessage(input: { - action: Exclude; + action: ForgeSetupAction; forge: Forge; host: string | null; t: TFunction; -}): string { +}): string | null { + if (!input.action) { + return null; + } const { brandLabel, signInCli } = getForgePresentation(input.forge); // A forge with no known CLI (an unknown/third-party forge rendered neutrally) // has no install/sign-in command to interpolate — show neutral guidance @@ -2271,8 +2435,180 @@ function buildToggleButtonStyle( ]; } -function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean { - return input.paneEnabled && input.isGit; +function useChangesTreeState({ + workspaceId, + cwd, + files, + viewMode, + changesTabOpen, + onViewModeChange, +}: { + workspaceId?: string | null; + cwd: string; + files: ParsedDiffFile[]; + viewMode: "flat" | "tree"; + changesTabOpen: boolean; + onViewModeChange: (viewMode: "flat" | "tree") => void; +}) { + const workspaceStateKey = useMemo( + () => + buildWorkspaceExplorerStateKey({ + workspaceId, + workspaceRoot: cwd.trim(), + }), + [cwd, workspaceId], + ); + const expandedPaths = usePanelStore((state) => + workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined, + ); + const collapsedFolders = usePanelStore((state) => + workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined, + ); + const setExpandedPaths = usePanelStore((state) => state.setDiffExpandedPathsForWorkspace); + const setCollapsedFolders = usePanelStore((state) => state.setDiffCollapsedFoldersForWorkspace); + const stableExpandedPaths = expandedPaths ?? EMPTY_PATH_LIST; + const stableCollapsedFolders = collapsedFolders ?? EMPTY_PATH_LIST; + const folderPaths = useMemo( + () => collectDirPaths(compressSingleChildChains(buildDiffTree(files))), + [files], + ); + const folderPathSet = useMemo(() => new Set(folderPaths), [folderPaths]); + const allExpanded = useMemo(() => { + if (files.length === 0 || changesTabOpen) { + return false; + } + const everyFileExpanded = files.every((file) => stableExpandedPaths.includes(file.path)); + const everyFolderExpanded = + viewMode !== "tree" || + stableCollapsedFolders.every((folderPath) => !folderPathSet.has(folderPath)); + return everyFileExpanded && everyFolderExpanded; + }, [changesTabOpen, files, folderPathSet, stableCollapsedFolders, stableExpandedPaths, viewMode]); + const toggleViewMode = useCallback(() => { + const nextViewMode = viewMode === "flat" ? "tree" : "flat"; + if (nextViewMode === "tree" && workspaceStateKey) { + setCollapsedFolders(workspaceStateKey, []); + } + onViewModeChange(nextViewMode); + }, [onViewModeChange, setCollapsedFolders, viewMode, workspaceStateKey]); + const toggleExpandAll = useCallback(() => { + if (!workspaceStateKey) { + return; + } + if (allExpanded) { + setExpandedPaths(workspaceStateKey, []); + if (viewMode === "tree") { + setCollapsedFolders(workspaceStateKey, folderPaths); + } + return; + } + setExpandedPaths( + workspaceStateKey, + files.map((file) => file.path), + ); + if (viewMode === "tree") { + setCollapsedFolders(workspaceStateKey, []); + } + }, [ + allExpanded, + files, + folderPaths, + setCollapsedFolders, + setExpandedPaths, + viewMode, + workspaceStateKey, + ]); + const updateExpandedPaths = useCallback( + (paths: string[]) => { + if (workspaceStateKey) { + setExpandedPaths(workspaceStateKey, paths); + } + }, + [setExpandedPaths, workspaceStateKey], + ); + const updateCollapsedFolders = useCallback( + (paths: string[]) => { + if (workspaceStateKey) { + setCollapsedFolders(workspaceStateKey, paths); + } + }, + [setCollapsedFolders, workspaceStateKey], + ); + + return { + expandedPaths: changesTabOpen ? EMPTY_PATH_LIST : stableExpandedPaths, + collapsedFolders: stableCollapsedFolders, + allExpanded, + toggleViewMode, + toggleExpandAll, + updateExpandedPaths, + updateCollapsedFolders, + }; +} + +function useDiffTabNavigation({ + serverId, + workspaceId, + cwd, + isMobile, +}: { + serverId: string; + workspaceId?: string | null; + cwd: string; + isMobile: boolean; +}) { + const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); + const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const persistenceKey = useMemo( + () => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }), + [cwd, serverId, workspaceId], + ); + const changesTabId = useWorkspaceLayoutStore((state) => { + if (!persistenceKey) { + return null; + } + const layout = state.layoutByWorkspace[persistenceKey]; + return ( + layout && collectAllTabs(layout.root).find((tab) => tab.target.kind === "working_diff")?.tabId + ); + }); + const changesTabOpen = !isMobile && Boolean(changesTabId); + const openChanges = useCallback( + (path?: string) => { + if (!persistenceKey || isMobile) { + return; + } + openWorkspaceTabFocused(persistenceKey, { + kind: "working_diff", + ...(path ? { focusPath: path, focusRequestId: Date.now() } : {}), + }); + }, + [isMobile, openWorkspaceTabFocused, persistenceKey], + ); + const toggleChanges = useCallback(() => { + if (!persistenceKey || isMobile) { + return; + } + if (changesTabId) { + closeWorkspaceTab(persistenceKey, changesTabId); + return; + } + openChanges(); + }, [changesTabId, closeWorkspaceTab, isMobile, openChanges, persistenceKey]); + const openCommit = useCallback( + (sha: string) => { + if (persistenceKey) { + openWorkspaceTabFocused(persistenceKey, { kind: "commit_diff", sha }); + } + }, + [openWorkspaceTabFocused, persistenceKey], + ); + return { + changesTabOpen, + openChanges, + toggleChanges, + openCommit, + onChangesFilePress: changesTabOpen ? openChanges : undefined, + }; } export function GitDiffPane({ @@ -2291,7 +2627,7 @@ export function GitDiffPane({ useChangesPreferences(); const wrapLines = changesPreferences.wrapLines; const viewMode = changesPreferences.viewMode; - const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified"; + const effectiveLayout = resolveDiffLayout(changesPreferences.layout, canUseSplitLayout); const handleToggleWrapLines = useCallback(() => { void updateChangesPreferences({ wrapLines: !wrapLines }); @@ -2308,8 +2644,6 @@ export function GitDiffPane({ }, [changesPreferences.layout, updateChangesPreferences]); const codeFontSize = appSettings.codeFontSize; - const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []); - const layoutToggleStyle = useMemo( () => buildToggleButtonStyle(false, styles.expandAllButton), [], @@ -2325,23 +2659,12 @@ export function GitDiffPane({ const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []); const toast = useToast(); - const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); - const commitDiffPersistenceKey = useMemo( - () => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }), - [cwd, serverId, workspaceId], - ); - const handleCommitPress = useCallback( - (sha: string) => { - if (!commitDiffPersistenceKey) { - return; - } - openWorkspaceTabFocused(commitDiffPersistenceKey, { - kind: "commit_diff", - sha, - }); - }, - [commitDiffPersistenceKey, openWorkspaceTabFocused], - ); + const { + changesTabOpen, + toggleChanges: handleToggleChangesTab, + openCommit: handleCommitPress, + onChangesFilePress, + } = useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile }); const refreshSupported = useSessionStore( (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, ); @@ -2361,106 +2684,34 @@ export function GitDiffPane({ const { status, - isLoading: isStatusLoading, - isError: isStatusError, - error: statusError, - } = useCheckoutStatusQuery({ serverId, cwd }); - const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError }); - const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges, currentBranchName } = - statusState; - - const reviewDraftScopeKey = useMemo( - () => - buildReviewDraftScopeKey({ - serverId, - workspaceId, - cwd, - baseRef, - ignoreWhitespace: changesPreferences.hideWhitespace, - }), - [baseRef, changesPreferences.hideWhitespace, cwd, serverId, workspaceId], - ); - const diffMode = useResolvedDiffMode({ - scopeKey: reviewDraftScopeKey, - hasUncommittedChanges, - }); - const setDiffModeOverride = useSetDiffModeOverride(); - - const { + isStatusLoading, + isGit, + notGit, + statusErrorMessage, + baseRef, + currentBranchName, + diffMode, + selectUncommitted: handleSelectUncommitted, + selectBase: handleSelectBase, files, - payloadError: diffPayloadError, - isLoading: isDiffLoading, - } = useCheckoutDiffQuery({ + diffPayloadError, + isDiffLoading, + reviewActions, + reviewAttachment, + } = useWorkingDiff({ serverId, + workspaceId: workspaceId ?? undefined, cwd, - mode: diffMode, - baseRef, ignoreWhitespace: changesPreferences.hideWhitespace, - enabled: shouldEnableCheckoutDiff({ paneEnabled: enabled !== false, isGit }), + enabled: enabled !== false, }); - const reviewDraftKey = useMemo( - () => - buildReviewDraftKey({ - serverId, - workspaceId, - cwd, - mode: diffMode, - baseRef, - ignoreWhitespace: changesPreferences.hideWhitespace, - }), - [baseRef, changesPreferences.hideWhitespace, cwd, diffMode, serverId, workspaceId], - ); - - const handleSelectUncommitted = useCallback(() => { - setDiffModeOverride({ - scopeKey: reviewDraftScopeKey, - override: { serverId, cwd, mode: "uncommitted", isDirtyAtSelection: hasUncommittedChanges }, - }); - }, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]); - - const handleSelectBase = useCallback(() => { - setDiffModeOverride({ - scopeKey: reviewDraftScopeKey, - override: { serverId, cwd, mode: "base", isDirtyAtSelection: hasUncommittedChanges }, - }); - }, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]); - - const reviewActions = useInlineReviewController({ - reviewDraftKey, - }); - const reviewAttachment = useReviewAttachmentSnapshot({ - key: reviewDraftKey, - diffFiles: files, + usePublishWorkingDiffAttachment({ + serverId, + workspaceId: workspaceId ?? undefined, cwd, - mode: diffMode, - baseRef, + attachment: reviewAttachment, + enabled: !changesTabOpen, }); - const workspaceAttachmentScopeKey = useMemo( - () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }), - [cwd, serverId, workspaceId], - ); - const setWorkspaceAttachments = useWorkspaceAttachmentsStore( - (state) => state.setWorkspaceAttachments, - ); - const clearWorkspaceAttachments = useWorkspaceAttachmentsStore( - (state) => state.clearWorkspaceAttachments, - ); - - useEffect(() => { - setWorkspaceAttachments({ - scopeKey: workspaceAttachmentScopeKey, - attachments: reviewAttachment ? [reviewAttachment] : [], - }); - - return () => { - clearWorkspaceAttachments({ scopeKey: workspaceAttachmentScopeKey }); - }; - }, [ - clearWorkspaceAttachments, - reviewAttachment, - setWorkspaceAttachments, - workspaceAttachmentScopeKey, - ]); const { githubFeaturesEnabled, forge, @@ -2481,42 +2732,28 @@ export function GitDiffPane({ }); const forgeSetupMessage = useMemo( () => - forgeSetupAction - ? buildForgeSetupMessage({ - action: forgeSetupAction, - forge, - host: parseForgeHost(status?.remoteUrl), - t, - }) - : null, - [forgeSetupAction, forge, status?.remoteUrl, t], - ); - const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]); - const workspaceStateKey = useMemo( - () => - buildWorkspaceExplorerStateKey({ - workspaceId, - workspaceRoot: normalizedWorkspaceRoot, + buildForgeSetupMessage({ + action: forgeSetupAction, + forge, + host: parseForgeHost(status?.remoteUrl), + t, }), - [normalizedWorkspaceRoot, workspaceId], - ); - const expandedPathsArray = usePanelStore((state) => - workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined, - ); - const setDiffExpandedPathsForWorkspace = usePanelStore( - (state) => state.setDiffExpandedPathsForWorkspace, - ); - const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]); - // The Changes view groups files into a directory tree on every form factor, - // consistent with the Files explorer (which is also a tree on mobile). - const collapsedFoldersArray = usePanelStore((state) => - workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined, + [forgeSetupAction, forge, status?.remoteUrl, t], ); - const setDiffCollapsedFoldersForWorkspace = usePanelStore( - (state) => state.setDiffCollapsedFoldersForWorkspace, + const handleViewModeChange = useCallback( + (nextViewMode: "flat" | "tree") => { + void updateChangesPreferences({ viewMode: nextViewMode }); + }, + [updateChangesPreferences], ); - const stableExpandedPathsArray = expandedPathsArray ?? EMPTY_PATH_LIST; - const stableCollapsedFoldersArray = collapsedFoldersArray ?? EMPTY_PATH_LIST; + const changesTree = useChangesTreeState({ + workspaceId, + cwd, + files, + viewMode, + changesTabOpen, + onViewModeChange: handleViewModeChange, + }); const sharedDisplayPreferences = useMemo( () => ({ layout: effectiveLayout, @@ -2526,49 +2763,6 @@ export function GitDiffPane({ }), [appSettings.monoFontFamily, codeFontSize, effectiveLayout, wrapLines], ); - const handleToggleViewMode = useCallback(() => { - const nextViewMode = viewMode === "flat" ? "tree" : "flat"; - if (nextViewMode === "tree" && workspaceStateKey) { - setDiffCollapsedFoldersForWorkspace(workspaceStateKey, []); - } - void updateChangesPreferences({ viewMode: nextViewMode }); - }, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]); - const allFileDiffsExpanded = useMemo(() => { - if (files.length === 0) return false; - return files.every((file) => expandedPaths.has(file.path)); - }, [expandedPaths, files]); - - const handleToggleExpandAll = useCallback(() => { - if (!workspaceStateKey) { - return; - } - if (allFileDiffsExpanded) { - setDiffExpandedPathsForWorkspace(workspaceStateKey, []); - } else { - setDiffExpandedPathsForWorkspace( - workspaceStateKey, - files.map((file) => file.path), - ); - } - }, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]); - const handleExpandedPathsChange = useCallback( - (nextPaths: string[]) => { - if (!workspaceStateKey) { - return; - } - setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths); - }, - [setDiffExpandedPathsForWorkspace, workspaceStateKey], - ); - const handleCollapsedFoldersChange = useCallback( - (nextPaths: string[]) => { - if (!workspaceStateKey) { - return; - } - setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextPaths); - }, - [setDiffCollapsedFoldersForWorkspace, workspaceStateKey], - ); const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: cwd }); const handleCopyPath = useCallback( (path: string) => { @@ -2588,30 +2782,32 @@ export function GitDiffPane({ () => ({ kind: "working_tree" as const, viewMode, - expandedPaths: stableExpandedPathsArray, - collapsedFolders: stableCollapsedFoldersArray, + expandedPaths: changesTree.expandedPaths, + collapsedFolders: changesTree.collapsedFolders, reviewActions, + onFilePress: onChangesFilePress, workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined, onOpenFile, onAddToChat, onCopyPath: handleCopyPath, onDownload: handleDownloadPath, - onExpandedPathsChange: handleExpandedPathsChange, - onCollapsedFoldersChange: handleCollapsedFoldersChange, + onExpandedPathsChange: changesTree.updateExpandedPaths, + onCollapsedFoldersChange: changesTree.updateCollapsedFolders, }), [ viewMode, - stableExpandedPathsArray, - stableCollapsedFoldersArray, + changesTree.expandedPaths, + changesTree.collapsedFolders, reviewActions, + onChangesFilePress, serverId, workspaceId, onOpenFile, onAddToChat, handleCopyPath, handleDownloadPath, - handleExpandedPathsChange, - handleCollapsedFoldersChange, + changesTree.updateExpandedPaths, + changesTree.updateCollapsedFolders, ], ); @@ -2643,9 +2839,6 @@ export function GitDiffPane({ () => computeCommittedDiffDescription(branchLabel, baseRefLabel), [baseRefLabel, branchLabel], ); - const uncommittedLabel = t("workspace.git.diff.uncommitted"); - const committedLabel = t("workspace.git.diff.committed"); - const emptyMessage = computeEmptyMessage( changesPreferences.hideWhitespace, diffMode, @@ -2696,39 +2889,19 @@ export function GitDiffPane({ {isGit ? ( - - - - {diffMode === "uncommitted" ? uncommittedLabel : committedLabel} - - - - - - {uncommittedLabel} - - - - {committedLabel} - - - + - {canUseSplitLayout ? ( + + {canUseSplitLayout && !changesTabOpen ? ( ) : null} - {files.length > 0 ? ( + {files.length > 0 && !changesTabOpen ? ( ) : null} ; @@ -44,6 +45,7 @@ export function useCheckoutDiffQuery({ baseRef, ignoreWhitespace, enabled = true, + queryScope, }: UseCheckoutDiffQueryOptions) { const isConnected = useHostRuntimeIsConnected(serverId); const normalizedCompare = useMemo( @@ -53,10 +55,17 @@ export function useCheckoutDiffQuery({ const compareMode = normalizedCompare.mode; const compareBaseRef = normalizedCompare.baseRef; const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace; - const queryKey = useMemo( - () => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace), - [serverId, cwd, mode, baseRef, compareIgnoreWhitespace], - ); + const queryKey = useMemo(() => { + const comparisonKey = checkoutDiffQueryKey( + serverId, + cwd, + compareMode, + compareBaseRef, + compareIgnoreWhitespace, + ); + const normalizedScope = queryScope?.trim(); + return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey; + }, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]); const routeEnabled = Boolean(enabled && isConnected && cwd); diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts new file mode 100644 index 00000000000..55e8709bd7a --- /dev/null +++ b/packages/app/src/git/use-working-diff.ts @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { + buildWorkspaceAttachmentScopeKey, + useWorkspaceAttachmentsStore, +} from "@/attachments/workspace-attachments-store"; +import { + buildReviewDraftKey, + buildReviewDraftScopeKey, + useInlineReviewController, + useResolvedDiffMode, + useReviewAttachmentSnapshot, + useSetDiffModeOverride, +} from "@/review"; +import { useCheckoutDiffQuery } from "@/git/use-diff-query"; +import { useCheckoutStatusQuery } from "@/git/use-status-query"; + +interface UseWorkingDiffOptions { + serverId: string; + workspaceId?: string; + cwd: string; + ignoreWhitespace: boolean; + enabled: boolean; + queryScope?: string; +} + +export function useWorkingDiff({ + serverId, + workspaceId, + cwd, + ignoreWhitespace, + enabled, + queryScope, +}: UseWorkingDiffOptions) { + const { + status, + isLoading: isStatusLoading, + isError: isStatusError, + error: statusError, + } = useCheckoutStatusQuery({ serverId, cwd }); + const gitStatus = status && status.isGit ? status : null; + const isGit = Boolean(gitStatus); + const notGit = status !== null && !status.isGit && !status.error; + const statusErrorMessage = + status?.error?.message ?? + (isStatusError && statusError instanceof Error ? statusError.message : null); + const baseRef = gitStatus?.baseRef ?? undefined; + const hasUncommittedChanges = Boolean(gitStatus?.isDirty); + const currentBranchName = + gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null; + + const reviewDraftScopeKey = useMemo( + () => + buildReviewDraftScopeKey({ + serverId, + workspaceId, + cwd, + baseRef, + ignoreWhitespace, + }), + [baseRef, cwd, ignoreWhitespace, serverId, workspaceId], + ); + const diffMode = useResolvedDiffMode({ + scopeKey: reviewDraftScopeKey, + hasUncommittedChanges, + }); + const setDiffModeOverride = useSetDiffModeOverride(); + const selectDiffMode = useCallback( + (mode: "uncommitted" | "base") => { + setDiffModeOverride({ + scopeKey: reviewDraftScopeKey, + override: { serverId, cwd, mode, isDirtyAtSelection: hasUncommittedChanges }, + }); + }, + [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride], + ); + const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]); + const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]); + + const { + files, + payloadError: diffPayloadError, + isLoading: isDiffLoading, + } = useCheckoutDiffQuery({ + serverId, + cwd, + mode: diffMode, + baseRef, + ignoreWhitespace, + enabled: enabled && isGit, + queryScope, + }); + const reviewDraftKey = useMemo( + () => + buildReviewDraftKey({ + serverId, + workspaceId, + cwd, + mode: diffMode, + baseRef, + ignoreWhitespace, + }), + [baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId], + ); + const reviewActions = useInlineReviewController({ reviewDraftKey }); + const reviewAttachment = useReviewAttachmentSnapshot({ + key: reviewDraftKey, + diffFiles: files, + cwd, + mode: diffMode, + baseRef, + }); + + return { + status, + isStatusLoading, + isGit, + notGit, + statusErrorMessage, + baseRef, + currentBranchName, + diffMode, + selectUncommitted, + selectBase, + files, + diffPayloadError, + isDiffLoading, + reviewActions, + reviewAttachment, + }; +} + +export function usePublishWorkingDiffAttachment({ + serverId, + workspaceId, + cwd, + attachment, + enabled, +}: { + serverId: string; + workspaceId?: string; + cwd: string; + attachment: ReturnType["reviewAttachment"]; + enabled: boolean; +}) { + const scopeKey = useMemo( + () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }), + [cwd, serverId, workspaceId], + ); + const setWorkspaceAttachments = useWorkspaceAttachmentsStore( + (state) => state.setWorkspaceAttachments, + ); + const clearWorkspaceAttachments = useWorkspaceAttachmentsStore( + (state) => state.clearWorkspaceAttachments, + ); + + useEffect(() => { + if (!enabled) { + return; + } + const attachments = attachment ? [attachment] : []; + setWorkspaceAttachments({ scopeKey, attachments }); + return () => { + const current = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]; + if (current === attachments) { + clearWorkspaceAttachments({ scopeKey }); + } + }; + }, [attachment, clearWorkspaceAttachments, enabled, scopeKey, setWorkspaceAttachments]); +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 87dd6f583e9..6f6518733fc 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -758,6 +758,8 @@ export const ar: TranslationResources = { }, }, diff: { + openChangesTab: "فتح علامة تبويب التغييرات", + closeChangesTab: "إغلاق علامة تبويب التغييرات", binaryFile: "ملف ثنائي", tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه", unified: "الفرق الموحدة", @@ -1494,6 +1496,8 @@ export const ar: TranslationResources = { changesLabel: "التغييرات", changesSubtitle: "فروقات شجرة العمل", commitSubtitle: "فروقات الالتزام", + uncommittedSubtitle: "تغييرات غير ملتزم بها", + baseSubtitle: "مقارنة مع {{baseRef}}", directoryMissing: "لم يتم العثور على دليل Workspace.", empty: "لا توجد تغييرات", loadError: "فشل تحميل الفروقات", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index db5921e04a8..bc8bba54617 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -768,6 +768,8 @@ export const en = { }, }, diff: { + openChangesTab: "Open Changes tab", + closeChangesTab: "Close Changes tab", binaryFile: "Binary file", tooLarge: "Diff too large to display", unified: "Unified diff", @@ -1505,6 +1507,8 @@ export const en = { changesLabel: "Changes", changesSubtitle: "Working tree diff", commitSubtitle: "Commit diff", + uncommittedSubtitle: "Uncommitted changes", + baseSubtitle: "Compared with {{baseRef}}", directoryMissing: "Workspace directory not found.", empty: "No changes", loadError: "Failed to load diff", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 4edc7bce967..204273c96c1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -789,6 +789,8 @@ export const es: TranslationResources = { }, }, diff: { + openChangesTab: "Abrir la pestaña Cambios", + closeChangesTab: "Cerrar la pestaña Cambios", binaryFile: "archivo binario", tooLarge: "La diferencia es demasiado grande para mostrarse", unified: "Diferencia unificada", @@ -1537,6 +1539,8 @@ export const es: TranslationResources = { changesLabel: "Cambios", changesSubtitle: "Diferencias del árbol de trabajo", commitSubtitle: "Diferencias del commit", + uncommittedSubtitle: "Cambios sin confirmar", + baseSubtitle: "Comparado con {{baseRef}}", directoryMissing: "No se encontró el directorio de Workspace.", empty: "Sin cambios", loadError: "No se pudieron cargar las diferencias", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 2c7e67bd076..07dd95a2ad4 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -788,6 +788,8 @@ export const fr: TranslationResources = { }, }, diff: { + openChangesTab: "Ouvrir l'onglet Modifications", + closeChangesTab: "Fermer l'onglet Modifications", binaryFile: "Fichier binaire", tooLarge: "Diff trop grand pour être affiché", unified: "Différentiel unifié", @@ -1540,6 +1542,8 @@ export const fr: TranslationResources = { changesLabel: "Modifications", changesSubtitle: "Différences de l'arbre de travail", commitSubtitle: "Différences du commit", + uncommittedSubtitle: "Modifications non validées", + baseSubtitle: "Comparé à {{baseRef}}", directoryMissing: "Répertoire Workspace introuvable.", empty: "Aucune modification", loadError: "Échec du chargement des différences", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 2029d9da0b2..5a27447dc93 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -769,6 +769,8 @@ export const ja: TranslationResources = { }, }, diff: { + openChangesTab: "変更タブを開く", + closeChangesTab: "変更タブを閉じる", binaryFile: "バイナリファイル", tooLarge: "差分が大きすぎて表示できません", unified: "ユニファイド差分", @@ -1510,6 +1512,8 @@ export const ja: TranslationResources = { changesLabel: "変更", changesSubtitle: "作業ツリーの差分", commitSubtitle: "コミット差分", + uncommittedSubtitle: "未コミットの変更", + baseSubtitle: "{{baseRef}} との比較", directoryMissing: "ワークスペースディレクトリが見つかりません。", empty: "変更はありません", loadError: "差分の読み込みに失敗しました", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 61030df178d..2fce56ddd1e 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -780,6 +780,8 @@ export const ptBR: TranslationResources = { }, }, diff: { + openChangesTab: "Abrir a aba Alterações", + closeChangesTab: "Fechar a aba Alterações", binaryFile: "Arquivo binário", tooLarge: "Diff grande demais para exibir", unified: "Diff unificado", @@ -1523,6 +1525,8 @@ export const ptBR: TranslationResources = { changesLabel: "Alterações", changesSubtitle: "Diff da árvore de trabalho", commitSubtitle: "Diff do commit", + uncommittedSubtitle: "Alterações não commitadas", + baseSubtitle: "Comparado com {{baseRef}}", directoryMissing: "Diretório do workspace não encontrado.", empty: "Nenhuma alteração", loadError: "Falha ao carregar diff", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 179ef702549..51e83e28d44 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -780,6 +780,8 @@ export const ru: TranslationResources = { }, }, diff: { + openChangesTab: "Открыть вкладку «Изменения»", + closeChangesTab: "Закрыть вкладку «Изменения»", binaryFile: "Бинарный файл", tooLarge: "Разница слишком велика для отображения", unified: "Единый дифференциал", @@ -1528,6 +1530,8 @@ export const ru: TranslationResources = { changesLabel: "Изменения", changesSubtitle: "Различия рабочего дерева", commitSubtitle: "Различия коммита", + uncommittedSubtitle: "Незафиксированные изменения", + baseSubtitle: "Сравнение с {{baseRef}}", directoryMissing: "Каталог Workspace не найден.", empty: "Нет изменений", loadError: "Не удалось загрузить различия", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index fbb97630fbb..b9ddd3cc76e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -750,6 +750,8 @@ export const zhCN: TranslationResources = { }, }, diff: { + openChangesTab: "打开“更改”标签页", + closeChangesTab: "关闭“更改”标签页", binaryFile: "二进制文件", tooLarge: "Diff 过大,无法显示", unified: "Unified diff", @@ -1475,6 +1477,8 @@ export const zhCN: TranslationResources = { changesLabel: "更改", changesSubtitle: "工作区差异", commitSubtitle: "提交差异", + uncommittedSubtitle: "未提交的更改", + baseSubtitle: "与 {{baseRef}} 比较", directoryMissing: "未找到 workspace 目录。", empty: "没有更改", loadError: "加载差异失败", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index e34c6aa4f0c..2c3dab2d893 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -73,7 +73,7 @@ import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; import { type Agent, useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import type { Theme } from "@/styles/theme"; import { useHideFinishedProviderSubagents, diff --git a/packages/app/src/panels/commit-diff-panel.tsx b/packages/app/src/panels/commit-diff-panel.tsx deleted file mode 100644 index 8cd5ef679e9..00000000000 --- a/packages/app/src/panels/commit-diff-panel.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useCallback, useMemo, type ReactNode } from "react"; -import { Text, View } from "react-native"; -import { useTranslation } from "react-i18next"; -import { GitCommitHorizontal } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import invariant from "tiny-invariant"; -import { SegmentedControl } from "@/components/ui/segmented-control"; -import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; -import { isWeb } from "@/constants/platform"; -import { useChangesPreferences } from "@/hooks/use-changes-preferences"; -import { useAppSettings } from "@/hooks/use-settings"; -import { SharedDiffView } from "@/git/diff-pane"; -import { useCommitDiffFiles } from "@/git/use-diff-files"; -import { usePaneContext } from "@/panels/pane-context"; -import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; -import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; - -const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); - -function CommitDiffPanel() { - const { t } = useTranslation(); - const { serverId, workspaceId, target } = usePaneContext(); - const cwd = useWorkspaceDirectory(serverId, workspaceId); - const { settings } = useAppSettings(); - const { preferences, updatePreferences } = useChangesPreferences(); - const isCompact = useIsCompactFormFactor(); - invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target"); - - const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({ - serverId, - cwd: cwd ?? "", - sha: target.sha, - enabled: Boolean(cwd), - }); - const canUseSplitLayout = isWeb && !isCompact; - const effectiveLayout = canUseSplitLayout ? preferences.layout : "unified"; - const layoutOptions = useMemo( - () => [ - { - value: "unified" as const, - label: t("workspace.git.diff.unified"), - testID: "commit-diff-layout-unified", - }, - { - value: "split" as const, - label: t("workspace.git.diff.split"), - testID: "commit-diff-layout-split", - }, - ], - [t], - ); - const handleLayoutChange = useCallback( - (layout: "unified" | "split") => { - void updatePreferences({ layout }); - }, - [updatePreferences], - ); - const displayPreferences = useMemo( - () => ({ - layout: effectiveLayout, - wrapLines: preferences.wrapLines, - codeFontSize: settings.codeFontSize, - monoFontFamily: settings.monoFontFamily, - }), - [effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily], - ); - const commitMode = useMemo(() => ({ kind: "commit" as const }), []); - - if (!cwd) { - return ( - - {t("panels.diff.directoryMissing")} - - ); - } - - if (capabilityMissing) { - return ( - - {t("panels.diff.capabilityMissing")} - - ); - } - let bodyContent: ReactNode; - if (error) { - bodyContent = ( - - {t("panels.diff.loadError")} - - ); - } else if (isLoading && files.length === 0) { - bodyContent = ( - - {t("workspace.tabs.loading")} - - ); - } else if (files.length === 0) { - bodyContent = ( - - {t("panels.diff.empty")} - - ); - } else { - bodyContent = ( - - ); - } - - return ( - - {canUseSplitLayout ? ( - - - - ) : null} - {bodyContent} - - ); -} - -function useCommitDiffPanelDescriptor( - target: Extract, -): PanelDescriptor { - const { t } = useTranslation(); - return { - label: target.sha.slice(0, 7), - subtitle: t("panels.diff.commitSubtitle"), - tooltip: target.sha, - titleState: "ready", - icon: ThemedGitCommitHorizontal, - statusBucket: null, - }; -} - -export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = { - kind: "commit_diff", - component: CommitDiffPanel, - useDescriptor: useCommitDiffPanelDescriptor, -}; - -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - minHeight: 0, - }, - toolbar: { - height: WORKSPACE_SECONDARY_HEADER_HEIGHT, - flexDirection: "row", - alignItems: "center", - justifyContent: "flex-end", - paddingHorizontal: theme.spacing[3], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - flexShrink: 0, - }, - body: { - flex: 1, - minHeight: 0, - }, - centerState: { - flex: 1, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[6], - paddingTop: theme.spacing[16], - }, - mutedText: { - fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - errorText: { - fontSize: theme.fontSize.base, - color: theme.colors.destructive, - textAlign: "center", - }, -})); diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx new file mode 100644 index 00000000000..65cc1b4c260 --- /dev/null +++ b/packages/app/src/panels/diff-panel.tsx @@ -0,0 +1,409 @@ +import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { FileDiff, GitCommitHorizontal } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; +import { useToast } from "@/contexts/toast-context"; +import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { + DiffFilesToolbar, + DiffLayoutToggle, + DiffModeMenu, + DiffOptionsMenu, + resolveDiffLayout, + SharedDiffView, +} from "@/git/diff-pane"; +import { useCommitDiffFiles } from "@/git/use-diff-files"; +import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; +import { useChangesPreferences } from "@/hooks/use-changes-preferences"; +import { useAppSettings } from "@/hooks/use-settings"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; + +const ThemedFileDiff = withUnistyles(FileDiff); +const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); + +function useDiffPanelPreferences() { + const { settings } = useAppSettings(); + const { preferences, updatePreferences } = useChangesPreferences(); + const isCompact = useIsCompactFormFactor(); + const canUseSplitLayout = isWeb && !isCompact; + const effectiveLayout = resolveDiffLayout(preferences.layout, canUseSplitLayout); + const displayPreferences = useMemo( + () => ({ + layout: effectiveLayout, + wrapLines: preferences.wrapLines, + codeFontSize: settings.codeFontSize, + monoFontFamily: settings.monoFontFamily, + }), + [effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily], + ); + const toggleLayout = useCallback(() => { + void updatePreferences({ layout: preferences.layout === "unified" ? "split" : "unified" }); + }, [preferences.layout, updatePreferences]); + const toggleWrapLines = useCallback(() => { + void updatePreferences({ wrapLines: !preferences.wrapLines }); + }, [preferences.wrapLines, updatePreferences]); + const toggleHideWhitespace = useCallback(() => { + void updatePreferences({ hideWhitespace: !preferences.hideWhitespace }); + }, [preferences.hideWhitespace, updatePreferences]); + + return { + preferences, + isCompact, + canUseSplitLayout, + displayPreferences, + toggleLayout, + toggleWrapLines, + toggleHideWhitespace, + }; +} + +function PanelState({ + message, + tone = "muted", + testID, +}: { + message: string; + tone?: "muted" | "error"; + testID?: string; +}) { + return ( + + {message} + + ); +} + +function WorkingDiffBody({ + cwd, + isConnected, + workingDiff, + hideWhitespace, + displayPreferences, + mode, +}: { + cwd: string | null | undefined; + isConnected: boolean; + workingDiff: ReturnType; + hideWhitespace: boolean; + displayPreferences: ReturnType["displayPreferences"]; + mode: Extract["mode"], { kind: "working_tab" }>; +}) { + const { t } = useTranslation(); + if (!cwd) { + return ; + } + if (!isConnected) { + return ; + } + if (workingDiff.isStatusLoading) { + return ; + } + if (workingDiff.statusErrorMessage) { + return ( + + ); + } + if (workingDiff.notGit) { + return ; + } + if (workingDiff.diffPayloadError) { + return ( + + ); + } + if (workingDiff.isDiffLoading && workingDiff.files.length === 0) { + return ; + } + if (workingDiff.files.length === 0) { + return ( + + ); + } + return ( + + ); +} + +function WorkingDiffPanel() { + const { t } = useTranslation(); + const toast = useToast(); + const { serverId, workspaceId, tabId, target } = usePaneContext(); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const isConnected = useHostRuntimeIsConnected(serverId); + const isActive = useRetainedPanelActive(); + const panelPreferences = useDiffPanelPreferences(); + const [expandedPaths, setExpandedPaths] = useState(null); + invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target"); + + const workingDiff = useWorkingDiff({ + serverId, + workspaceId, + cwd: cwd ?? "", + ignoreWhitespace: panelPreferences.preferences.hideWhitespace, + enabled: Boolean(cwd) && isActive, + queryScope: `working-diff-tab:${tabId}`, + }); + usePublishWorkingDiffAttachment({ + serverId, + workspaceId, + cwd: cwd ?? "", + attachment: workingDiff.reviewAttachment, + enabled: Boolean(cwd) && isActive, + }); + + const refreshSupported = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, + ); + const runRefresh = useCheckoutGitActionsStore((state) => state.refresh); + const isRefreshing = + useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd: cwd ?? "", actionId: "refresh" }), + ) === "pending"; + const refresh = useCallback(() => { + if (!cwd || isRefreshing) { + return; + } + void runRefresh({ serverId, cwd }).catch((error) => { + toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); + }); + }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); + + const expandedPathSet = useMemo( + () => (expandedPaths === null ? null : new Set(expandedPaths)), + [expandedPaths], + ); + const allFilesExpanded = + workingDiff.files.length > 0 && + (expandedPathSet === null || workingDiff.files.every((file) => expandedPathSet.has(file.path))); + const toggleExpandAll = useCallback(() => { + setExpandedPaths(allFilesExpanded ? [] : null); + }, [allFilesExpanded]); + const mode = useMemo( + () => ({ + kind: "working_tab" as const, + expandedPaths, + reviewActions: workingDiff.reviewActions, + focusPath: target.focusPath, + focusRequestId: target.focusRequestId, + onExpandedPathsChange: setExpandedPaths, + }), + [expandedPaths, target.focusPath, target.focusRequestId, workingDiff.reviewActions], + ); + + const baseRefLabel = workingDiff.baseRef?.replace(/^refs\/(heads|remotes)\//, "") ?? ""; + return ( + + + + + {panelPreferences.canUseSplitLayout ? ( + + ) : null} + {workingDiff.files.length > 0 ? ( + + ) : null} + + + + + + + + ); +} + +function CommitDiffPanel() { + const { t } = useTranslation(); + const { serverId, workspaceId, target } = usePaneContext(); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const panelPreferences = useDiffPanelPreferences(); + invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target"); + const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({ + serverId, + cwd: cwd ?? "", + sha: target.sha, + enabled: Boolean(cwd), + }); + const mode = useMemo(() => ({ kind: "commit" as const }), []); + + let body: ReactNode; + if (!cwd) { + body = ; + } else if (capabilityMissing) { + body = ( + + ); + } else if (error) { + body = ( + + ); + } else if (isLoading && files.length === 0) { + body = ; + } else if (files.length === 0) { + body = ; + } else { + body = ( + + ); + } + + return ( + + {panelPreferences.canUseSplitLayout ? ( + + + + + + ) : null} + {body} + + ); +} + +function useWorkingDiffPanelDescriptor(): PanelDescriptor { + const { t } = useTranslation(); + return { + label: t("panels.diff.changesLabel"), + subtitle: t("panels.diff.changesSubtitle"), + tooltip: t("panels.diff.changesSubtitle"), + titleState: "ready", + icon: ThemedFileDiff, + statusBucket: null, + }; +} + +function useCommitDiffPanelDescriptor( + target: Extract, +): PanelDescriptor { + const { t } = useTranslation(); + return { + label: target.sha.slice(0, 7), + subtitle: t("panels.diff.commitSubtitle"), + tooltip: target.sha, + titleState: "ready", + icon: ThemedGitCommitHorizontal, + statusBucket: null, + }; +} + +export const workingDiffPanelRegistration: PanelRegistration<"working_diff"> = { + kind: "working_diff", + component: WorkingDiffPanel, + useDescriptor: useWorkingDiffPanelDescriptor, +}; + +export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = { + kind: "commit_diff", + component: CommitDiffPanel, + useDescriptor: useCommitDiffPanelDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + }, + toolbar: { + height: WORKSPACE_SECONDARY_HEADER_HEIGHT, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + flexShrink: 0, + }, + toolbarActions: { + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing[2], + }, + body: { + flex: 1, + minHeight: 0, + }, + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[6], + paddingTop: theme.spacing[16], + }, + mutedText: { + fontSize: theme.fontSize.base, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + errorText: { + fontSize: theme.fontSize.base, + color: theme.colors.destructive, + textAlign: "center", + }, +})); diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index b49ca43b4fc..b45449f29e6 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, type ReactNode } from "react"; import invariant from "tiny-invariant"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; export interface PaneContextValue { diff --git a/packages/app/src/panels/panel-registry.ts b/packages/app/src/panels/panel-registry.ts index 5fa16e5aa34..6c8afb14f34 100644 --- a/packages/app/src/panels/panel-registry.ts +++ b/packages/app/src/panels/panel-registry.ts @@ -1,5 +1,5 @@ import type { ComponentType } from "react"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; export interface PanelIconProps { diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index c86c656e3d2..9b8d370694d 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -1,6 +1,6 @@ import { agentPanelRegistration } from "@/panels/agent-panel"; import { browserPanelRegistration } from "@/panels/browser-panel"; -import { commitDiffPanelRegistration } from "@/panels/commit-diff-panel"; +import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel"; import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; import { registerPanel } from "@/panels/panel-registry"; @@ -22,5 +22,6 @@ export function ensurePanelsRegistered(): void { registerPanel(browserPanelRegistration); registerPanel(filePanelRegistration); registerPanel(commitDiffPanelRegistration); + registerPanel(workingDiffPanelRegistration); panelsRegistered = true; } diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 3ca919a60c1..a183b82554e 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -13,7 +13,7 @@ import invariant from "tiny-invariant"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import type { Theme } from "@/styles/theme"; import { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index e60adaf2e1c..e8863d0d223 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -78,7 +78,7 @@ import type { MessagePayload } from "@/composer/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; import { getWorkspaceNamingAttachments, diff --git a/packages/app/src/screens/workspace/visible-agent-ids.test.ts b/packages/app/src/screens/workspace/visible-agent-ids.test.ts index 3fbe5611929..66208894752 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.test.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; import { selectVisibleAgentIds } from "./visible-agent-ids"; test("selects only the active agent tab in every visible pane", () => { diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts index cfaf4b3eba2..b9e9e866236 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -1,5 +1,5 @@ import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; import { deriveWorkspacePaneState } from "./workspace-pane-state"; export function selectVisibleAgentIds(input: { diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 620e14fcf62..5689f318dcb 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -444,7 +444,7 @@ interface WorkspaceDesktopTabsRowProps { function getFallbackTabLabel( tab: WorkspaceTabDescriptor, - labels: { newAgent: string; setup: string; terminal: string; agent: string }, + labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string }, ): string { if (tab.target.kind === "draft") { return labels.newAgent; @@ -458,6 +458,9 @@ function getFallbackTabLabel( if (tab.target.kind === "file") { return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; } + if (tab.target.kind === "working_diff") { + return labels.changes; + } return labels.agent; } @@ -836,6 +839,7 @@ export function WorkspaceDesktopTabsRow({ setup: t("workspace.tabs.fallback.setup"), terminal: t("workspace.tabs.fallback.terminal"), agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), }), [t], ); diff --git a/packages/app/src/screens/workspace/workspace-file-open-command.ts b/packages/app/src/screens/workspace/workspace-file-open-command.ts index 0ec7cc8ac2a..e6af1348b59 100644 --- a/packages/app/src/screens/workspace/workspace-file-open-command.ts +++ b/packages/app/src/screens/workspace/workspace-file-open-command.ts @@ -2,7 +2,7 @@ import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, } from "@/workspace/file-open"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; interface OpenWorkspaceFileFromExplorerInput { filePath: string; diff --git a/packages/app/src/screens/workspace/workspace-pane-state.test.ts b/packages/app/src/screens/workspace/workspace-pane-state.test.ts index d57e4ffac4c..778d8fded21 100644 --- a/packages/app/src/screens/workspace/workspace-pane-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-pane-state.test.ts @@ -5,7 +5,7 @@ import { resolveSideFileOpenPlacement, } from "@/screens/workspace/workspace-pane-state"; import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab { return { diff --git a/packages/app/src/screens/workspace/workspace-pane-state.ts b/packages/app/src/screens/workspace/workspace-pane-state.ts index e32a05722f8..016e2f0c8af 100644 --- a/packages/app/src/screens/workspace/workspace-pane-state.ts +++ b/packages/app/src/screens/workspace/workspace-pane-state.ts @@ -3,7 +3,7 @@ import { type SplitPane, type WorkspaceLayout, } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { buildDeterministicWorkspaceTabId, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index f41bde08a4d..f64447d9ab0 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -74,14 +74,17 @@ import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { - buildWorkspaceTabPersistenceKey, collectAllTabs, getFocusedBrowserId, type WorkspaceLayout, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, } from "@/stores/workspace-layout-store"; -import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { + buildWorkspaceTabPersistenceKey, + type WorkspaceTab, + type WorkspaceTabTarget, +} from "@/workspace-tabs/model"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -345,6 +348,7 @@ function getFallbackTabOptionLabel( terminal: string; browser: string; agent: string; + changes: string; }, ): string { if (tab.target.kind === "draft") { @@ -362,6 +366,9 @@ function getFallbackTabOptionLabel( if (tab.target.kind === "file") { return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; } + if (tab.target.kind === "working_diff") { + return labels.changes; + } if (tab.target.kind === "commit_diff") { return tab.target.sha.slice(0, 7); } @@ -376,6 +383,7 @@ function getFallbackTabOptionDescription( agent: string; terminal: string; browser: string; + changes: string; }, ): string { if (tab.target.kind === "draft") { @@ -399,6 +407,9 @@ function getFallbackTabOptionDescription( if (tab.target.kind === "commit_diff") { return tab.target.sha.slice(0, 7); } + if (tab.target.kind === "working_diff") { + return labels.changes; + } return tab.target.path; } @@ -671,6 +682,7 @@ function MobileWorkspaceTabOption({ terminal: t("workspace.tabs.fallback.terminal"), browser: t("workspace.tabs.fallback.browser"), agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), }), [t], ); @@ -2514,6 +2526,7 @@ function WorkspaceScreenContent({ terminal: t("workspace.tabs.fallback.terminal"), browser: t("workspace.tabs.fallback.browser"), agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), }), [t], ); diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts index 68325ee747b..56de7207c12 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu"; +import { + buildWorkspaceDesktopTabActions, + buildWorkspaceTabMenuEntries, +} from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; function createAgentTab(): WorkspaceTabDescriptor { @@ -253,6 +256,37 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts"); }); + it("uses a Changes close id for the working diff tab", () => { + const actions = buildWorkspaceDesktopTabActions({ + tab: { + key: "working_diff_abc", + tabId: "working_diff_abc", + kind: "working_diff", + target: { + kind: "working_diff", + focusPath: "src/example.ts", + focusRequestId: 1, + }, + }, + index: 0, + tabCount: 1, + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onCopyFilePath: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), + onCloseTab: vi.fn(), + onCloseTabsToLeft: vi.fn(), + onCloseTabsToRight: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + expect(actions.closeButtonTestId).toMatch(/^workspace-working-diff-close-/); + expect(actions.menuEntries).not.toContainEqual( + expect.objectContaining({ kind: "item", key: "copy-file-path" }), + ); + }); + it("uses the same rename entry shape for agent and terminal tabs", () => { const terminalTab: WorkspaceTabDescriptor = { key: "terminal_abc", diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index db15ea57724..4845b316006 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -144,6 +144,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "commit_diff") { return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`; } + if (tab.target.kind === "working_diff") { + return `workspace-working-diff-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } diff --git a/packages/app/src/screens/workspace/workspace-tab-model.test.ts b/packages/app/src/screens/workspace/workspace-tab-model.test.ts index 5bfa56660ad..a01bc34e552 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; describe("deriveWorkspaceTabModel", () => { it("keeps normalized tabs in stored order and preserves targets", () => { diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index 6a5c8acfa6d..b7e6c967f85 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -1,4 +1,4 @@ -import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { deriveWorkspacePaneState, type WorkspaceDerivedTab, diff --git a/packages/app/src/screens/workspace/workspace-tabs-types.ts b/packages/app/src/screens/workspace/workspace-tabs-types.ts index ef1a09e2144..4a41512fa18 100644 --- a/packages/app/src/screens/workspace/workspace-tabs-types.ts +++ b/packages/app/src/screens/workspace/workspace-tabs-types.ts @@ -1,4 +1,4 @@ -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; export interface WorkspaceTabDescriptor { key: string; diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index a45d1f8eac4..998796c62d8 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { navigateToLastWorkspace, navigateToWorkspace, diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index 24e92b74acf..393653c6204 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -11,7 +11,7 @@ import { resolveWorkspaceMapKeyByIdentity, } from "@/utils/workspace-identity"; import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab"; export interface RouteSelectionInput { diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index d31a144856f..2dbd27f2a59 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export interface PendingWorkspaceDraftSubmission { serverId: string; diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index f7633b34ca2..5ccf90adce6 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -1,5 +1,5 @@ import invariant from "tiny-invariant"; -import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants"; import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids"; import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids"; @@ -285,9 +285,14 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null { const tab = value as WorkspaceTab; const target = normalizeWorkspaceTabTarget(tab.target); + if (!target) { + return null; + } const tabId = - trimNonEmpty(tab.tabId) ?? (target ? buildDeterministicWorkspaceTabId(target) : null); - if (!target || !tabId) { + target.kind === "working_diff" + ? buildDeterministicWorkspaceTabId(target) + : (trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target)); + if (!tabId) { return null; } diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index b65eedd2e0a..24d162eae42 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -15,9 +15,8 @@ vi.mock("@react-native-async-storage/async-storage", () => { }; }); -import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; import { - buildWorkspaceTabPersistenceKey, collectAllPanes, collectAllTabs, createWorkspaceLayoutStore, @@ -27,8 +26,10 @@ import { getFocusedBrowserId, getTreeDepth, insertSplit, + normalizeLayout, removePaneFromTree, removeTabFromTree, + stripEphemeralTabsFromLayout, type SplitNode, type SplitPane, } from "@/stores/workspace-layout-store"; @@ -1128,6 +1129,73 @@ describe("workspace-layout-store actions", () => { ]); }); + it("persists working diff tabs while stripping commit diff tabs", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + + store.openTabFocused(workspaceKey, { + kind: "working_diff", + focusPath: "src/a.ts", + }); + store.openTabFocused(workspaceKey, { kind: "commit_diff", sha: "abc123" }); + + const partialize = workspaceLayoutStore.persist.getOptions().partialize; + expect(partialize).toBeTypeOf("function"); + if (!partialize) { + throw new Error("Workspace layout partialize function is missing"); + } + const currentState = workspaceLayoutStore.getState(); + const layout = stripEphemeralTabsFromLayout(currentState.layoutByWorkspace[workspaceKey]); + const persisted = partialize(currentState); + + expect(persisted).toEqual({ + layoutByWorkspace: { [workspaceKey]: layout }, + splitSizesByWorkspace: currentState.splitSizesByWorkspace, + }); + expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([ + { + kind: "working_diff", + focusPath: "src/a.ts", + }, + ]); + }); + + it("canonicalizes comparison-specific working diff tab ids from persisted layouts", () => { + const legacyTabId = "working_diff_uncommitted_0_n"; + const layout = normalizeLayout({ + root: { + kind: "pane", + pane: { + id: "main", + tabIds: [legacyTabId], + focusedTabId: legacyTabId, + tabs: [ + { + tabId: legacyTabId, + createdAt: 1, + target: { + kind: "working_diff", + focusPath: "src/a.ts", + mode: "uncommitted", + baseRef: null, + ignoreWhitespace: false, + }, + }, + ], + }, + }, + focusedPaneId: "main", + }); + + expect(collectAllTabs(layout.root)).toEqual([ + { + tabId: "working_diff", + target: { kind: "working_diff", focusPath: "src/a.ts" }, + createdAt: 1, + }, + ]); + }); + it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => { useWorkspaceLayoutIds( "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 2f84c53b8b2..dee6c58693d 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -2,11 +2,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect, useState } from "react"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import { - buildWorkspaceTabPersistenceKey, - type WorkspaceTab, - type WorkspaceTabTarget, -} from "@/stores/workspace-tabs-store"; +import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { defaultWorkspaceLayoutIds, type WorkspaceLayoutIdSource, @@ -47,7 +43,6 @@ import { } from "@/stores/workspace-layout-actions"; import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity"; -export { buildWorkspaceTabPersistenceKey }; export { collectAllPanes, collectAllTabs, diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index 23e69eedc0b..669d4b6462e 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -1,6 +1,6 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { create } from "zustand"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; export type WorkspaceCreationMethod = "open_project" | "create_worktree"; diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index d3838dad821..1559359f0fb 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -5,8 +5,9 @@ import { deriveWorkspaceAgentVisibility, type WorkspaceAgentVisibility, } from "@/workspace-tabs/agent-visibility"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { selectSubagentsForParent } from "@/subagents/select"; -import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store"; +import { useWorkspaceLayoutStore } from "./workspace-layout-store"; import { useSessionStore, type Agent } from "./session-store"; vi.mock("@react-native-async-storage/async-storage", () => { diff --git a/packages/app/src/stores/workspace-tabs-store/index.ts b/packages/app/src/stores/workspace-tabs-store/index.ts deleted file mode 100644 index 013f3fca42a..00000000000 --- a/packages/app/src/stores/workspace-tabs-store/index.ts +++ /dev/null @@ -1,124 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; -import { - applyCloseTab, - applyEnsureTab, - applyFocusTab, - applyOpenDraftTab, - applyOpenOrFocusTab, - applyPurgeWorkspace, - applyReorderTabs, - applyRetargetTab, - initialWorkspaceTabsCoreState, - migrateWorkspaceTabsState, - partializeWorkspaceTabsState, - selectWorkspaceTabs, - type WorkspaceTab, - type WorkspaceTabsCoreState, - type WorkspaceTabTarget, -} from "./state"; - -export { buildWorkspaceTabPersistenceKey } from "./state"; -export type { WorkspaceDraftTabSetup, WorkspaceTab, WorkspaceTabTarget } from "./state"; - -interface WorkspaceTabsState extends WorkspaceTabsCoreState { - openDraftTab: (input: { - serverId: string; - workspaceId: string; - draftId: string; - }) => string | null; - ensureTab: (input: { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - }) => string | null; - openOrFocusTab: (input: { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - }) => string | null; - focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; - closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; - retargetTab: (input: { - serverId: string; - workspaceId: string; - tabId: string; - target: WorkspaceTabTarget; - }) => string | null; - reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; - getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; - purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void; -} - -export const useWorkspaceTabsStore = create()( - persist( - (set, get) => ({ - ...initialWorkspaceTabsCoreState, - openDraftTab: ({ serverId, workspaceId, draftId }) => { - let resolved: string | null = null; - set((state) => { - const result = applyOpenDraftTab(state, { - serverId, - workspaceId, - draftId, - now: Date.now(), - }); - resolved = result.tabId; - return result.state; - }); - return resolved; - }, - ensureTab: ({ serverId, workspaceId, target }) => { - let resolved: string | null = null; - set((state) => { - const result = applyEnsureTab(state, { - serverId, - workspaceId, - target, - now: Date.now(), - }); - resolved = result.tabId; - return result.state; - }); - return resolved; - }, - openOrFocusTab: ({ serverId, workspaceId, target }) => { - let resolved: string | null = null; - set((state) => { - const result = applyOpenOrFocusTab(state, { - serverId, - workspaceId, - target, - now: Date.now(), - }); - resolved = result.tabId; - return result.state; - }); - return resolved; - }, - focusTab: (input) => set((state) => applyFocusTab(state, input)), - closeTab: (input) => set((state) => applyCloseTab(state, input)), - retargetTab: ({ serverId, workspaceId, tabId, target }) => { - let resolved: string | null = null; - set((state) => { - const result = applyRetargetTab(state, { serverId, workspaceId, tabId, target }); - resolved = result.tabId; - return result.state; - }); - return resolved; - }, - reorderTabs: (input) => set((state) => applyReorderTabs(state, input)), - getWorkspaceTabs: (input) => selectWorkspaceTabs(get(), input), - purgeWorkspace: (input) => set((state) => applyPurgeWorkspace(state, input)), - }), - { - name: "workspace-tabs-state", - version: 5, - storage: createJSONStorage(() => AsyncStorage), - partialize: (state) => partializeWorkspaceTabsState(state, { now: Date.now() }), - migrate: (persistedState) => - migrateWorkspaceTabsState(persistedState, { now: Date.now() }) as WorkspaceTabsState, - }, - ), -); diff --git a/packages/app/src/stores/workspace-tabs-store/state.test.ts b/packages/app/src/stores/workspace-tabs-store/state.test.ts deleted file mode 100644 index a8bde4245dc..00000000000 --- a/packages/app/src/stores/workspace-tabs-store/state.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - applyCloseTab, - applyEnsureTab, - applyFocusTab, - applyOpenDraftTab, - applyOpenOrFocusTab, - applyRetargetTab, - buildWorkspaceTabPersistenceKey, - initialWorkspaceTabsCoreState, - migrateWorkspaceTabsState, - type WorkspaceTabsCoreState, -} from "./state"; - -const SERVER_ID = "server-1"; -const WORKSPACE_ID = "/repo/worktree"; -const WORKSPACE_KEY = `${SERVER_ID}:${WORKSPACE_ID}`; - -const NOW = 1_700_000_000_000; - -function emptyState(): WorkspaceTabsCoreState { - return { - uiTabsByWorkspace: {}, - tabOrderByWorkspace: {}, - focusedTabIdByWorkspace: {}, - }; -} - -describe("buildWorkspaceTabPersistenceKey", () => { - it("preserves opaque workspace ids instead of normalizing them like paths", () => { - expect( - buildWorkspaceTabPersistenceKey({ - serverId: SERVER_ID, - workspaceId: " setup\\workspace\\ ", - }), - ).toBe("server-1:setup\\workspace\\"); - }); -}); - -describe("workspace-tabs-store reducers", () => { - it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => { - const draftTabId = "draft_123"; - - let state = emptyState(); - state = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "left" }, - now: NOW, - }).state; - state = applyOpenDraftTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - now: NOW, - }).state; - state = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "right" }, - now: NOW, - }).state; - state = applyFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - }); - - const beforeOrder = state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; - - const retargeted = applyRetargetTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created" }, - }); - - const afterOrder = retargeted.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; - const tabs = retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; - const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null; - - expect(retargeted.tabId).toBe(draftTabId); - expect(afterOrder).toEqual(beforeOrder); - expect(retargeted.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(draftTabId); - expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" }); - }); - - it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => { - let state = emptyState(); - const ensured = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - now: NOW, - }); - state = ensured.state; - expect(ensured.tabId).toBe("terminal_term-1"); - expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBeUndefined(); - - const focused = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - now: NOW, - }); - expect(focused.tabId).toBe("terminal_term-1"); - expect(focused.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe("terminal_term-1"); - }); - - it("ensureTab deduplicates by target when a retargeted tab already exists", () => { - const draftTabId = "draft_x"; - - let state = emptyState(); - state = applyOpenDraftTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - now: NOW, - }).state; - state = applyRetargetTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created-agent" }, - }).state; - - const ensured = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "created-agent" }, - now: NOW, - }); - - const tabs = ensured.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; - const order = ensured.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? []; - const matchingTabs = tabs.filter( - (tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent", - ); - - expect(ensured.tabId).toBe(draftTabId); - expect(matchingTabs).toHaveLength(1); - expect(order).toEqual([draftTabId]); - }); - - it("openDraftTab creates a draft tab and deduplicates by draftId", () => { - let state = emptyState(); - const first = applyOpenDraftTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: "draft-1", - now: NOW, - }); - state = first.state; - const second = applyOpenDraftTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: "draft-2", - now: NOW, - }); - state = second.state; - - expect(first.tabId).toBe("draft-1"); - expect(second.tabId).toBe("draft-2"); - expect(state.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual([first.tabId, second.tabId]); - expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toEqual([ - { - tabId: "draft-1", - target: { kind: "draft", draftId: "draft-1" }, - createdAt: NOW, - }, - { - tabId: "draft-2", - target: { kind: "draft", draftId: "draft-2" }, - createdAt: NOW, - }, - ]); - }); - - it("keeps draft setup on a retargeted tab", () => { - let state = emptyState(); - const ensured = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "agent-1" }, - now: NOW, - }); - expect(ensured.tabId).toBe("agent_agent-1"); - state = ensured.state; - - const retargeted = applyRetargetTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: ensured.tabId!, - target: { - kind: "draft", - draftId: "draft-replacement", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: { effort: "high" }, - }, - }, - }); - - expect(retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({ - kind: "draft", - draftId: "draft-replacement", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: { effort: "high" }, - }, - }); - }); - - it("updates an existing draft tab when the setup changes", () => { - let state = emptyState(); - const first = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "draft", draftId: "draft-1" }, - now: NOW, - }); - state = first.state; - const second = applyEnsureTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { - kind: "draft", - draftId: "draft-1", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: {}, - }, - }, - now: NOW, - }); - state = second.state; - - expect(second.tabId).toBe(first.tabId); - expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); - expect(state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({ - kind: "draft", - draftId: "draft-1", - setup: { - provider: "mock", - cwd: "/repo/worktree", - modeId: "load-test", - model: "ten-second-stream", - thinkingOptionId: null, - featureValues: {}, - }, - }); - }); - - it("retargeting a background draft keeps the currently focused tab focused", () => { - const draftTabId = "draft_background"; - - let state = emptyState(); - state = applyOpenDraftTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - now: NOW, - }).state; - const file = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - now: NOW, - }); - state = file.state; - - state = applyRetargetTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: draftTabId, - target: { kind: "agent", agentId: "created-agent" }, - }).state; - - expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(file.tabId); - }); - - it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => { - let state = emptyState(); - const fileResult = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - now: NOW, - }); - state = fileResult.state; - const terminalResult = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "terminal", terminalId: "term-1" }, - now: NOW, - }); - state = terminalResult.state; - - expect(fileResult.tabId).toBe("file_/repo/worktree/src/index.ts"); - expect(terminalResult.tabId).toBe("terminal_term-1"); - expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(terminalResult.tabId); - - const reopened = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "file", path: "/repo/worktree/src/index.ts" }, - now: NOW, - }); - - expect(reopened.tabId).toBe(fileResult.tabId); - expect(reopened.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(fileResult.tabId); - }); - - it("builds a deterministic setup tab keyed by workspace id", () => { - const result = applyOpenOrFocusTab(initialWorkspaceTabsCoreState, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "setup", workspaceId: WORKSPACE_ID }, - now: NOW, - }); - - expect(result.tabId).toBe(`setup_${WORKSPACE_ID}`); - expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId); - }); - - it("opens a commit diff tab with a commit-specific id", () => { - let state = emptyState(); - const commit = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "commit_diff", sha: "abc123" }, - now: NOW, - }); - state = commit.state; - - expect(commit.tabId).toBe("commit_diff_abc123"); - expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); - }); - - it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => { - let state = emptyState(); - const first = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "left" }, - now: NOW, - }); - state = first.state; - const second = applyOpenOrFocusTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: "right" }, - now: NOW, - }); - state = second.state; - expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(second.tabId); - - state = applyCloseTab(state, { - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - tabId: second.tabId!, - }); - - expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(first.tabId); - expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); - }); -}); - -describe("migrateWorkspaceTabsState commit diff coercion", () => { - // This legacy store no longer enforces the "commit diff tabs are ephemeral" - // guarantee — that now lives in the workspace-layout store's partialize (see - // stripEphemeralTabsFromLayout). This migration only needs to carry old commit - // diff targets forward to the dedicated `commit_diff` tab shape. - it("migrates a legacy commit diff tab to the dedicated target shape", () => { - const persisted = { - state: { - uiTabsByWorkspace: { - [WORKSPACE_KEY]: [ - { - tabId: "commit_diff_abc123", - target: { kind: "diff", diffTarget: { kind: "commit", sha: "abc123" } }, - createdAt: NOW, - }, - ], - }, - }, - }; - - const migrated = migrateWorkspaceTabsState(persisted, { now: NOW }); - const tabs = migrated.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; - - expect(tabs).toHaveLength(1); - expect(tabs[0]?.target).toEqual({ kind: "commit_diff", sha: "abc123" }); - expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual(["commit_diff_abc123"]); - }); - - it("drops a legacy working diff tab during migration", () => { - const persisted = { - state: { - uiTabsByWorkspace: { - [WORKSPACE_KEY]: [ - { - tabId: "diff_working:base:main", - target: { - kind: "diff", - diffTarget: { kind: "working", mode: "base", baseRef: "main" }, - }, - createdAt: NOW, - }, - ], - }, - }, - }; - - const migrated = migrateWorkspaceTabsState(persisted, { now: NOW }); - - expect(migrated.uiTabsByWorkspace[WORKSPACE_KEY]).toBeUndefined(); - expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toBeUndefined(); - }); -}); diff --git a/packages/app/src/stores/workspace-tabs-store/state.ts b/packages/app/src/stores/workspace-tabs-store/state.ts deleted file mode 100644 index 9a670b2710b..00000000000 --- a/packages/app/src/stores/workspace-tabs-store/state.ts +++ /dev/null @@ -1,799 +0,0 @@ -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import { - buildDeterministicWorkspaceTabId, - normalizeWorkspaceDraftTabSetup, - normalizeWorkspaceTabTarget, - workspaceTabTargetsEqual, -} from "@/workspace-tabs/identity"; -import type { WorkspaceFileTabTarget } from "@/workspace/file-open"; - -export interface WorkspaceDraftTabSetup { - provider: AgentProvider; - cwd: string; - modeId: string | null; - model: string | null; - thinkingOptionId: string | null; - featureValues: Record; -} - -export type WorkspaceTabTarget = - | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } - | { kind: "agent"; agentId: string } - | { kind: "provider_subagent"; parentAgentId: string; subagentId: string } - | { kind: "terminal"; terminalId: string } - | { kind: "browser"; browserId: string } - | WorkspaceFileTabTarget - | { kind: "setup"; workspaceId: string } - | { kind: "commit_diff"; sha: string }; - -export interface WorkspaceTab { - tabId: string; - target: WorkspaceTabTarget; - createdAt: number; -} - -export interface WorkspaceTabsCoreState { - uiTabsByWorkspace: Record; - tabOrderByWorkspace: Record; - focusedTabIdByWorkspace: Record; -} - -export const initialWorkspaceTabsCoreState: WorkspaceTabsCoreState = { - uiTabsByWorkspace: {}, - tabOrderByWorkspace: {}, - focusedTabIdByWorkspace: {}, -}; - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export function buildWorkspaceTabPersistenceKey(input: { - serverId: string; - workspaceId: string; -}): string | null { - const serverId = trimNonEmpty(input.serverId); - const workspaceId = trimNonEmpty(input.workspaceId); - if (!serverId || !workspaceId) { - return null; - } - // workspaceId is opaque; do not parse this key back into a path. - return `${serverId}:${workspaceId}`; -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function toObjectRecord(value: unknown): Record | undefined { - return isPlainRecord(value) ? value : undefined; -} - -function normalizeTabOrder(list: unknown): string[] { - if (!Array.isArray(list)) { - return []; - } - const next: string[] = []; - const used = new Set(); - for (const value of list) { - const tabId = trimNonEmpty(typeof value === "string" ? value : null); - if (!tabId || used.has(tabId)) { - continue; - } - used.add(tabId); - next.push(tabId); - } - return next; -} - -function ensureInOrder(input: { current: string[]; tabId: string }): string[] { - if (input.current.includes(input.tabId)) { - return input.current; - } - return [...input.current, input.tabId]; -} - -function retargetTabAtIndex( - tab: WorkspaceTab, - index: number, - targetIndex: number, - normalizedTarget: WorkspaceTabTarget, -): WorkspaceTab { - return index === targetIndex ? { ...tab, target: normalizedTarget } : tab; -} - -function buildNextTabsForEnsure(args: { - currentTabs: WorkspaceTab[]; - existingIndex: number; - effectiveTabId: string; - normalizedTarget: WorkspaceTabTarget; - createdAt: number; -}): WorkspaceTab[] { - const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args; - if (existingIndex < 0) { - return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }]; - } - const existing = currentTabs[existingIndex]; - if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) { - return currentTabs; - } - return currentTabs.map((tab, index) => - retargetTabAtIndex(tab, index, existingIndex, normalizedTarget), - ); -} - -export interface EnsureTabInput { - serverId: string; - workspaceId: string; - target: WorkspaceTabTarget; - now: number; -} - -export interface EnsureTabResult { - state: WorkspaceTabsCoreState; - tabId: string | null; -} - -export function applyEnsureTab( - state: WorkspaceTabsCoreState, - input: EnsureTabInput, -): EnsureTabResult { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - const normalizedTarget = normalizeWorkspaceTabTarget(input.target); - if (!key || !normalizedTarget) { - return { state, tabId: null }; - } - - const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget); - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const tabWithSameTarget = - currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null; - const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId; - - const currentOrder = state.tabOrderByWorkspace[key] ?? []; - const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId }); - const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId); - const nextTabs = buildNextTabsForEnsure({ - currentTabs, - existingIndex, - effectiveTabId, - normalizedTarget, - createdAt: input.now, - }); - - const uiTabsByWorkspace = - nextTabs === currentTabs - ? state.uiTabsByWorkspace - : { ...state.uiTabsByWorkspace, [key]: nextTabs }; - const tabOrderByWorkspace = - nextOrder === currentOrder - ? state.tabOrderByWorkspace - : { ...state.tabOrderByWorkspace, [key]: nextOrder }; - - if ( - uiTabsByWorkspace === state.uiTabsByWorkspace && - tabOrderByWorkspace === state.tabOrderByWorkspace - ) { - return { state, tabId: effectiveTabId }; - } - - return { - state: { ...state, uiTabsByWorkspace, tabOrderByWorkspace }, - tabId: effectiveTabId, - }; -} - -export interface FocusTabInput { - serverId: string; - workspaceId: string; - tabId: string; -} - -export function applyFocusTab( - state: WorkspaceTabsCoreState, - input: FocusTabInput, -): WorkspaceTabsCoreState { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - const normalizedTabId = trimNonEmpty(input.tabId); - if (!key || !normalizedTabId) { - return state; - } - if (state.focusedTabIdByWorkspace[key] === normalizedTabId) { - return state; - } - return { - ...state, - focusedTabIdByWorkspace: { - ...state.focusedTabIdByWorkspace, - [key]: normalizedTabId, - }, - }; -} - -export function applyOpenOrFocusTab( - state: WorkspaceTabsCoreState, - input: EnsureTabInput, -): EnsureTabResult { - const ensured = applyEnsureTab(state, input); - if (!ensured.tabId) { - return ensured; - } - const focused = applyFocusTab(ensured.state, { - serverId: input.serverId, - workspaceId: input.workspaceId, - tabId: ensured.tabId, - }); - return { state: focused, tabId: ensured.tabId }; -} - -export interface OpenDraftTabInput { - serverId: string; - workspaceId: string; - draftId: string; - now: number; -} - -export function applyOpenDraftTab( - state: WorkspaceTabsCoreState, - input: OpenDraftTabInput, -): EnsureTabResult { - const normalizedDraftId = trimNonEmpty(input.draftId); - if (!normalizedDraftId) { - return { state, tabId: null }; - } - return applyOpenOrFocusTab(state, { - serverId: input.serverId, - workspaceId: input.workspaceId, - target: { kind: "draft", draftId: normalizedDraftId }, - now: input.now, - }); -} - -export interface CloseTabInput { - serverId: string; - workspaceId: string; - tabId: string; -} - -export function applyCloseTab( - state: WorkspaceTabsCoreState, - input: CloseTabInput, -): WorkspaceTabsCoreState { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - const normalizedTabId = trimNonEmpty(input.tabId); - if (!key || !normalizedTabId) { - return state; - } - - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId); - const currentOrder = state.tabOrderByWorkspace[key] ?? []; - const nextOrder = currentOrder.filter((value) => value !== normalizedTabId); - - let nextUiTabsByWorkspace: Record; - if (nextTabs.length === 0) { - const { [key]: _removed, ...rest } = state.uiTabsByWorkspace; - nextUiTabsByWorkspace = rest; - } else if (nextTabs.length === currentTabs.length) { - nextUiTabsByWorkspace = state.uiTabsByWorkspace; - } else { - nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs }; - } - - let nextTabOrderByWorkspace: Record; - if (nextOrder.length === 0) { - const { [key]: _removed, ...rest } = state.tabOrderByWorkspace; - nextTabOrderByWorkspace = rest; - } else if (nextOrder.length === currentOrder.length) { - nextTabOrderByWorkspace = state.tabOrderByWorkspace; - } else { - nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder }; - } - - const currentFocused = state.focusedTabIdByWorkspace[key] ?? null; - const nextFocused = - currentFocused !== normalizedTabId ? currentFocused : (nextOrder[nextOrder.length - 1] ?? null); - const nextFocusedByWorkspace = (() => { - if (!nextFocused) { - const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace; - return rest; - } - return { ...state.focusedTabIdByWorkspace, [key]: nextFocused }; - })(); - - const tabsChanged = nextTabs.length !== currentTabs.length; - const orderChanged = nextOrder.length !== currentOrder.length; - const focusChanged = - (state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null); - - if (!tabsChanged && !orderChanged && !focusChanged) { - return state; - } - - return { - uiTabsByWorkspace: nextUiTabsByWorkspace, - tabOrderByWorkspace: nextTabOrderByWorkspace, - focusedTabIdByWorkspace: nextFocusedByWorkspace, - }; -} - -export interface RetargetTabInput { - serverId: string; - workspaceId: string; - tabId: string; - target: WorkspaceTabTarget; -} - -export function applyRetargetTab( - state: WorkspaceTabsCoreState, - input: RetargetTabInput, -): EnsureTabResult { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - const normalizedTabId = trimNonEmpty(input.tabId); - const normalizedTarget = normalizeWorkspaceTabTarget(input.target); - if (!key || !normalizedTabId || !normalizedTarget) { - return { state, tabId: null }; - } - - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId); - if (index < 0) { - return { state, tabId: null }; - } - - const currentTarget = currentTabs[index]?.target; - if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) { - return { state, tabId: null }; - } - - const nextTabs = currentTabs.map((tab, tabIndex) => - tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab, - ); - - return { - state: { - ...state, - uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs }, - }, - tabId: normalizedTabId, - }; -} - -export interface ReorderTabsInput { - serverId: string; - workspaceId: string; - tabIds: string[]; -} - -export function applyReorderTabs( - state: WorkspaceTabsCoreState, - input: ReorderTabsInput, -): WorkspaceTabsCoreState { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - if (!key) { - return state; - } - - const normalized = normalizeTabOrder(input.tabIds); - const current = state.tabOrderByWorkspace[key] ?? []; - if (current.length === normalized.length) { - let same = true; - for (let i = 0; i < current.length; i += 1) { - if (current[i] !== normalized[i]) { - same = false; - break; - } - } - if (same) { - return state; - } - } - - return { - ...state, - tabOrderByWorkspace: { - ...state.tabOrderByWorkspace, - [key]: normalized, - }, - }; -} - -export interface PurgeWorkspaceInput { - serverId: string; - workspaceId: string; -} - -export function applyPurgeWorkspace( - state: WorkspaceTabsCoreState, - input: PurgeWorkspaceInput, -): WorkspaceTabsCoreState { - const key = buildWorkspaceTabPersistenceKey({ - serverId: input.serverId, - workspaceId: input.workspaceId, - }); - if (!key) { - return state; - } - if ( - !(key in state.uiTabsByWorkspace) && - !(key in state.tabOrderByWorkspace) && - !(key in state.focusedTabIdByWorkspace) - ) { - return state; - } - const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace; - const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace; - const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } = state.focusedTabIdByWorkspace; - return { - ...state, - uiTabsByWorkspace: remainingUiTabsByWorkspace, - tabOrderByWorkspace: remainingTabOrderByWorkspace, - focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace, - }; -} - -export function selectWorkspaceTabs( - state: WorkspaceTabsCoreState, - input: { serverId: string; workspaceId: string }, -): WorkspaceTab[] { - const key = buildWorkspaceTabPersistenceKey(input); - if (!key) { - return []; - } - return state.uiTabsByWorkspace[key] ?? []; -} - -interface MigrationRawSources { - rawUiTabsByWorkspace: Record; - rawFocused: Record; - rawOrder: Record; - legacyOrder: Record; -} - -function extractMigrationRawSources(persistedState: unknown): MigrationRawSources { - const top = toObjectRecord(persistedState) ?? {}; - const rawState = toObjectRecord(top.state) ?? top; - - return { - rawUiTabsByWorkspace: - toObjectRecord( - rawState.uiTabsByWorkspace ?? - rawState.openTabsByWorkspace ?? - top.uiTabsByWorkspace ?? - top.openTabsByWorkspace, - ) ?? {}, - rawFocused: - toObjectRecord( - rawState.focusedTabIdByWorkspace ?? - rawState.lastFocusedTabByWorkspace ?? - top.focusedTabIdByWorkspace, - ) ?? {}, - rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {}, - legacyOrder: - toObjectRecord( - rawState.tabOrderByWorkspace ?? - rawState.tabOrderLegacyByWorkspace ?? - top.tabOrderLegacyByWorkspace, - ) ?? {}, - }; -} - -function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTarget | null { - const kind = typeof raw.kind === "string" ? raw.kind : null; - if (kind === "draft" && typeof raw.draftId === "string") { - const setup = normalizeWorkspaceDraftTabSetup(raw.setup); - return normalizeWorkspaceTabTarget({ - kind: "draft", - draftId: raw.draftId, - ...(setup ? { setup } : {}), - }); - } - if (kind === "agent" && typeof raw.agentId === "string") { - return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); - } - if ( - kind === "provider_subagent" && - typeof raw.parentAgentId === "string" && - typeof raw.subagentId === "string" - ) { - return normalizeWorkspaceTabTarget({ - kind: "provider_subagent", - parentAgentId: raw.parentAgentId, - subagentId: raw.subagentId, - }); - } - if (kind === "terminal" && typeof raw.terminalId === "string") { - return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId }); - } - if (kind === "browser" && typeof raw.browserId === "string") { - return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId }); - } - if (kind === "file" && typeof raw.path === "string") { - return normalizeWorkspaceTabTarget({ - kind: "file", - path: raw.path, - lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined, - lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined, - }); - } - if (kind === "setup" && typeof raw.workspaceId === "string") { - return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId }); - } - return coercePersistedDiffTabTargetByKind(kind, raw); -} - -function coercePersistedDiffTabTargetByKind( - kind: string | null, - raw: Record, -): WorkspaceTabTarget | null { - if (kind === "commit_diff" && typeof raw.sha === "string") { - return normalizeWorkspaceTabTarget({ kind: "commit_diff", sha: raw.sha }); - } - return kind === "diff" ? coercePersistedDiffTabTarget(raw) : null; -} - -// NOTE: This legacy store no longer persists diff tabs — live tab persistence is -// owned by the workspace-layout store, which is where the "commit diff tabs are -// ephemeral on reload" guarantee is enforced (see stripEphemeralTabsFromLayout). -// This coercion exists only so this store's migration stays type-complete for any -// old diff-shaped entries left in `workspace-tabs-state` blobs. Working-tree diff -// tabs are dropped because they no longer exist as workspace targets; commit diff -// tabs migrate to the dedicated `commit_diff` target shape. -function coercePersistedDiffTabTarget(raw: Record): WorkspaceTabTarget | null { - const diffTarget = toObjectRecord(raw.diffTarget); - if (!diffTarget || diffTarget.kind !== "commit" || typeof diffTarget.sha !== "string") { - return null; - } - return normalizeWorkspaceTabTarget({ - kind: "commit_diff", - sha: diffTarget.sha, - }); -} - -function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null { - const record = toObjectRecord(rawTab); - if (!record) { - return null; - } - const rawTarget = toObjectRecord(record.target); - const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null; - if (!normalizedTarget) { - return null; - } - const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null); - const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget); - const rawCreatedAt = record.createdAt; - return { - tabId, - target: normalizedTarget, - createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : now, - }; -} - -interface MigratedTabsForKey { - nextUiTabs: WorkspaceTab[]; - orderFromTabs: string[]; -} - -function migrateUiTabsForKey(rawEntries: unknown, now: number): MigratedTabsForKey { - const entries = Array.isArray(rawEntries) ? rawEntries : []; - const nextUiTabs: WorkspaceTab[] = []; - const orderFromTabs: string[] = []; - const usedOrder = new Set(); - - for (const rawTab of entries) { - const migrated = migrateSingleTab(rawTab, now); - if (!migrated) { - continue; - } - if (!usedOrder.has(migrated.tabId)) { - usedOrder.add(migrated.tabId); - orderFromTabs.push(migrated.tabId); - } - nextUiTabs.push(migrated); - } - - return { nextUiTabs, orderFromTabs }; -} - -function mergeExplicitTabOrder( - tabOrderByWorkspace: Record, - rawOrder: Record, -): void { - for (const key in rawOrder) { - const normalizedOrder = normalizeTabOrder(rawOrder[key]); - if (normalizedOrder.length === 0) { - continue; - } - const existing = tabOrderByWorkspace[key] ?? []; - tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]); - } -} - -function convertLegacyOrderEntry(entry: unknown): string | null { - const raw = typeof entry === "string" ? entry.trim() : ""; - if (!raw) { - return null; - } - if (raw.startsWith("agent:")) { - const agentId = raw.slice("agent:".length).trim(); - return agentId ? `agent_${agentId}` : null; - } - if (raw.startsWith("terminal:")) { - const terminalId = raw.slice("terminal:".length).trim(); - return terminalId ? `terminal_${terminalId}` : null; - } - return null; -} - -function normalizeLegacyOrderList(list: unknown[]): string[] { - const result: string[] = []; - for (const entry of list) { - const converted = convertLegacyOrderEntry(entry); - if (converted) { - result.push(converted); - } - } - return result; -} - -function mergeLegacyTabOrder( - tabOrderByWorkspace: Record, - legacyOrder: Record, -): void { - for (const key in legacyOrder) { - const list = legacyOrder[key]; - if (!Array.isArray(list) || list.length === 0) { - continue; - } - const normalizedLegacyOrder = normalizeLegacyOrderList(list); - if (normalizedLegacyOrder.length === 0) { - continue; - } - const existing = tabOrderByWorkspace[key] ?? []; - tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]); - } -} - -function resolveFocusedTabId(rawValue: unknown): string | null { - if (typeof rawValue === "string") { - return trimNonEmpty(rawValue); - } - if (!rawValue || typeof rawValue !== "object") { - return null; - } - const value = rawValue as { - kind?: string; - agentId?: string; - terminalId?: string; - draftId?: string; - }; - if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) { - return `agent_${value.agentId.trim()}`; - } - if ( - value.kind === "terminal" && - typeof value.terminalId === "string" && - value.terminalId.trim() - ) { - return `terminal_${value.terminalId.trim()}`; - } - if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) { - return value.draftId.trim(); - } - return null; -} - -function migrateFocusedTabIds( - focusedTabIdByWorkspace: Record, - rawFocused: Record, -): void { - for (const key in rawFocused) { - const resolved = resolveFocusedTabId(rawFocused[key]); - if (resolved) { - focusedTabIdByWorkspace[key] = resolved; - } - } -} - -export function migrateWorkspaceTabsState( - persistedState: unknown, - options: { now: number }, -): WorkspaceTabsCoreState { - const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } = - extractMigrationRawSources(persistedState); - - const uiTabsByWorkspace: Record = {}; - const tabOrderByWorkspace: Record = {}; - const focusedTabIdByWorkspace: Record = {}; - - for (const key in rawUiTabsByWorkspace) { - const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey( - rawUiTabsByWorkspace[key], - options.now, - ); - if (nextUiTabs.length > 0) { - uiTabsByWorkspace[key] = nextUiTabs; - } - if (orderFromTabs.length > 0) { - tabOrderByWorkspace[key] = orderFromTabs; - } - } - - mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder); - mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder); - migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused); - - return { - uiTabsByWorkspace, - tabOrderByWorkspace, - focusedTabIdByWorkspace, - }; -} - -export function partializeWorkspaceTabsState( - state: WorkspaceTabsCoreState, - options: { now: number }, -): WorkspaceTabsCoreState { - const nextUiTabsByWorkspace: Record = {}; - for (const key in state.uiTabsByWorkspace) { - const tabs = (state.uiTabsByWorkspace[key] ?? []) - .map((tab) => { - const normalizedTarget = normalizeWorkspaceTabTarget(tab.target); - const normalizedTabId = trimNonEmpty(tab.tabId); - if (!normalizedTarget || !normalizedTabId) { - return null; - } - return { - tabId: normalizedTabId, - target: normalizedTarget, - createdAt: typeof tab.createdAt === "number" ? tab.createdAt : options.now, - } satisfies WorkspaceTab; - }) - .filter((tab): tab is WorkspaceTab => tab !== null); - if (tabs.length > 0) { - nextUiTabsByWorkspace[key] = tabs; - } - } - - const nextTabOrderByWorkspace: Record = {}; - for (const key in state.tabOrderByWorkspace) { - const order = normalizeTabOrder(state.tabOrderByWorkspace[key]); - if (order.length > 0) { - nextTabOrderByWorkspace[key] = order; - } - } - - const nextFocusedTabIdByWorkspace: Record = {}; - for (const key in state.focusedTabIdByWorkspace) { - const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]); - if (focusedTabId) { - nextFocusedTabIdByWorkspace[key] = focusedTabId; - } - } - - return { - uiTabsByWorkspace: nextUiTabsByWorkspace, - tabOrderByWorkspace: nextTabOrderByWorkspace, - focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace, - }; -} diff --git a/packages/app/src/utils/prepare-workspace-tab.ts b/packages/app/src/utils/prepare-workspace-tab.ts index 213b107f1f6..7c8d7a43aed 100644 --- a/packages/app/src/utils/prepare-workspace-tab.ts +++ b/packages/app/src/utils/prepare-workspace-tab.ts @@ -1,8 +1,5 @@ import { generateDraftId } from "@/stores/draft-keys"; -import { - buildWorkspaceTabPersistenceKey, - type WorkspaceTabTarget, -} from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model"; export interface PrepareWorkspaceTabInput { serverId: string; diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index 0f96b5f220e..f4a7b7a9a40 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab"; const SERVER_ID = "server-1"; diff --git a/packages/app/src/workspace-tabs/identity.test.ts b/packages/app/src/workspace-tabs/identity.test.ts index 60e85293bf1..5c175f4f306 100644 --- a/packages/app/src/workspace-tabs/identity.test.ts +++ b/packages/app/src/workspace-tabs/identity.test.ts @@ -44,6 +44,42 @@ describe("provider subagent tab identity", () => { }); }); +describe("working diff tab identity", () => { + const target = { + kind: "working_diff" as const, + focusPath: "src/example.ts", + focusRequestId: 1, + }; + + it("normalizes file focus navigation", () => { + expect( + normalizeWorkspaceTabTarget({ + ...target, + focusPath: " src\\example.ts ", + }), + ).toEqual(target); + }); + + it("treats focus as navigation state rather than tab identity", () => { + expect(workspaceTabTargetsEqual(target, target)).toBe(true); + expect(workspaceTabTargetsEqual(target, { ...target, focusPath: "src/other.ts" })).toBe(false); + expect(workspaceTabTargetsEqual(target, { ...target, focusRequestId: 2 })).toBe(false); + const workingDiffId = buildDeterministicWorkspaceTabId(target); + const otherFocusId = buildDeterministicWorkspaceTabId({ + ...target, + focusPath: "src/other.ts", + }); + const fileId = buildDeterministicWorkspaceTabId({ + kind: "file", + path: target.focusPath, + }); + + expect(workingDiffId).toBe("working_diff"); + expect(workingDiffId).toBe(otherFocusId); + expect(workingDiffId).not.toBe(fileId); + }); +}); + describe("commit diff tab identity", () => { it("keys a commit diff tab by its sha", () => { expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe( diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index 73bfec02273..0741321c755 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -1,7 +1,5 @@ -import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { normalizeWorkspaceFileLocation, workspaceFileLocationsEqual } from "@/workspace/file-open"; - -type WorkspaceDraftTabSetup = NonNullable["setup"]>; +import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; export function normalizeWorkspaceTabTarget( value: WorkspaceTabTarget | null | undefined, @@ -31,6 +29,9 @@ export function normalizeWorkspaceTabTarget( if (value.kind === "file") { return normalizeFileTabTarget(value); } + if (value.kind === "working_diff") { + return normalizeWorkingDiffTabTarget(value); + } return normalizeSimpleWorkspaceTabTarget(value); } @@ -104,12 +105,22 @@ export function workspaceTabTargetsEqual( if (left.kind === "terminal" && right.kind === "terminal") { return left.terminalId === right.terminalId; } + return secondaryWorkspaceTabTargetsEqual(left, right); +} + +function secondaryWorkspaceTabTargetsEqual( + left: WorkspaceTabTarget, + right: WorkspaceTabTarget, +): boolean { if (left.kind === "browser" && right.kind === "browser") { return left.browserId === right.browserId; } if (left.kind === "file" && right.kind === "file") { return workspaceFileLocationsEqual(left, right); } + if (left.kind === "working_diff" && right.kind === "working_diff") { + return left.focusPath === right.focusPath && left.focusRequestId === right.focusRequestId; + } if (left.kind === "setup" && right.kind === "setup") { return left.workspaceId === right.workspaceId; } @@ -174,6 +185,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "commit_diff") { return `commit_diff_${target.sha}`; } + if (target.kind === "working_diff") { + return "working_diff"; + } return `file_${target.path}`; } @@ -192,6 +206,24 @@ function normalizeFileTabTarget( return location ? { kind: "file", ...location } : null; } +function normalizeWorkingDiffTabTarget( + value: Extract, +): WorkspaceTabTarget | null { + const focusPath = trimNonEmpty(value.focusPath)?.replace(/\\/g, "/") ?? null; + const focusRequestId = normalizePositiveInteger(value.focusRequestId); + return { + kind: "working_diff" as const, + ...(focusPath ? { focusPath } : {}), + ...(focusRequestId ? { focusRequestId } : {}), + }; +} + +function normalizePositiveInteger(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : null; +} + function trimOptionalString(value: string | null | undefined): string | null { return value == null ? null : trimNonEmpty(value); } diff --git a/packages/app/src/workspace-tabs/model.test.ts b/packages/app/src/workspace-tabs/model.test.ts new file mode 100644 index 00000000000..c9623bbd99f --- /dev/null +++ b/packages/app/src/workspace-tabs/model.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildWorkspaceTabPersistenceKey } from "./model"; + +describe("buildWorkspaceTabPersistenceKey", () => { + it("trims and joins opaque server and workspace ids", () => { + expect( + buildWorkspaceTabPersistenceKey({ + serverId: " server-1 ", + workspaceId: " setup\\workspace\\ ", + }), + ).toBe("server-1:setup\\workspace\\"); + }); + + it("rejects incomplete identities", () => { + expect(buildWorkspaceTabPersistenceKey({ serverId: "", workspaceId: "workspace" })).toBeNull(); + expect(buildWorkspaceTabPersistenceKey({ serverId: "server", workspaceId: " " })).toBeNull(); + }); +}); diff --git a/packages/app/src/workspace-tabs/model.ts b/packages/app/src/workspace-tabs/model.ts new file mode 100644 index 00000000000..88589447508 --- /dev/null +++ b/packages/app/src/workspace-tabs/model.ts @@ -0,0 +1,46 @@ +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { WorkspaceFileTabTarget } from "@/workspace/file-open"; + +export interface WorkspaceDraftTabSetup { + provider: AgentProvider; + cwd: string; + modeId: string | null; + model: string | null; + thinkingOptionId: string | null; + featureValues: Record; +} + +export interface WorkspaceWorkingDiffTabTarget { + kind: "working_diff"; + focusPath?: string; + focusRequestId?: number; +} + +export type WorkspaceTabTarget = + | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } + | { kind: "agent"; agentId: string } + | { kind: "provider_subagent"; parentAgentId: string; subagentId: string } + | { kind: "terminal"; terminalId: string } + | { kind: "browser"; browserId: string } + | WorkspaceFileTabTarget + | WorkspaceWorkingDiffTabTarget + | { kind: "setup"; workspaceId: string } + | { kind: "commit_diff"; sha: string }; + +export interface WorkspaceTab { + tabId: string; + target: WorkspaceTabTarget; + createdAt: number; +} + +export function buildWorkspaceTabPersistenceKey(input: { + serverId: string; + workspaceId: string; +}): string | null { + const serverId = input.serverId.trim(); + const workspaceId = input.workspaceId.trim(); + if (!serverId || !workspaceId) { + return null; + } + return `${serverId}:${workspaceId}`; +} diff --git a/packages/app/src/workspace/use-workspace-archive.ts b/packages/app/src/workspace/use-workspace-archive.ts index 0d5c050be6e..7dc86a61aeb 100644 --- a/packages/app/src/workspace/use-workspace-archive.ts +++ b/packages/app/src/workspace/use-workspace-archive.ts @@ -8,11 +8,8 @@ import { type WorktreeArchiveWarningLabels, } from "@/git/worktree-archive-warning"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { - buildWorkspaceTabPersistenceKey, - useWorkspaceLayoutStore, -} from "@/stores/workspace-layout-store"; -import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void { @@ -20,7 +17,6 @@ function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: str if (workspaceKey) { useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey); } - useWorkspaceTabsStore.getState().purgeWorkspace(input); } export interface ArchiveWorkspaceInput { From 10da5ca169dfc205b55b35a12280796cb546bbc8 Mon Sep 17 00:00:00 2001 From: Ethan Greenfeld <39423472+ebg1223@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:25:12 -0700 Subject: [PATCH 015/420] Improve OMP state compatibility and advisor rendering (#2219) * Accept OMP session state without thinkingLevel Models whose reasoning effort is encoded in the model ID (e.g. cursor-grok-4.5-high-fast) are marked reasoning: false by OMP, which omits thinkingLevel from get_state. The required schema field made session creation throw a ZodError. Make it optional and resolve the thinking option to null when absent. * Render OMP advisor messages as blocks --- .../agent/providers/omp/advisor-message.ts | 98 +++++++++++++++++++ .../server/agent/providers/omp/agent.test.ts | 44 +++++++++ .../src/server/agent/providers/omp/agent.ts | 6 +- .../agent/providers/omp/cli-runtime.test.ts | 17 ++++ .../agent/providers/omp/history-hooks.ts | 8 +- .../providers/omp/history-mapper.test.ts | 53 ++++++++++ .../agent/providers/omp/message-history.ts | 3 +- .../server/agent/providers/omp/rpc-types.ts | 2 +- .../providers/omp/test-utils/omp-harness.ts | 20 +++- 9 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/server/agent/providers/omp/advisor-message.ts diff --git a/packages/server/src/server/agent/providers/omp/advisor-message.ts b/packages/server/src/server/agent/providers/omp/advisor-message.ts new file mode 100644 index 00000000000..ec46dfc00cc --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/advisor-message.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; + +import type { AgentTimelineItem } from "../../agent-sdk-types.js"; +import type { OmpAgentMessage } from "./rpc-types.js"; + +type OmpCustomMessage = Extract; +type OmpAdvisorToolCallItem = Extract; +type OmpAdvisorSeverity = "nit" | "concern" | "blocker"; + +interface OmpAdvisorNote { + note: string; + severity?: OmpAdvisorSeverity; + advisor?: string; +} + +const ADVISOR_SEVERITIES: Record = { + nit: true, + concern: true, + blocker: true, +}; + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readAdvisorNotes(message: OmpCustomMessage): OmpAdvisorNote[] { + const details = Reflect.get(message, "details"); + if (!details || typeof details !== "object") return []; + const notes = Reflect.get(details, "notes"); + if (!Array.isArray(notes)) return []; + + return notes.flatMap((value): OmpAdvisorNote[] => { + if (!value || typeof value !== "object") return []; + const note = readOptionalString(Reflect.get(value, "note")); + if (!note) return []; + const rawSeverity = Reflect.get(value, "severity"); + const severity = + typeof rawSeverity === "string" && rawSeverity in ADVISOR_SEVERITIES + ? (rawSeverity as OmpAdvisorSeverity) + : undefined; + const advisor = readOptionalString(Reflect.get(value, "advisor")); + return [{ note, ...(severity ? { severity } : {}), ...(advisor ? { advisor } : {}) }]; + }); +} + +function formatAdvisorNote(note: OmpAdvisorNote): string { + const prefix = [ + note.severity ? `[${note.severity}]` : null, + note.advisor ? `[${note.advisor}]` : null, + ] + .filter(Boolean) + .join(" "); + return prefix ? `${prefix} ${note.note}` : note.note; +} + +function buildAdvisorLabel(noteCount: number, blockerCount: number): string { + if (noteCount === 0) return "Advisor"; + const label = `Advisor · ${noteCount} ${noteCount === 1 ? "note" : "notes"}`; + return blockerCount > 0 + ? `${label} · ${blockerCount} ${blockerCount === 1 ? "blocker" : "blockers"}` + : label; +} + +function buildAdvisorCallId(message: OmpCustomMessage, text: string): string { + const id = readOptionalString(Reflect.get(message, "id")); + if (id) return `omp-advisor:${id}`; + const digest = createHash("sha1").update(text.trim()).digest("hex").slice(0, 12); + return `omp-advisor:${digest}`; +} + +export function mapOmpAdvisorMessageToToolCall( + message: OmpCustomMessage, + text: string, +): OmpAdvisorToolCallItem | null { + if (Reflect.get(message, "customType") !== "advisor") return null; + + const notes = readAdvisorNotes(message); + const blockerCount = notes.filter((note) => note.severity === "blocker").length; + return { + type: "tool_call", + callId: buildAdvisorCallId(message, text), + name: "advisor", + status: "completed", + detail: { + type: "plain_text", + label: buildAdvisorLabel(notes.length, blockerCount), + text: notes.length > 0 ? notes.map(formatAdvisorNote).join("\n\n") : text, + icon: "brain", + }, + metadata: { + synthetic: true, + source: "omp_advisor", + noteCount: notes.length, + blockerCount, + }, + error: null, + }; +} diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 0323156967b..082b71edccb 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -88,6 +88,50 @@ describe("OMP agent client and session", () => { expect(omp.completedTurnCount()).toBe(1); }); + test("streams OMP advisor messages as distinct tool-call blocks", async () => { + const omp = new OmpHarness(); + await omp.start(); + + await omp.runPromptWithCustomMessage( + "review this", + { + role: "custom", + content: 'Exercise the failure path.', + customType: "advisor", + id: "advisor-live-1", + display: true, + details: { + notes: [{ note: "Exercise the failure path.", severity: "concern" }], + }, + }, + "fixed", + ); + + expect(omp.timeline()).toEqual([ + { type: "user_message", text: "review this", messageId: "user-1" }, + { + type: "tool_call", + callId: "omp-advisor:advisor-live-1", + name: "advisor", + status: "completed", + detail: { + type: "plain_text", + label: "Advisor · 1 note", + text: "[concern] Exercise the failure path.", + icon: "brain", + }, + metadata: { + synthetic: true, + source: "omp_advisor", + noteCount: 1, + blockerCount: 0, + }, + error: null, + }, + { type: "assistant_message", text: "fixed", messageId: "omp-assistant-1" }, + ]); + }); + test("does not accept a follow-up until OMP reports stable idle", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 28ca8784d79..39cdca02fac 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -91,6 +91,7 @@ import { mapOmpAvailableCommandsUpdate, mapOmpRuntimeSlashCommands } from "./com import { streamOmpHistory } from "./history.js"; import { mapOmpTodoReminderEvent, mapOmpTodoState, mapOmpTodoToolResult } from "./todo-mapper.js"; import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js"; +import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js"; import { clearOmpHostToolState, handleOmpHostToolRuntimeEvent, @@ -511,7 +512,7 @@ function isOmpRequestAbortError(error: unknown): boolean { function resolveThinkingOptionId( cachedThinkingOptionId: string | null, - sessionThinkingLevel: OmpThinkingLevel, + sessionThinkingLevel: OmpThinkingLevel | undefined, ): OmpThinkingLevel | null { const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel; return normalizeOmpThinkingOption(currentThinking); @@ -1987,11 +1988,12 @@ export class OmpAgentSession implements AgentSession { if (event.message.role === "custom") { const text = getUserMessageText(event.message.content); if (text) { + const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text); this.emit({ type: "timeline", provider: this.provider, turnId, - item: { type: "assistant_message", text }, + item: advisorItem ?? { type: "assistant_message", text }, }); } if (!this.activeTurnHasUserMessage) { diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index ae33d583073..cfa8745442d 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -96,6 +96,23 @@ describe("OMP CLI runtime", () => { }); }); + test("accepts session state without thinkingLevel for non-reasoning models", async () => { + const child = createOmpChild(); + // Models like cursor-grok-4.5-high-fast encode effort in the model ID, so + // OMP marks them reasoning: false and omits thinkingLevel from get_state. + replyToCommands(child, () => ({ + model: null, + isStreaming: false, + isCompacting: false, + sessionId: "session-1", + messageCount: 0, + queuedMessageCount: 0, + })); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + await expect(session.getState()).resolves.toMatchObject({ sessionId: "session-1" }); + }); + test("rejects malformed RPC results instead of trusting transport data", async () => { const child = createOmpChild(); replyToCommands(child, () => ({ diff --git a/packages/server/src/server/agent/providers/omp/history-hooks.ts b/packages/server/src/server/agent/providers/omp/history-hooks.ts index 914acc6c629..75b9505c3f9 100644 --- a/packages/server/src/server/agent/providers/omp/history-hooks.ts +++ b/packages/server/src/server/agent/providers/omp/history-hooks.ts @@ -1,13 +1,15 @@ import type { OmpHistoryMapperHooks } from "./message-history.js"; +import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js"; import { mapOmpSystemNoticeToToolCall } from "./system-notice.js"; import { mapOmpToolDetail } from "./tool-call-mapper.js"; import { resolveOmpEmittedToolCallId } from "./tool-call-id.js"; export const OMP_HISTORY_MAPPER_HOOKS: OmpHistoryMapperHooks = { mapToolDetail: mapOmpToolDetail, - mapCustomMessage: (text, provider) => { - const noticeItem = mapOmpSystemNoticeToToolCall(text); - return noticeItem ? { type: "timeline", provider, item: noticeItem } : null; + mapCustomMessage: (message, text, provider) => { + const item = + mapOmpAdvisorMessageToToolCall(message, text) ?? mapOmpSystemNoticeToToolCall(text); + return item ? { type: "timeline", provider, item } : null; }, resolveToolCallId: resolveOmpEmittedToolCallId, }; diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index 47665869dbc..353f3c98f82 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -156,6 +156,59 @@ describe("OMP history mapper", () => { ]); }); + test("renders replayed OMP advisor messages as synthetic tool-call blocks", async () => { + await expect( + collectHistory([ + { + role: "custom", + content: [ + { + type: "text", + text: 'Add an authorization check.', + }, + ], + customType: "advisor", + id: "advisor-message-1", + display: true, + details: { + notes: [ + { + note: "Add an authorization check.", + severity: "blocker", + advisor: "security", + }, + { note: "Exercise the failure path.", severity: "concern" }, + ], + }, + }, + ]), + ).resolves.toEqual([ + { + type: "timeline", + provider: "omp", + item: { + type: "tool_call", + callId: "omp-advisor:advisor-message-1", + name: "advisor", + status: "completed", + detail: { + type: "plain_text", + label: "Advisor · 2 notes · 1 blocker", + text: "[blocker] [security] Add an authorization check.\n\n[concern] Exercise the failure path.", + icon: "brain", + }, + metadata: { + synthetic: true, + source: "omp_advisor", + noteCount: 2, + blockerCount: 1, + }, + error: null, + }, + }, + ]); + }); + test("suppresses replayed raw todo tool calls through the OMP detail hook", async () => { await expect( collectHistory([ diff --git a/packages/server/src/server/agent/providers/omp/message-history.ts b/packages/server/src/server/agent/providers/omp/message-history.ts index 562c669eeba..04a6b287723 100644 --- a/packages/server/src/server/agent/providers/omp/message-history.ts +++ b/packages/server/src/server/agent/providers/omp/message-history.ts @@ -17,6 +17,7 @@ export interface OmpCapturedUserMessageEntry { export interface OmpHistoryMapperHooks { mapCustomMessage?: ( + message: Extract, text: string, provider: string, ) => Extract | null; @@ -117,7 +118,7 @@ export class OmpHistoryMapper { message: Extract, ): AgentStreamEvent[] { const text = getUserMessageText(message.content); - const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null; + const mappedEvent = text ? this.hooks.mapCustomMessage?.(message, text, this.provider) : null; if (mappedEvent) { return [mappedEvent]; } diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index bdd5412a426..c97a3b7f3ec 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -114,7 +114,7 @@ const OmpContextUsageSchema = z export const OmpSessionStateSchema = z .object({ model: OmpModelSchema.nullable().optional(), - thinkingLevel: OmpThinkingLevelSchema, + thinkingLevel: OmpThinkingLevelSchema.optional(), isStreaming: z.boolean(), isCompacting: z.boolean(), autoCompactionEnabled: z.boolean().optional(), diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 817649e2601..2675373fa2c 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -12,7 +12,7 @@ import type { } from "../../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../../tools/types.js"; import { OmpAgentClient, OmpAgentSession, type OmpProviderIdleScheduler } from "../agent.js"; -import type { OmpRpcSlashCommand } from "../rpc-types.js"; +import type { OmpAgentMessage, OmpRpcSlashCommand } from "../rpc-types.js"; import { FakeOmp } from "./fake-omp.js"; const CWD = "/tmp/paseo-omp-agent-test"; @@ -155,6 +155,24 @@ export class OmpHarness { return await run; } + async runPromptWithCustomMessage( + input: string, + customMessage: Extract, + output: string, + ): Promise { + const session = this.requireSession(); + const promptStarted = this.omp.latestSession().nextPrompt(); + const run = session.run(input); + await promptStarted; + const runtime = this.omp.latestSession(); + runtime.beginTurn(); + runtime.acceptPrompt(input, "user-1"); + runtime.emit({ type: "message_end", message: customMessage }); + runtime.streamAssistantText(output); + runtime.finishTurn(); + return await run; + } + async runPromptAfterExtensionNotice(input: string, output: string): Promise { const session = this.requireSession(); const promptStarted = this.omp.latestSession().nextPrompt(); From de69b2a2af34e865af235bcb28ba94b897fce7ff Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Wed, 22 Jul 2026 21:30:44 +0200 Subject: [PATCH 016/420] fix(server): tone usage bars by how full they are (#2322) A usage bar in Settings -> Usage stayed green all the way to 100%, so the one moment the meter matters was the moment it said nothing. The client already computes this. `window-bar.tsx` reads `window.tone ?? deriveTone(usedPct)`, and `deriveTone` escalates past 70% and 90%. But `usage.ts` only assigns `window.tone` when a provider sends one, so any provider that sends a tone opts out of the thresholds entirely. Claude and Kimi hardcoded `tone: "ok"`, and Codex escalated to `warning` past 70% but could never reach `danger`. Providers now derive tone from the percentage they already have, through one shared `toneFromUsedPct` whose thresholds match the client's. Claude, Codex and Kimi windows all escalate correctly. Balances had the same gap by a different route: `balance-bar.tsx` has no `deriveTone` fallback, and `balanceToneFromRemaining` only escalates once a balance is completely spent, so a credits bar at 99% was green. Cursor and Grok know their limit, so they now tone by used percentage. Codex credits report only a remaining balance with no limit, so they keep the remaining-based tone, documented as the no-limit case. MiniMax is unchanged: it maps a status its own API supplies rather than hardcoding a tone. Verified against the live Anthropic API with a session window at 83%, which now reports `warning` where it previously reported `ok`. Closes #2320 Co-authored-by: Claude Opus 4.8 --- .../quota-fetcher/providers/claude.ts | 9 ++- .../services/quota-fetcher/providers/codex.ts | 7 +- .../quota-fetcher/providers/cursor.ts | 5 +- .../services/quota-fetcher/providers/grok.ts | 5 +- .../services/quota-fetcher/providers/kimi.ts | 9 ++- .../services/quota-fetcher/service.test.ts | 78 +++++++++++++++++++ .../src/services/quota-fetcher/usage.test.ts | 51 ++++++++++++ .../src/services/quota-fetcher/usage.ts | 30 +++++++ 8 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/services/quota-fetcher/usage.test.ts diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/services/quota-fetcher/providers/claude.ts index 05624d481a9..1684f787a17 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ b/packages/server/src/services/quota-fetcher/providers/claude.ts @@ -14,6 +14,7 @@ import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { ApiNumberSchema, fetchProviderApi, + toneFromUsedPct, unavailableUsage, windowFromUsedPct, } from "../usage.js"; @@ -156,7 +157,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { label: "Session", utilizationPct: resp.five_hour.utilization, resetsAt: resp.five_hour.resets_at ?? null, - tone: "ok", + tone: toneFromUsedPct(resp.five_hour.utilization), }), ); } @@ -167,7 +168,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { label: "Weekly", utilizationPct: resp.seven_day.utilization, resetsAt: resp.seven_day.resets_at ?? null, - tone: "ok", + tone: toneFromUsedPct(resp.seven_day.utilization), }), ); } @@ -178,7 +179,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { label: "Weekly · Opus", utilizationPct: resp.seven_day_opus.utilization, resetsAt: resp.seven_day_opus.resets_at ?? null, - tone: "ok", + tone: toneFromUsedPct(resp.seven_day_opus.utilization), }), ); } @@ -189,7 +190,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { label: "Weekly · Omelette", utilizationPct: resp.seven_day_omelette.utilization, resetsAt: resp.seven_day_omelette.resets_at ?? null, - tone: "ok", + tone: toneFromUsedPct(resp.seven_day_omelette.utilization), }), ); } diff --git a/packages/server/src/services/quota-fetcher/providers/codex.ts b/packages/server/src/services/quota-fetcher/providers/codex.ts index 76adcb3f25f..a0607fcf2fb 100644 --- a/packages/server/src/services/quota-fetcher/providers/codex.ts +++ b/packages/server/src/services/quota-fetcher/providers/codex.ts @@ -12,6 +12,7 @@ import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { ApiNumberSchema, balanceToneFromRemaining, + toneFromUsedPct, fetchProviderApi, unavailableUsage, windowFromUsedPct, @@ -143,7 +144,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { label: "Session", utilizationPct: session.usedPct, resetsAt: session.resetsAt, - tone: "ok", + tone: toneFromUsedPct(session.usedPct), }), ); } @@ -154,7 +155,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { label: "Weekly", utilizationPct: weekly.usedPct, resetsAt: weekly.resetsAt, - tone: weekly.usedPct >= 70 ? "warning" : "ok", + tone: toneFromUsedPct(weekly.usedPct), }), ); } @@ -165,7 +166,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { label: "Code review", utilizationPct: codeReview.usedPct, resetsAt: codeReview.resetsAt, - tone: codeReview.usedPct >= 70 ? "warning" : "ok", + tone: toneFromUsedPct(codeReview.usedPct), }), ); } diff --git a/packages/server/src/services/quota-fetcher/providers/cursor.ts b/packages/server/src/services/quota-fetcher/providers/cursor.ts index 8831e2617ae..5f30cae5fc0 100644 --- a/packages/server/src/services/quota-fetcher/providers/cursor.ts +++ b/packages/server/src/services/quota-fetcher/providers/cursor.ts @@ -9,7 +9,8 @@ import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messag import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { ApiNullableNumberSchema, - balanceToneFromRemaining, + toneFromUsedPct, + usedPctOf, fetchProviderApi, toIsoStringOrNull, unavailableUsage, @@ -160,7 +161,7 @@ export class CursorQuotaProvider implements ProviderUsageFetcher { limit, unit: "usd", resetsAt: billingCycleEnd, - tone: balanceToneFromRemaining(remaining), + tone: toneFromUsedPct(usedPctOf(totalSpend, limit)), }); } diff --git a/packages/server/src/services/quota-fetcher/providers/grok.ts b/packages/server/src/services/quota-fetcher/providers/grok.ts index 3c4986a3243..8d660c34c1f 100644 --- a/packages/server/src/services/quota-fetcher/providers/grok.ts +++ b/packages/server/src/services/quota-fetcher/providers/grok.ts @@ -7,7 +7,8 @@ import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messag import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; import { ApiNumberSchema, - balanceToneFromRemaining, + toneFromUsedPct, + usedPctOf, fetchProviderApi, unavailableUsage, } from "../usage.js"; @@ -89,7 +90,7 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { remaining, limit: monthlyLimit, unit: "credits", - tone: balanceToneFromRemaining(remaining), + tone: toneFromUsedPct(usedPctOf(creditUsage, monthlyLimit)), }); } diff --git a/packages/server/src/services/quota-fetcher/providers/kimi.ts b/packages/server/src/services/quota-fetcher/providers/kimi.ts index 6b2b61f0212..af3e6b56024 100644 --- a/packages/server/src/services/quota-fetcher/providers/kimi.ts +++ b/packages/server/src/services/quota-fetcher/providers/kimi.ts @@ -5,7 +5,12 @@ import type { Logger } from "pino"; import { z } from "zod"; import type { ProviderUsage } from "../../../server/messages.js"; import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; -import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js"; +import { + ApiOptionalStringSchema, + fetchProviderApi, + toneFromUsedPct, + unavailableUsage, +} from "../usage.js"; const KimiUsageResponseSchema = z.object({ usage: z @@ -81,7 +86,7 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { usedPct, remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), resetsAt: resp.usage?.resetTime ?? null, - tone: "ok", + tone: toneFromUsedPct(usedPct), }, ], balances: [], diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 71f371c34ba..5dfc1840801 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -947,3 +947,81 @@ describe("real provider usage fetchers", () => { }); }); }); + +// Regression for #2320: providers hardcoded `tone: "ok"`, which suppressed the client's +// own thresholds (window-bar.tsx reads `window.tone ?? deriveTone(usedPct)`), so a bar +// stayed green at 99%. Codex escalated to "warning" but could never reach "danger". +describe("usage bars escalate as they fill", () => { + let claudeHome: string; + let codexHome: string; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-tone-claude-")); + codexHome = mkdtempSync(join(tmpdir(), "paseo-tone-codex-")); + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + }); + + function claudeAt(utilization: number) { + writeClaudeCredentials(claudeHome, "at_valid"); + return new ClaudeQuotaProvider({ + logger: createLogger(), + claudeHome, + claudeKeychainReader: async () => null, + fetch: mockFetch( + new Map([ + [ + "https://api.anthropic.com/api/oauth/usage", + () => + jsonResponse({ + seven_day: { utilization, resets_at: "2026-06-04T00:00:00Z" }, + }), + ], + ]), + ), + }).fetchUsage(); + } + + it.each([ + [10, "ok"], + [75, "warning"], + [99, "danger"], + ])("a Claude window at %s%% is %s", async (utilization, tone) => { + const usage = await claudeAt(utilization); + expect(usage.windows).toEqual([expect.objectContaining({ id: "weekly", tone })]); + }); + + it("a Codex window can reach danger, not just warning", async () => { + writeCodexAuth(codexHome, "at_codex"); + const usage = await new CodexQuotaProvider({ + logger: createLogger(), + codexHome, + fetch: mockFetch( + new Map([ + [ + "https://chatgpt.com/backend-api/wham/usage", + () => + jsonResponse( + makeCodexResponse({ + rate_limit: { + primary_window: { used_percent: 12, reset_at: 1_748_812_800 }, + secondary_window: { used_percent: 96, reset_at: 1_749_072_000 }, + }, + }), + ), + ], + ]), + ), + }).fetchUsage(); + + expect(usage.windows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "session", tone: "ok" }), + expect.objectContaining({ id: "weekly", tone: "danger" }), + ]), + ); + }); +}); diff --git a/packages/server/src/services/quota-fetcher/usage.test.ts b/packages/server/src/services/quota-fetcher/usage.test.ts new file mode 100644 index 00000000000..8e37583e301 --- /dev/null +++ b/packages/server/src/services/quota-fetcher/usage.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { balanceToneFromRemaining, toneFromUsedPct, usedPctOf } from "./usage.js"; + +describe("toneFromUsedPct", () => { + // Thresholds must match deriveTone in the app's provider-usage/tone.ts, which is what + // the client applies when a window arrives without a tone. + it.each([ + [0, "ok"], + [69.9, "ok"], + [70, "warning"], + [90, "warning"], + [90.1, "danger"], + [100, "danger"], + [150, "danger"], + ])("%s%% used is %s", (usedPct, expected) => { + expect(toneFromUsedPct(usedPct)).toBe(expected); + }); + + it("is neutral when the percentage is unknown", () => { + expect(toneFromUsedPct(null)).toBe("default"); + expect(toneFromUsedPct(undefined)).toBe("default"); + }); +}); + +describe("usedPctOf", () => { + it("computes a percentage of the limit", () => { + expect(usedPctOf(15.79, 42.5)).toBeCloseTo(37.15, 2); + }); + + it("is unknown when either side is missing", () => { + expect(usedPctOf(null, 100)).toBeNull(); + expect(usedPctOf(50, null)).toBeNull(); + }); + + // A zero limit would divide to Infinity and render as a full red bar. + it("is unknown when the limit is zero or negative", () => { + expect(usedPctOf(50, 0)).toBeNull(); + expect(usedPctOf(50, -1)).toBeNull(); + }); +}); + +describe("balanceToneFromRemaining", () => { + // Kept for balances with no limit, where no percentage can be computed. It only + // escalates at exhaustion, which is why anything with a limit should use + // toneFromUsedPct instead. + it("stays ok until nothing is left", () => { + expect(balanceToneFromRemaining(0.01)).toBe("ok"); + expect(balanceToneFromRemaining(0)).toBe("danger"); + expect(balanceToneFromRemaining(null)).toBe("default"); + }); +}); diff --git a/packages/server/src/services/quota-fetcher/usage.ts b/packages/server/src/services/quota-fetcher/usage.ts index 148219960cd..fa766f5778d 100644 --- a/packages/server/src/services/quota-fetcher/usage.ts +++ b/packages/server/src/services/quota-fetcher/usage.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { ProviderUsage, ProviderUsageBalance, + ProviderUsageTone, ProviderUsageWindow, } from "../../server/messages.js"; import type { ProviderApiFetch } from "./provider.js"; @@ -67,6 +68,26 @@ export function windowFromUsedPct(input: { return window; } +/** + * The tone scale for anything measured against a known limit, windows and balances alike. + * + * Thresholds match `deriveTone` in the app's provider-usage/tone.ts, which is what the + * client falls back to when a window arrives without a tone. Healthy is "ok" rather than + * "default" because that is what every provider setting a tone has always sent, and it is + * what the bars render today below their thresholds. + */ +export function toneFromUsedPct(usedPct: number | null | undefined): ProviderUsageTone { + if (typeof usedPct !== "number") return "default"; + if (usedPct > 90) return "danger"; + if (usedPct >= 70) return "warning"; + return "ok"; +} + +/** + * Tone for a balance with no known limit, where a percentage cannot be computed and the + * only signal is whether anything is left. Prefer `toneFromUsedPct` when a limit exists: + * this one stays "ok" until the balance is completely spent. + */ export function balanceToneFromRemaining( remaining: number | null | undefined, ): ProviderUsageBalance["tone"] { @@ -75,6 +96,15 @@ export function balanceToneFromRemaining( return "ok"; } +/** Percentage of a limit consumed, or null when either side is unknown. */ +export function usedPctOf( + used: number | null | undefined, + limit: number | null | undefined, +): number | null { + if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return null; + return (used / limit) * 100; +} + export function toIsoStringOrNull(timestampMs: number): string | null { const date = new Date(timestampMs); return Number.isFinite(date.getTime()) ? date.toISOString() : null; From 5dfe50ca749f8701b41956bc6bb88e69c11da0b5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 21:32:28 +0200 Subject: [PATCH 017/420] Fix notifications opening the wrong workspace (#2331) * fix(app): open notifications on the right host Agent notifications previously omitted workspace ownership, so a cold target host was treated as missing and fell back to its empty home route. Carry the authoritative workspace and keep older notifications on a target-host resolver until lookup is conclusive. * fix(app): separate notification and agent URL routing Notifications use their authoritative workspace target directly. Stable agent URLs remain server-and-agent targets whose workspace is resolved by the agent route. * fix(protocol): preserve notification workspace targets Both attention message variants retain workspaceId through outbound validation so notification clicks receive the authoritative route target. * test(app): use workspace-scoped notification target * test(server): give notification agents workspace targets * test(app): target notification workspace in navigation e2e --- docs/expo-router.md | 14 ++ .../workspace-navigation-regression.spec.ts | 49 ++++- packages/app/src/app/_layout.tsx | 5 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 174 +++++++++++------- packages/app/src/contexts/session-context.tsx | 52 +++++- .../agent-route-resolution-view.tsx | 147 +++++++++++++++ .../navigation/agent-route-resolution.test.ts | 85 +++++++++ .../src/navigation/agent-route-resolution.ts | 53 ++++++ packages/app/src/utils/host-routes.ts | 6 +- .../src/utils/notification-routing.test.ts | 11 +- .../app/src/utils/notification-routing.ts | 10 +- .../app/src/utils/os-notifications.test.ts | 10 +- .../src/agent-attention-notification.test.ts | 22 +++ .../src/agent-attention-notification.ts | 3 + packages/protocol/src/agent-deep-link.ts | 16 +- packages/protocol/src/messages.ts | 2 + .../tests/validation/ws-outbound.test.ts | 59 ++++++ .../websocket-server.notifications.test.ts | 7 +- .../server/src/server/websocket-server.ts | 6 +- 19 files changed, 624 insertions(+), 107 deletions(-) create mode 100644 packages/app/src/navigation/agent-route-resolution-view.tsx create mode 100644 packages/app/src/navigation/agent-route-resolution.test.ts create mode 100644 packages/app/src/navigation/agent-route-resolution.ts diff --git a/docs/expo-router.md b/docs/expo-router.md index df3aa2c5bc5..cf85f00c075 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -73,6 +73,20 @@ only use local param fallback during cold mount (`/` or empty pathname), or a hidden workspace can overwrite the remembered workspace before Settings or History returns. +## Agent Targets + +Notifications and agent URLs enter the router with different authoritative +targets. + +- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them + directly to the workspace with the agent open intent. +- Agent URLs carry only `serverId` and `agentId`. Route them through + `/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves + the agent's workspace from the host, and then opens the agent there. + +Both paths converge on `navigateToAgent()`. Do not make notification routing +guess a workspace, and do not add a workspace to the stable agent URL format. + ## Params Required dynamic params belong to the matched route. diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index 82b3078fb2f..7ddbf3ea91b 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -1,4 +1,8 @@ -import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { + buildHostAgentDetailRoute, + buildHostWorkspaceOpenRoute, + buildHostWorkspaceRoute, +} from "@/utils/host-routes"; import { expect, test, type Page } from "./fixtures"; import { gotoAppShell, openSettings } from "./helpers/app"; import { @@ -34,6 +38,7 @@ import { getServerId } from "./helpers/server-id"; import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates"; import { expectAppRoute } from "./helpers/route-assertions"; import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { addOfflineHostAndReload } from "./helpers/hosts"; const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i; type StartupPresentation = "splash" | "app"; @@ -158,6 +163,43 @@ async function expectWorkspaceLocation( test.describe("Workspace navigation regression", () => { test.describe.configure({ timeout: 240_000 }); + test("opens a notification's workspace on a different offline host", async ({ page }) => { + const target = { + serverId: "notification-offline-host", + workspaceId: "notification-workspace", + agentId: "notification-agent", + }; + + await gotoAppShell(page); + await addOfflineHostAndReload(page, { + serverId: target.serverId, + label: "Notification Host", + }); + await expect( + page.getByTestId("sidebar-settings").filter({ visible: true }).first(), + ).toBeVisible({ + timeout: 30_000, + }); + + await page.evaluate((data) => { + globalThis.dispatchEvent( + new CustomEvent("paseo:web-notification-click", { + detail: { data: { ...data, reason: "finished" } }, + cancelable: true, + }), + ); + }, target); + + await expectAppRoute( + page, + buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`), + { timeout: 30_000 }, + ); + await expect(page.getByText("Connecting", { exact: true })).toBeVisible(); + await expect(page.getByText("Notification Host", { exact: true })).toBeVisible(); + await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0); + }); + test("keeps one replacement draft after returning from settings and closing the last tab", async ({ page, withWorkspace, @@ -420,12 +462,13 @@ test.describe("Workspace navigation regression", () => { await expectWorkspaceDeckEntryCount(page, 2); await page.evaluate( - ({ agentId, serverId: targetServerId }) => { + ({ agentId, serverId: targetServerId, workspaceId }) => { globalThis.dispatchEvent( new CustomEvent("paseo:web-notification-click", { detail: { data: { serverId: targetServerId, + workspaceId, agentId, reason: "finished", }, @@ -434,7 +477,7 @@ test.describe("Workspace navigation regression", () => { }), ); }, - { agentId: secondAgent.id, serverId }, + { agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId }, ); await waitForWorkspaceTabsVisible(page); await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index c3346e9279f..5591949f0fa 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -147,9 +147,10 @@ function PushNotificationRouter() { const openNotification = useStableEvent((data: Record | undefined) => { const target = resolveNotificationTarget(data); const serverId = target.serverId; + const workspaceId = target.workspaceId; const agentId = target.agentId; - if (serverId && agentId) { - navigateToAgent({ serverId, agentId, pin: true }); + if (serverId && workspaceId && agentId) { + navigateToAgent({ serverId, workspaceId, agentId, pin: true }); return; } diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 39193bc1642..0ad03941436 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -1,10 +1,13 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useLocalSearchParams, useRouter, type Href } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { useFetchQuery } from "@/data/query"; +import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution"; +import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view"; import { useSessionStore } from "@/stores/session-store"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { buildHostRootRoute } from "@/utils/host-routes"; -import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; +import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; +import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; +import { toErrorMessage } from "@/utils/error-messages"; import { navigateToAgent } from "@/utils/navigate-to-agent"; export default function HostAgentReadyRoute() { @@ -21,97 +24,128 @@ function HostAgentReadyRouteContent() { serverId?: string; agentId?: string; }>(); - const redirectedRef = useRef(false); + const handledNavigationRef = useRef(null); const serverId = typeof params.serverId === "string" ? params.serverId : ""; const agentId = typeof params.agentId === "string" ? params.agentId : ""; - const client = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); + const hosts = useHosts(); + const runtimeSnapshot = useHostRuntimeSnapshot(serverId); + const client = runtimeSnapshot?.client ?? null; + const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting"; + const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const agentWorkspaceId = useSessionStore((state) => { if (!serverId || !agentId) { return null; } return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null; }); - const hasHydratedWorkspaces = useSessionStore((state) => - serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false, + const shouldLookupAgent = Boolean( + serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId, ); - const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId); - - useEffect(() => { - if (redirectedRef.current) { - return; + const lookupQuery = useFetchQuery({ + queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0], + queryFn: async () => { + if (!client) { + throw new Error("Target host client is unavailable"); + } + const result = await client.fetchAgent({ agentId }); + return result?.agent?.workspaceId ?? null; + }, + enabled: shouldLookupAgent, + retry: false, + dataShape: "value", + staleTimeMs: 0, + }); + const lookup = useMemo(() => { + if (!shouldLookupAgent) { + return { kind: "idle" }; } - if (!serverId || !agentId) { - redirectedRef.current = true; - router.replace("/" as Href); - return; + if (lookupQuery.isFetching) { + return { kind: "fetching" }; } - - if (resolvedWorkspaceId) { - redirectedRef.current = true; - navigateToAgent({ - serverId, - agentId, - }); + if (lookupQuery.isError) { + return { kind: "failed", error: toErrorMessage(lookupQuery.error) }; } - }, [agentId, resolvedWorkspaceId, router, serverId]); + if (lookupQuery.isSuccess) { + return { kind: "found", workspaceId: lookupQuery.data }; + } + return { kind: "fetching" }; + }, [ + lookupQuery.data, + lookupQuery.error, + lookupQuery.isError, + lookupQuery.isFetching, + lookupQuery.isSuccess, + shouldLookupAgent, + ]); + const resolution = resolveAgentRoute({ + serverId, + agentId, + cachedWorkspaceId: agentWorkspaceId, + connectionStatus, + lookup, + }); useEffect(() => { - if (redirectedRef.current) { - return; + let navigationKey: string | null = null; + if (resolution.kind === "invalid") { + navigationKey = "invalid"; + } else if (resolution.kind === "resolved") { + navigationKey = `workspace:${resolution.workspaceId}`; + } else if (resolution.kind === "notFound") { + navigationKey = "not-found"; } - if (!serverId || !agentId) { + if (!navigationKey || handledNavigationRef.current === navigationKey) { return; } - if (agentWorkspaceId && !hasHydratedWorkspaces) { + handledNavigationRef.current = navigationKey; + + if (resolution.kind === "resolved") { + navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId }); return; } - if (!client || !isConnected) { - redirectedRef.current = true; - router.replace(buildHostRootRoute(serverId)); - } - }, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]); + router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId)); + }, [agentId, resolution, router, serverId]); - useEffect(() => { - if (redirectedRef.current) { + const handleRetry = useCallback(() => { + if (resolution.kind === "lookupError") { + void lookupQuery.refetch(); return; } - if (!serverId || !agentId || !client || !isConnected) { + if (serverId) { + void getHostRuntimeStore().runProbeCycleNow(serverId); + } + }, [lookupQuery, resolution.kind, serverId]); + const handleManageHost = useCallback(() => { + if (serverId) { + router.push(buildSettingsHostRoute(serverId)); + } + }, [router, serverId]); + const handleBack = useCallback(() => { + if (router.canGoBack()) { + router.back(); return; } + router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href)); + }, [router, serverId]); - let cancelled = false; - void client - .fetchAgent({ agentId }) - .then((result) => { - if (cancelled || redirectedRef.current) { - return; - } - const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId); - redirectedRef.current = true; - if (workspaceId) { - navigateToAgent({ - serverId, - agentId, - workspaceId, - }); - return; - } - router.replace(buildHostRootRoute(serverId)); - return; - }) - .catch(() => { - if (cancelled || redirectedRef.current) { - return; - } - redirectedRef.current = true; - router.replace(buildHostRootRoute(serverId)); - }); - - return () => { - cancelled = true; - }; - }, [agentId, client, isConnected, router, serverId]); + if ( + resolution.kind === "waitingForHost" || + resolution.kind === "fetchingAgent" || + resolution.kind === "lookupError" + ) { + // Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host + // reconnects, then resolve the workspace from the authoritative agent record. + return ( + + ); + } return null; } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 606ea94c0c8..e0819ad7a9d 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -29,6 +29,7 @@ import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { buildAgentAttentionNotificationPayload, + type AgentAttentionReason, type AgentAttentionNotificationPayload, type NotificationPermissionRequest, } from "@getpaseo/protocol/agent-attention-notification"; @@ -154,6 +155,35 @@ const getLatestPermissionRequest = ( return null; }; +interface AgentAttentionNotificationInput { + notification?: AgentAttentionNotificationPayload; + reason: AgentAttentionReason; + serverId: string; + workspaceId: string | undefined; + agentId: string; + assistantMessage: string | null; + permissionRequest: NotificationPermissionRequest | null; +} + +function resolveAgentAttentionNotification( + input: AgentAttentionNotificationInput, +): AgentAttentionNotificationPayload | null { + if (input.notification) { + return input.notification.data.workspaceId ? input.notification : null; + } + if (!input.workspaceId) { + return null; + } + return buildAgentAttentionNotificationPayload({ + reason: input.reason, + serverId: input.serverId, + workspaceId: input.workspaceId, + agentId: input.agentId, + assistantMessage: input.reason === "finished" ? input.assistantMessage : null, + permissionRequest: input.reason === "permission" ? input.permissionRequest : null, + }); +} + type WorkspaceSetupProgressPayload = Extract< SessionOutboundMessage, { type: "workspace_setup_progress" } @@ -500,16 +530,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const assistantMessage = findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail); const permissionRequest = getLatestPermissionRequest(session, params.agentId); + const workspaceId = session?.agents?.get(params.agentId)?.workspaceId; - const notification = - params.notification ?? - buildAgentAttentionNotificationPayload({ - reason: params.reason, - serverId, - agentId: params.agentId, - assistantMessage: params.reason === "finished" ? assistantMessage : null, - permissionRequest: params.reason === "permission" ? permissionRequest : null, - }); + const notification = resolveAgentAttentionNotification({ + notification: params.notification, + reason: params.reason, + serverId, + workspaceId, + agentId: params.agentId, + assistantMessage, + permissionRequest, + }); + if (!notification) { + return; + } void sendOsNotification({ title: notification.title, diff --git a/packages/app/src/navigation/agent-route-resolution-view.tsx b/packages/app/src/navigation/agent-route-resolution-view.tsx new file mode 100644 index 00000000000..b875e0bef4c --- /dev/null +++ b/packages/app/src/navigation/agent-route-resolution-view.tsx @@ -0,0 +1,147 @@ +import { Text, View } from "react-native"; +import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import type { AgentRouteResolution } from "@/navigation/agent-route-resolution"; +import { formatConnectionStatus } from "@/utils/daemons"; +import type { Theme } from "@/styles/theme"; + +type VisibleAgentRouteResolution = Extract< + AgentRouteResolution, + { kind: "waitingForHost" | "fetchingAgent" | "lookupError" } +>; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +export function AgentRouteResolutionView({ + resolution, + hostName, + lastHostError, + onRetry, + onManageHost, + onBack, +}: { + resolution: VisibleAgentRouteResolution; + hostName: string; + lastHostError: string | null; + onRetry: () => void; + onManageHost: () => void; + onBack: () => void; +}) { + const { t } = useTranslation(); + + if (resolution.kind === "fetchingAgent") { + return ( + + + + + {t("agentPanel.unavailable.preparingSession", { serverLabel: hostName })} + + {t("agentPanel.unavailable.showSoon")} + + + ); + } + + if (resolution.kind === "lookupError") { + return ( + + + {t("agentPanel.states.failedToLoad")} + {resolution.error} + + + + + + + ); + } + + const isConnecting = + resolution.connectionStatus === "connecting" || resolution.connectionStatus === "idle"; + let title = t("workspace.route.cannotReachHost", { hostName }); + if (isConnecting) { + title = t("agentPanel.unavailable.connecting", { serverLabel: hostName }); + } else if (resolution.connectionStatus === "offline") { + title = t("workspace.route.hostOffline", { hostName }); + } + + return ( + + {isConnecting ? ( + + ) : null} + + {title} + + {isConnecting + ? t("agentPanel.unavailable.showWhenOnline") + : t("workspace.route.hostStatus", { + status: formatConnectionStatus(resolution.connectionStatus), + })} + + {lastHostError ? {lastHostError} : null} + + {!isConnecting ? ( + + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + emptyState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[6], + }, + textStack: { + alignItems: "center", + gap: theme.spacing[2], + maxWidth: 520, + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + textAlign: "center", + }, + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, + error: { + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + lineHeight: Math.round(theme.fontSize.sm * 1.4), + textAlign: "center", + }, + actions: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + flexWrap: "wrap", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/navigation/agent-route-resolution.test.ts b/packages/app/src/navigation/agent-route-resolution.test.ts new file mode 100644 index 00000000000..99fb95ec451 --- /dev/null +++ b/packages/app/src/navigation/agent-route-resolution.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { resolveAgentRoute } from "@/navigation/agent-route-resolution"; + +const VALID_ROUTE = { + serverId: "server-1", + agentId: "agent-1", + cachedWorkspaceId: null, +} as const; + +describe("resolveAgentRoute", () => { + it("opens a cached workspace without waiting for its host", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + cachedWorkspaceId: "workspace-1", + connectionStatus: "offline", + lookup: { kind: "idle" }, + }), + ).toEqual({ kind: "resolved", workspaceId: "workspace-1" }); + }); + + it.each(["idle", "connecting", "offline", "error"] as const)( + "waits for a %s target host instead of abandoning the agent", + (connectionStatus) => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + connectionStatus, + lookup: { kind: "idle" }, + }), + ).toEqual({ kind: "waitingForHost", connectionStatus }); + }, + ); + + it("fetches the agent after its target host connects", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + connectionStatus: "online", + lookup: { kind: "idle" }, + }), + ).toEqual({ kind: "fetchingAgent" }); + }); + + it("opens the workspace returned by the target host", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + connectionStatus: "online", + lookup: { kind: "found", workspaceId: "workspace-2" }, + }), + ).toEqual({ kind: "resolved", workspaceId: "workspace-2" }); + }); + + it("abandons the agent only after the target host says it is missing", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + connectionStatus: "online", + lookup: { kind: "found", workspaceId: null }, + }), + ).toEqual({ kind: "notFound" }); + }); + + it("keeps lookup failures retryable", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + connectionStatus: "online", + lookup: { kind: "failed", error: "connection closed" }, + }), + ).toEqual({ kind: "lookupError", error: "connection closed" }); + }); + + it("rejects incomplete route parameters", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + agentId: "", + connectionStatus: "online", + lookup: { kind: "idle" }, + }), + ).toEqual({ kind: "invalid" }); + }); +}); diff --git a/packages/app/src/navigation/agent-route-resolution.ts b/packages/app/src/navigation/agent-route-resolution.ts new file mode 100644 index 00000000000..88fb848c5b3 --- /dev/null +++ b/packages/app/src/navigation/agent-route-resolution.ts @@ -0,0 +1,53 @@ +import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; + +export type AgentRouteLookup = + | { kind: "idle" } + | { kind: "fetching" } + | { kind: "found"; workspaceId: string | null | undefined } + | { kind: "failed"; error: string }; + +export type AgentRouteResolution = + | { kind: "invalid" } + | { kind: "resolved"; workspaceId: string } + | { + kind: "waitingForHost"; + connectionStatus: Exclude; + } + | { kind: "fetchingAgent" } + | { kind: "notFound" } + | { kind: "lookupError"; error: string }; + +export function resolveAgentRoute(input: { + serverId: string; + agentId: string; + cachedWorkspaceId: string | null | undefined; + connectionStatus: HostRuntimeConnectionStatus; + lookup: AgentRouteLookup; +}): AgentRouteResolution { + if (!input.serverId || !input.agentId) { + return { kind: "invalid" }; + } + + const cachedWorkspaceId = normalizeWorkspaceOpaqueId(input.cachedWorkspaceId); + if (cachedWorkspaceId) { + return { kind: "resolved", workspaceId: cachedWorkspaceId }; + } + + if (input.connectionStatus !== "online") { + return { kind: "waitingForHost", connectionStatus: input.connectionStatus }; + } + + if (input.lookup.kind === "found") { + const fetchedWorkspaceId = normalizeWorkspaceOpaqueId(input.lookup.workspaceId); + return fetchedWorkspaceId + ? { kind: "resolved", workspaceId: fetchedWorkspaceId } + : { kind: "notFound" }; + } + + if (input.lookup.kind === "failed") { + return { kind: "lookupError", error: input.lookup.error }; + } + + return { kind: "fetchingAgent" }; +} diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 79f78c22910..118ea2c4065 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -1,4 +1,5 @@ import { Buffer } from "buffer"; +import { buildAgentDeepLinkRoute } from "@getpaseo/protocol/agent-deep-link"; type NullableString = string | null | undefined; const BASE64_WORKSPACE_ID_PREFIX = "b64_"; @@ -389,7 +390,10 @@ export function buildHostAgentDetailRoute(serverId: string, agentId: string, wor if (!normalizedServerId || !normalizedAgentId) { return "/" as const; } - return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}` as const; + return buildAgentDeepLinkRoute({ + serverId: normalizedServerId, + agentId: normalizedAgentId, + }); } export function buildHostRootRoute(serverId: string) { diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index f492b56e87d..92fdad43f4d 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -56,13 +56,11 @@ describe("buildNotificationRoute", () => { agentId: "agent-1", workspaceId: "ws-main", }), - ).toBe("/h/srv-1/agent/agent-1"); + ).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1"); }); - it("routes directly to server-scoped agent path when both ids are present", () => { - expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe( - "/h/srv-1/agent/agent-1", - ); + it("does not treat an incomplete notification as an agent URL", () => { + expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe("/h/srv-1"); }); it("routes to the workspace terminal tab when workspace and terminal ids are present", () => { @@ -98,8 +96,9 @@ describe("buildNotificationRoute", () => { expect( buildNotificationRoute({ serverId: "srv/with/slash", + workspaceId: "workspace-1", agentId: "agent with space", }), - ).toBe("/h/srv%2Fwith%2Fslash/agent/agent%20with%20space"); + ).toBe("/h/srv%2Fwith%2Fslash/workspace/workspace-1?open=agent%3Aagent%20with%20space"); }); }); diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index ba6d9f1fafb..04c78f591a7 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,9 +1,5 @@ import type { Href } from "expo-router"; -import { - buildHostAgentDetailRoute, - buildHostRootRoute, - buildHostWorkspaceOpenRoute, -} from "@/utils/host-routes"; +import { buildHostRootRoute, buildHostWorkspaceOpenRoute } from "@/utils/host-routes"; type NotificationData = Record | null | undefined; type NotificationRoute = Extract; @@ -33,8 +29,8 @@ export function resolveNotificationTarget(data: NotificationData): { export function buildNotificationRoute(data: NotificationData): NotificationRoute { const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data); - if (serverId && agentId) { - return buildHostAgentDetailRoute(serverId, agentId); + if (serverId && workspaceId && agentId) { + return buildHostWorkspaceOpenRoute(serverId, workspaceId, `agent:${agentId}`); } if (serverId && workspaceId && terminalId) { return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`); diff --git a/packages/app/src/utils/os-notifications.test.ts b/packages/app/src/utils/os-notifications.test.ts index e570a4c5978..84a8a65bffc 100644 --- a/packages/app/src/utils/os-notifications.test.ts +++ b/packages/app/src/utils/os-notifications.test.ts @@ -196,14 +196,20 @@ describe("sendOsNotification", () => { await sendOsNotification({ title: "Agent finished", - data: { serverId: "srv with space", agentId: "agent/1" }, + data: { + serverId: "srv with space", + workspaceId: "workspace-1", + agentId: "agent/1", + }, }); const clicked = created[0]; expect(clicked.clickListeners).toHaveLength(1); clicked.clickListeners[0]?.({} as Event); - expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1"); + expect(assign).toHaveBeenCalledWith( + "/h/srv%20with%20space/workspace/workspace-1?open=agent%3Aagent%2F1", + ); }); it("returns false when the Notification API is unavailable", async () => { diff --git a/packages/protocol/src/agent-attention-notification.test.ts b/packages/protocol/src/agent-attention-notification.test.ts index 79d19b99cd1..04aabceb2cc 100644 --- a/packages/protocol/src/agent-attention-notification.test.ts +++ b/packages/protocol/src/agent-attention-notification.test.ts @@ -6,10 +6,27 @@ import { } from "./agent-attention-notification.js"; describe("buildAgentAttentionNotificationPayload", () => { + it("carries the workspace needed to open a cold agent destination", () => { + const payload = buildAgentAttentionNotificationPayload({ + reason: "finished", + serverId: "srv-1", + workspaceId: "workspace-1", + agentId: "agent-1", + }); + + expect(payload.data).toEqual({ + serverId: "srv-1", + workspaceId: "workspace-1", + agentId: "agent-1", + reason: "finished", + }); + }); + it("builds finished notifications from markdown assistant text", () => { const payload = buildAgentAttentionNotificationPayload({ reason: "finished", serverId: "srv-1", + workspaceId: "workspace-1", agentId: "agent-1", assistantMessage: "**Done**. Updated `README.md` and [link](https://example.com).", }); @@ -19,6 +36,7 @@ describe("buildAgentAttentionNotificationPayload", () => { body: "Done. Updated README.md and link.", data: { serverId: "srv-1", + workspaceId: "workspace-1", agentId: "agent-1", reason: "finished", }, @@ -29,6 +47,7 @@ describe("buildAgentAttentionNotificationPayload", () => { const payload = buildAgentAttentionNotificationPayload({ reason: "permission", serverId: "srv-2", + workspaceId: "workspace-2", agentId: "agent-2", permissionRequest: { id: "perm-1", @@ -45,6 +64,7 @@ describe("buildAgentAttentionNotificationPayload", () => { body: "Approve command - Run git push", data: { serverId: "srv-2", + workspaceId: "workspace-2", agentId: "agent-2", reason: "permission", }, @@ -55,6 +75,7 @@ describe("buildAgentAttentionNotificationPayload", () => { const payload = buildAgentAttentionNotificationPayload({ reason: "error", serverId: "srv-3", + workspaceId: "workspace-3", agentId: "agent-3", }); @@ -63,6 +84,7 @@ describe("buildAgentAttentionNotificationPayload", () => { body: "Encountered an error.", data: { serverId: "srv-3", + workspaceId: "workspace-3", agentId: "agent-3", reason: "error", }, diff --git a/packages/protocol/src/agent-attention-notification.ts b/packages/protocol/src/agent-attention-notification.ts index 60728e1bf20..961e864d851 100644 --- a/packages/protocol/src/agent-attention-notification.ts +++ b/packages/protocol/src/agent-attention-notification.ts @@ -5,6 +5,7 @@ export type AgentAttentionReason = "finished" | "error" | "permission"; export interface AgentAttentionNotificationData { [key: string]: unknown; serverId: string; + workspaceId?: string; agentId: string; reason: AgentAttentionReason; } @@ -18,6 +19,7 @@ export interface AgentAttentionNotificationPayload { interface BuildAgentAttentionNotificationPayloadInput { reason: AgentAttentionReason; serverId: string; + workspaceId: string; agentId: string; assistantMessage?: string | null; permissionRequest?: NotificationPermissionRequest | null; @@ -203,6 +205,7 @@ export function buildAgentAttentionNotificationPayload( body, data: { serverId: input.serverId, + workspaceId: input.workspaceId, agentId: input.agentId, reason: input.reason, }, diff --git a/packages/protocol/src/agent-deep-link.ts b/packages/protocol/src/agent-deep-link.ts index bb4980ffe3e..a8e297f3dc3 100644 --- a/packages/protocol/src/agent-deep-link.ts +++ b/packages/protocol/src/agent-deep-link.ts @@ -7,18 +7,24 @@ function normalizeSegment(value: string): string { return value.trim(); } -export function buildAgentDeepLink(target: AgentDeepLinkTarget): string { +function normalizeAgentDeepLinkTarget(target: AgentDeepLinkTarget): AgentDeepLinkTarget { const serverId = normalizeSegment(target.serverId); const agentId = normalizeSegment(target.agentId); if (!serverId || !agentId) { throw new Error("Agent deep links require a server ID and agent ID."); } - return `paseo://h/${encodeURIComponent(serverId)}/agent/${encodeURIComponent(agentId)}`; + return { serverId, agentId }; +} + +export function buildAgentDeepLinkRoute( + target: AgentDeepLinkTarget, +): `/h/${string}/agent/${string}` { + const { serverId, agentId } = normalizeAgentDeepLinkTarget(target); + return `/h/${encodeURIComponent(serverId)}/agent/${encodeURIComponent(agentId)}`; } -export function buildAgentDeepLinkRoute(target: AgentDeepLinkTarget): string { - const link = new URL(buildAgentDeepLink(target)); - return `/h${link.pathname}`; +export function buildAgentDeepLink(target: AgentDeepLinkTarget): string { + return `paseo:/${buildAgentDeepLinkRoute(target)}`; } export function parseAgentDeepLink(input: string): AgentDeepLinkTarget | null { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d1944179a65..f6be9128d8a 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -658,6 +658,7 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ body: z.string(), data: z.object({ serverId: z.string(), + workspaceId: z.string().optional(), agentId: z.string(), reason: z.enum(["finished", "error", "permission"]), }), @@ -3564,6 +3565,7 @@ export const AgentAttentionRequiredMessageSchema = z.object({ body: z.string(), data: z.object({ serverId: z.string(), + workspaceId: z.string().optional(), agentId: z.string(), reason: z.enum(["finished", "error", "permission"]), }), diff --git a/packages/protocol/tests/validation/ws-outbound.test.ts b/packages/protocol/tests/validation/ws-outbound.test.ts index 63338ac630f..ffc1795f455 100644 --- a/packages/protocol/tests/validation/ws-outbound.test.ts +++ b/packages/protocol/tests/validation/ws-outbound.test.ts @@ -138,6 +138,65 @@ const SourceSchema = z.object({ ); }); + it.each([ + { + name: "dedicated attention message", + message: { + type: "agent_attention_required", + payload: { + agentId: "agent-1", + reason: "finished", + timestamp: "2026-07-22T18:00:00.000Z", + shouldNotify: true, + notification: { + title: "Agent finished", + body: "Done", + data: { + serverId: "server-1", + workspaceId: "workspace-1", + agentId: "agent-1", + reason: "finished", + }, + }, + }, + }, + }, + { + name: "agent stream attention event", + message: { + type: "agent_stream", + payload: { + agentId: "agent-1", + timestamp: "2026-07-22T18:00:00.000Z", + event: { + type: "attention_required", + provider: "codex", + reason: "finished", + timestamp: "2026-07-22T18:00:00.000Z", + shouldNotify: true, + notification: { + title: "Agent finished", + body: "Done", + data: { + serverId: "server-1", + workspaceId: "workspace-1", + agentId: "agent-1", + reason: "finished", + }, + }, + }, + }, + }, + }, + ])("preserves workspaceId in a $name", ({ message }) => { + const envelope = { type: "session", message }; + + expect(GeneratedWSOutboundMessageSchema.safeParse(envelope)).toEqual({ + success: true, + data: envelope, + }); + }); + it("emits runtime imports with .js extensions", async () => { const generated = await readFile(generatedWSOutboundPath, "utf8"); expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"'); diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index fb517efe54a..f662a044a9e 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -14,6 +14,8 @@ import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; +const WORKSPACE_ID = "workspace-1"; + const wsModuleMock = vi.hoisted(() => { class MockWebSocketServer { readonly handlers = new Map void>(); @@ -86,7 +88,7 @@ function createServer(agentManagerOverrides?: Record) { const agentManager = { subscribe: vi.fn(() => () => {}), setAgentAttentionCallback: vi.fn(), - getAgent: vi.fn(() => null), + getAgent: vi.fn(() => ({ workspaceId: WORKSPACE_ID, pendingPermissions: new Map() })), getLastAssistantMessage: vi.fn(async () => null), getMetricsSnapshot: vi.fn(() => ({ total: 0, @@ -225,6 +227,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { getAgent: vi.fn(() => ({ config: { title: null }, cwd: "/tmp/worktree", + workspaceId: WORKSPACE_ID, pendingPermissions: new Map(), })), getLastAssistantMessage, @@ -242,6 +245,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { body: "Done. Updated README.md and link.", data: { serverId: "srv-test", + workspaceId: WORKSPACE_ID, agentId: "agent-1", reason: "finished", }, @@ -256,6 +260,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { getAgent: vi.fn(() => ({ config: { title: null }, cwd: "/tmp/worktree", + workspaceId: WORKSPACE_ID, labels: {}, pendingPermissions: new Map(), })), diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b9c5b1e1432..cec656af2a5 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2152,13 +2152,17 @@ export class VoiceAssistantWebSocketServer { const allStates = clientEntries.map((e) => e.state); const nowMs = Date.now(); const agent = this.agentManager.getAgent(params.agentId); + if (!agent?.workspaceId) { + return; + } const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId); const notification = buildAgentAttentionNotificationPayload({ reason: params.reason, serverId: this.serverId, + workspaceId: agent.workspaceId, agentId: params.agentId, assistantMessage, - permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null, + permissionRequest: findLatestPermissionRequest(agent.pendingPermissions), }); const plan = computeNotificationPlan({ From 35f51714774744215c2e139704bb09524abfc455 Mon Sep 17 00:00:00 2001 From: Byeonghoon Yoo Date: Thu, 23 Jul 2026 04:42:16 +0900 Subject: [PATCH 018/420] fix(omp): complete turns when agent_end omits messages (#2261) * fix(omp): complete turns after empty agent_end * test(omp): remove timing flush from empty end regression --- .../server/agent/providers/omp/agent.test.ts | 14 ++++++++++ .../src/server/agent/providers/omp/agent.ts | 27 +++++++++++++++---- .../providers/omp/test-utils/fake-omp.ts | 6 +++++ .../providers/omp/test-utils/omp-harness.ts | 16 +++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 082b71edccb..d21ee0c0dea 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -132,6 +132,20 @@ describe("OMP agent client and session", () => { ]); }); + test("completes a streamed assistant turn when agent_end omits messages", async () => { + const omp = new OmpHarness(); + await omp.start(); + + const { completion } = await omp.startPromptWithEmptyAgentEnd( + "hello OMP", + "empty terminal payload recovered", + ); + await expect(completion).resolves.toMatchObject({ + finalText: "empty terminal payload recovered", + }); + expect(omp.completedTurnCount()).toBe(1); + }); + test("does not accept a follow-up until OMP reports stable idle", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 39cdca02fac..63e9f23d5bb 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -899,6 +899,7 @@ export class OmpAgentSession implements AgentSession { private activeTurnId: string | null = null; private activeClientMessageId: string | null = null; private activeAssistantMessageId: string | null = null; + private activeTurnTerminalAssistantMessage: OmpAgentMessage | null = null; private activeTurnStarted = false; private activeTurnHasUserMessage = false; private activeNoTurnPromptText: string | null = null; @@ -987,6 +988,7 @@ export class OmpAgentSession implements AgentSession { this.activeTurnId = turnId; this.activeClientMessageId = options?.clientMessageId ?? null; this.activeAssistantMessageId = null; + this.activeTurnTerminalAssistantMessage = null; this.activeTurnStarted = false; this.activeTurnHasUserMessage = false; this.activePromptRequestId = null; @@ -1017,6 +1019,7 @@ export class OmpAgentSession implements AgentSession { this.activeTurnStarted = false; this.activeTurnHasUserMessage = false; this.activeAssistantMessageId = null; + this.activeTurnTerminalAssistantMessage = null; this.clearNoTurnBuffers(); if (isOmpRequestAbortError(error)) { this.emit({ @@ -1151,6 +1154,7 @@ export class OmpAgentSession implements AgentSession { this.activeTurnStarted = false; this.activeTurnHasUserMessage = false; this.activeAssistantMessageId = null; + this.activeTurnTerminalAssistantMessage = null; this.clearNoTurnBuffers(); this.emit({ type: "turn_canceled", @@ -1773,6 +1777,7 @@ export class OmpAgentSession implements AgentSession { this.activeClientMessageId = null; this.activeTurnStarted = false; this.activeTurnHasUserMessage = false; + this.activeTurnTerminalAssistantMessage = null; this.clearNoTurnBuffers(); this.emit({ type: "turn_failed", @@ -1857,17 +1862,25 @@ export class OmpAgentSession implements AgentSession { }, }); return; - case "agent_end": + case "agent_end": { + const messages = event.messages ?? []; + let terminalMessages: OmpAgentMessage[] | null = null; + if (messages.some((message) => message.role === "assistant")) { + terminalMessages = messages; + } else if (this.activeTurnTerminalAssistantMessage) { + terminalMessages = [this.activeTurnTerminalAssistantMessage]; + } // OMP can end an internal extension-notice cycle before it starts the - // model turn for the same prompt. That cycle has no assistant message - // and is not the foreground turn's terminal event. - if (!(event.messages ?? []).some((message) => message.role === "assistant")) { + // model turn for the same prompt. Ignore only cycles where neither the + // terminal payload nor the live stream contained an assistant message. + if (!terminalMessages) { return; } // A state request is processed after OMP's RPC loop becomes promptable, // so do not advertise Paseo idle until it reports that transition. - void this.completeTurnAfterProviderIdle(turnId, event.messages ?? []); + void this.completeTurnAfterProviderIdle(turnId, terminalMessages); return; + } default: return; } @@ -1983,6 +1996,9 @@ export class OmpAgentSession implements AgentSession { ): void { if (event.message.role === "assistant") { this.activeAssistantMessageId = null; + if (turnId) { + this.activeTurnTerminalAssistantMessage = event.message; + } return; } if (event.message.role === "custom") { @@ -2097,6 +2113,7 @@ export class OmpAgentSession implements AgentSession { this.activeTurnId = null; this.activeClientMessageId = null; this.activeAssistantMessageId = null; + this.activeTurnTerminalAssistantMessage = null; this.activeTurnStarted = false; this.activeTurnHasUserMessage = false; this.clearNoTurnBuffers(); diff --git a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts index 05dbdf342dd..31cd6fd0fb8 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts @@ -413,6 +413,12 @@ export class FakeOmpSession implements OmpRuntimeSession { this.emit({ type: "agent_end", messages: this.messages }); } + finishTurnWithEmptyAgentEnd(message: OmpAgentMessage = { role: "assistant", content: [] }): void { + this.messages = [...this.messages, message]; + this.emit({ type: "message_end", message }); + this.emit({ type: "agent_end", messages: [] }); + } + beginTurn(): void { this.emit({ type: "turn_start" }); } diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 2675373fa2c..a2fda872e2d 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -173,6 +173,22 @@ export class OmpHarness { return await run; } + async startPromptWithEmptyAgentEnd( + input: string, + output: string, + ): Promise<{ completion: Promise }> { + const session = this.requireSession(); + const promptStarted = this.omp.latestSession().nextPrompt(); + const completion = session.run(input); + await promptStarted; + const runtime = this.omp.latestSession(); + runtime.beginTurn(); + runtime.acceptPrompt(input, "user-1"); + runtime.streamAssistantText(output); + runtime.finishTurnWithEmptyAgentEnd(); + return { completion }; + } + async runPromptAfterExtensionNotice(input: string, output: string): Promise { const session = this.requireSession(); const promptStarted = this.omp.latestSession().nextPrompt(); From 894fa8516ee3846d2b2665a0cb7f5a799a94e149 Mon Sep 17 00:00:00 2001 From: Josh Bendavid Date: Wed, 22 Jul 2026 21:47:09 +0200 Subject: [PATCH 019/420] fix(omp): accept all command source types in slash command schema (#2175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OmpRpcSlashCommandSchema had a strict source enum ['extension', 'prompt', 'skill', 'builtin'] that rejected omp commands with source 'custom' or 'file'. When omp reported any such command, the entire get_available_commands response failed Zod validation and listCommands returned an empty array — so the Paseo autocomplete only showed /exit and /clear instead of all omp commands. Relax source to z.string().optional(), matching the already-permissive OmpAvailableCommandSchema used for the available_commands_update event. --- packages/server/src/server/agent/providers/omp/rpc-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index c97a3b7f3ec..31a057291be 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -149,7 +149,7 @@ export const OmpRpcSlashCommandSchema = z .object({ name: z.string(), description: z.string().optional(), - source: z.enum(["extension", "prompt", "skill", "builtin"]), + source: z.string().optional(), sourceInfo: z.record(z.string(), z.unknown()).optional(), input: z.object({ hint: z.string().optional() }).passthrough().nullable().optional(), }) From 30b871e8d2321fd37721a1c0065f2792f050e64e Mon Sep 17 00:00:00 2001 From: Slava Goltser Date: Wed, 22 Jul 2026 16:09:45 -0400 Subject: [PATCH 020/420] feat(omp): add write approval mode (#2228) Expose OMP write approval mode in the provider manifest and launch configuration. --- packages/protocol/src/provider-manifest.ts | 7 +++++++ .../src/server/agent/providers/omp/agent.test.ts | 13 +++++++++++++ .../server/agent/providers/omp/provider-config.ts | 5 +++++ .../server/src/server/daemon-e2e/agent-configs.ts | 1 + 4 files changed, 26 insertions(+) diff --git a/packages/protocol/src/provider-manifest.ts b/packages/protocol/src/provider-manifest.ts index b3e388faa42..0e98efcb535 100644 --- a/packages/protocol/src/provider-manifest.ts +++ b/packages/protocol/src/provider-manifest.ts @@ -151,6 +151,13 @@ export const OMP_MODES: AgentProviderModeDefinition[] = [ colorTier: "dangerous", isUnattended: true, }, + { + id: "write", + label: "Write Approval", + description: "Launches OMP with write approval mode — reads are free, writes require approval.", + icon: "ShieldAlert", + colorTier: "moderate", + }, { id: "ask", label: "Always Ask", diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index d21ee0c0dea..69b2d8ff316 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -74,6 +74,18 @@ describe("OMP agent client and session", () => { expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "max"])); }); + test("launches with write approval mode", async () => { + const omp = new OmpHarness(); + await omp.start({ modeId: "write" }); + + expect(omp.launchConfiguration()).toEqual({ + cwd: "/tmp/paseo-omp-agent-test", + protocolMode: "rpc-ui", + modeId: "write", + argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "write", "--thinking", "medium"], + }); + }); + test("streams a prompt through completion", async () => { const omp = new OmpHarness(); await omp.start(); @@ -282,6 +294,7 @@ describe("OMP agent client and session", () => { await expect(omp.availableModes()).resolves.toEqual([ expect.objectContaining({ id: "full" }), + expect.objectContaining({ id: "write" }), expect.objectContaining({ id: "ask" }), ]); await expect(omp.commands()).resolves.toEqual( diff --git a/packages/server/src/server/agent/providers/omp/provider-config.ts b/packages/server/src/server/agent/providers/omp/provider-config.ts index 115ec80053b..cd384f91424 100644 --- a/packages/server/src/server/agent/providers/omp/provider-config.ts +++ b/packages/server/src/server/agent/providers/omp/provider-config.ts @@ -39,6 +39,11 @@ export function resolveOmpLaunchMode( switch (modeId ?? DEFAULT_OMP_MODE_ID) { case "full": return { modeId: "full", extraArgs: ["--approval-mode", "yolo", ...modelRoleArgs] }; + case "write": + return { + modeId: "write", + extraArgs: ["--approval-mode", "write", ...modelRoleArgs], + }; case "ask": return { modeId: "ask", diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts index f728c1da74b..720a62182aa 100644 --- a/packages/server/src/server/daemon-e2e/agent-configs.ts +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -62,6 +62,7 @@ export const agentConfigs = { thinkingOptionId: "medium", modes: { full: "full", // launches omp with yolo approval mode + write: "write", // launches omp with write approval mode ask: "ask", // launches omp with always-ask approval mode }, }, From e699f07a17dad19b8e24ae6956588089ffd2edef Mon Sep 17 00:00:00 2001 From: Byeonghoon Yoo Date: Thu, 23 Jul 2026 05:33:04 +0900 Subject: [PATCH 021/420] fix(omp): complete delayed model turns after local-only results (#2282) Wait briefly for extension-queued model turns before completing local-only OMP prompts, and preserve autonomous turn completion. --- .../server/agent/providers/omp/agent.test.ts | 112 ++++++++++++++- .../src/server/agent/providers/omp/agent.ts | 92 ++++++++++-- .../providers/omp/test-utils/omp-harness.ts | 132 +++++++++++++++++- 3 files changed, 320 insertions(+), 16 deletions(-) diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 69b2d8ff316..fe2fc374927 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import type { PaseoToolCatalog } from "../../tools/types.js"; -import type { OmpProviderIdleScheduler } from "./agent.js"; +import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js"; import { OmpHarness } from "./test-utils/omp-harness.js"; class ManualIdleScheduler implements OmpProviderIdleScheduler { @@ -30,6 +30,41 @@ class ManualIdleScheduler implements OmpProviderIdleScheduler { } } +class ManualNoTurnScheduler implements OmpNoTurnScheduler { + private settleResolve: (() => void) | null = null; + private aborted = false; + + waitForSettle(signal: AbortSignal): Promise { + if (signal.aborted) { + this.aborted = true; + return Promise.resolve(); + } + return new Promise((resolve) => { + this.settleResolve = resolve; + signal.addEventListener( + "abort", + () => { + this.aborted = true; + this.settleResolve = null; + resolve(); + }, + { once: true }, + ); + }); + } + + settle(): void { + const resolve = this.settleResolve; + if (!resolve) throw new Error("OMP has not requested a no-turn settle wait"); + this.settleResolve = null; + resolve(); + } + + wasAborted(): boolean { + return this.aborted; + } +} + function createToolCatalog(): PaseoToolCatalog { return { tools: new Map([ @@ -235,6 +270,81 @@ describe("OMP agent client and session", () => { expect(omp.completedTurnCount()).toBe(1); }); + test("completes a local-only prompt when no OMP turn begins", async () => { + const omp = new OmpHarness(); + await omp.start(); + + await expect(omp.runPromptWithoutTurn("/model")).resolves.toMatchObject({ finalText: "" }); + expect(omp.completedTurnCount()).toBe(1); + }); + + test("waits for a delayed queued model turn after OMP's local-only result", async () => { + const omp = new OmpHarness(); + await omp.start(); + + const completion = await omp.runPromptAfterDelayedFalseLocalOnlyResult( + "hello OMP", + "delayed queued model turn completed", + ); + + expect(completion.completedBeforeTurn).toBe(false); + expect(completion.result).toMatchObject({ finalText: "delayed queued model turn completed" }); + expect(omp.completedTurnCount()).toBe(1); + }); + + test("completes an async local-only result after the settle window", async () => { + const scheduler = new ManualNoTurnScheduler(); + const omp = new OmpHarness({ noTurnScheduler: scheduler }); + await omp.start(); + const prompt = await omp.startPromptWithFalseLocalOnlyResult("local-only"); + + expect(prompt.completed()).toBe(false); + scheduler.settle(); + await expect(prompt.completion).resolves.toMatchObject({ finalText: "" }); + expect(omp.completedTurnCount()).toBe(1); + }); + + test("cancels an async local-only settle when the OMP session closes", async () => { + const scheduler = new ManualNoTurnScheduler(); + const omp = new OmpHarness({ noTurnScheduler: scheduler }); + await omp.start(); + const prompt = await omp.startPromptWithFalseLocalOnlyResult("local-only"); + + await omp.close(); + + expect(scheduler.wasAborted()).toBe(true); + expect(prompt.completed()).toBe(false); + expect(omp.completedTurnCount()).toBe(0); + }); + + test("preserves a correlated invoked result over a local-only prompt ack", async () => { + const omp = new OmpHarness(); + await omp.start(); + + const completion = await omp.runPromptAfterCorrelatedTrueResult( + "hello OMP", + "correlated model turn completed", + ); + + expect(completion.completedBeforeTurn).toBe(false); + expect(completion.result).toMatchObject({ finalText: "correlated model turn completed" }); + expect(omp.completedTurnCount()).toBe(1); + }); + + test("completes an autonomous OMP turn without a foreground turn ID", async () => { + const omp = new OmpHarness(); + await omp.start(); + + await omp.runAutonomousTurn("autonomous turn completed"); + + expect(omp.completedTurnCount()).toBe(1); + expect(omp.timeline()).toContainEqual({ + type: "assistant_message", + text: "autonomous turn completed", + messageId: "omp-assistant-1", + }); + }); + test("resumes an OMP session and replays its history", async () => { const omp = new OmpHarness(); await omp.resume( diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 63e9f23d5bb..eec64cee470 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; +import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; import type { Logger } from "pino"; import stripAnsi from "strip-ansi"; @@ -148,12 +149,23 @@ export interface OmpAgentClientOptions { runtime?: OmpRuntime; subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; + noTurnScheduler?: OmpNoTurnScheduler; } export interface OmpProviderIdleScheduler { waitForRetry(): Promise; } +export interface OmpNoTurnScheduler { + waitForSettle(signal: AbortSignal): Promise; +} + +// COMPAT(ompDelayedLocalOnlyResult): OMP 17.0.5 can report a regular prompt as +// local-only shortly before an extension-queued model turn starts. Added in +// v0.2.0-beta.1; remove after January 20, 2027 once the minimum OMP version +// guarantees prompt_result waits for queued extension work. +const OMP_NO_TURN_SETTLE_MS = 5_000; + interface OmpPromptPayload { text: string; images?: OmpImageContent[]; @@ -184,6 +196,7 @@ interface OmpAgentSessionOptions { logger: Logger; subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; + noTurnScheduler?: OmpNoTurnScheduler; paseoTools?: PaseoToolCatalog; /** * When false (resumed sessions), replayed session events are dropped until @@ -201,6 +214,14 @@ function createOmpProviderIdleScheduler(): OmpProviderIdleScheduler { }; } +function createOmpNoTurnScheduler(): OmpNoTurnScheduler { + return { + waitForSettle: async (signal) => { + await delay(OMP_NO_TURN_SETTLE_MS, undefined, { signal }); + }, + }; +} + interface OmpResumeConfig { cwd: string; model?: string; @@ -905,7 +926,9 @@ export class OmpAgentSession implements AgentSession { private activeNoTurnPromptText: string | null = null; private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = []; private activePromptRequestId: string | null = null; + private activePromptAgentInvoked: boolean | null = null; private readonly pendingPromptResults = new Map(); + private pendingNoTurnCompletionAbort: AbortController | null = null; private lastKnownThinkingOptionId: string | null; private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; private outOfBandCompactionStarted = false; @@ -917,6 +940,7 @@ export class OmpAgentSession implements AgentSession { private state: OmpSessionState; private readonly currentModeId: string | null; private readonly providerIdleScheduler: OmpProviderIdleScheduler; + private readonly noTurnScheduler: OmpNoTurnScheduler; private closed = false; private live: boolean; private readonly emittedUserMessageIds = new Set(); @@ -930,6 +954,7 @@ export class OmpAgentSession implements AgentSession { this.paseoTools = options.paseoTools; this.live = options.live ?? true; this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler(); + this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler(); this.subagentCardTracker = new OmpSubagentCardTracker({ scheduler: options.subagentCardScheduler, }); @@ -1005,8 +1030,12 @@ export class OmpAgentSession implements AgentSession { if (ack.requestId) { this.pendingPromptResults.delete(ack.requestId); } - const agentInvoked = correlatedResult ?? ack.agentInvoked; - if (agentInvoked === false) { + this.activePromptAgentInvoked = correlatedResult ?? ack.agentInvoked ?? null; + if (correlatedResult === false) { + this.scheduleNoTurnPromptCompletion(turnId); + return; + } + if (correlatedResult !== true && ack.agentInvoked === false) { await this.completeNoTurnPrompt(turnId); return; } @@ -1183,6 +1212,7 @@ export class OmpAgentSession implements AgentSession { return; } this.closed = true; + this.cancelNoTurnPromptCompletion(); try { await this.runtimeSession.close(); } finally { @@ -1309,11 +1339,40 @@ export class OmpAgentSession implements AgentSession { return this.activeTurnId ?? undefined; } + private scheduleNoTurnPromptCompletion(turnId: string): void { + this.cancelNoTurnPromptCompletion(); + const abort = new AbortController(); + this.pendingNoTurnCompletionAbort = abort; + void this.noTurnScheduler + .waitForSettle(abort.signal) + .then(async () => { + if (this.pendingNoTurnCompletionAbort !== abort) { + return undefined; + } + this.pendingNoTurnCompletionAbort = null; + return await this.completeNoTurnPrompt(turnId); + }) + .catch((error: unknown) => { + if (!abort.signal.aborted) { + this.logger.debug({ err: error }, "OMP local-only settle wait failed"); + } + }); + } + + private cancelNoTurnPromptCompletion(): void { + this.pendingNoTurnCompletionAbort?.abort(); + this.pendingNoTurnCompletionAbort = null; + } + private async completeNoTurnPrompt(turnId: string): Promise { - await new Promise((resolve) => { - setImmediate(resolve); - }); - if (this.activeTurnId !== turnId || this.activeTurnStarted || this.activeTurnHasUserMessage) { + await waitForImmediate(); + if ( + this.closed || + this.activeTurnId !== turnId || + this.activeTurnStarted || + this.activePromptAgentInvoked === true || + this.activeTurnHasUserMessage + ) { return; } this.emitBufferedNoTurnOutputs(turnId); @@ -1321,8 +1380,10 @@ export class OmpAgentSession implements AgentSession { } private clearNoTurnBuffers(): void { + this.cancelNoTurnPromptCompletion(); this.activeNoTurnPromptText = null; this.activePromptRequestId = null; + this.activePromptAgentInvoked = null; this.pendingNoTurnOutputs.splice(0, this.pendingNoTurnOutputs.length); } @@ -1736,12 +1797,13 @@ export class OmpAgentSession implements AgentSession { ? event.agentInvoked : undefined; if (requestId && agentInvoked !== undefined) { - if ( - requestId === this.activePromptRequestId && - agentInvoked === false && - this.activeTurnId - ) { - void this.completeNoTurnPrompt(this.activeTurnId); + if (requestId === this.activePromptRequestId && this.activeTurnId) { + this.activePromptAgentInvoked = agentInvoked; + if (agentInvoked === false) { + this.scheduleNoTurnPromptCompletion(this.activeTurnId); + } else { + this.cancelNoTurnPromptCompletion(); + } } else if (this.activePromptRequestId === null) { this.pendingPromptResults.set(requestId, agentInvoked); } @@ -2139,7 +2201,7 @@ export class OmpAgentSession implements AgentSession { turnId: string | undefined, messages: OmpAgentMessage[], ): Promise { - while (!this.closed && this.activeTurnId === turnId) { + while (!this.closed && this.activeTurnStarted && this.currentTurnIdForEvent() === turnId) { try { const state = await this.runtimeSession.getState(); this.state = state; @@ -2188,6 +2250,7 @@ export class OmpAgentClient implements AgentClient { private readonly modelRoleParams: OmpModelRoleParams; private readonly subagentCardScheduler?: OmpSubagentCardScheduler; private readonly providerIdleScheduler?: OmpProviderIdleScheduler; + private readonly noTurnScheduler?: OmpNoTurnScheduler; private readonly runtime: OmpRuntime; constructor(options: OmpAgentClientOptions) { @@ -2209,6 +2272,7 @@ export class OmpAgentClient implements AgentClient { this.modelRoleParams = modelRoleParams; this.subagentCardScheduler = options.subagentCardScheduler; this.providerIdleScheduler = options.providerIdleScheduler; + this.noTurnScheduler = options.noTurnScheduler; this.runtime = options.runtime ?? createRuntime(options.logger, runtimeSettings); } @@ -2249,6 +2313,7 @@ export class OmpAgentClient implements AgentClient { logger: this.logger, subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, + noTurnScheduler: this.noTurnScheduler, paseoTools: launchContext?.paseoTools, }); } catch (error) { @@ -2289,6 +2354,7 @@ export class OmpAgentClient implements AgentClient { logger: this.logger, subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, + noTurnScheduler: this.noTurnScheduler, paseoTools: launchContext?.paseoTools, live: false, }); diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index a2fda872e2d..c7e3c905e9c 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -1,6 +1,7 @@ import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { setImmediate as waitForImmediate } from "node:timers/promises"; import pino from "pino"; import type { @@ -11,7 +12,12 @@ import type { AgentTimelineItem, } from "../../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../../tools/types.js"; -import { OmpAgentClient, OmpAgentSession, type OmpProviderIdleScheduler } from "../agent.js"; +import { + OmpAgentClient, + OmpAgentSession, + type OmpNoTurnScheduler, + type OmpProviderIdleScheduler, +} from "../agent.js"; import type { OmpAgentMessage, OmpRpcSlashCommand } from "../rpc-types.js"; import { FakeOmp } from "./fake-omp.js"; @@ -59,11 +65,17 @@ export class OmpHarness { private readonly events: AgentStreamEvent[] = []; private session: OmpAgentSession | null = null; - constructor(options: { providerIdleScheduler?: OmpProviderIdleScheduler } = {}) { + constructor( + options: { + providerIdleScheduler?: OmpProviderIdleScheduler; + noTurnScheduler?: OmpNoTurnScheduler; + } = {}, + ) { this.client = new OmpAgentClient({ logger: pino({ level: "silent" }), runtime: this.omp, providerIdleScheduler: options.providerIdleScheduler, + noTurnScheduler: options.noTurnScheduler, }); } @@ -251,6 +263,121 @@ export class OmpHarness { return await run; } + async runPromptWithoutTurn(input: string): Promise { + const session = this.requireSession(); + this.omp.latestSession().promptAck = { agentInvoked: false }; + return await session.run(input); + } + + async startPromptWithFalseLocalOnlyResult( + input: string, + ): Promise<{ completed: () => boolean; completion: Promise }> { + const session = this.requireSession(); + const runtime = this.omp.latestSession(); + runtime.promptAck = { requestId: "prompt-local-only" }; + const promptStarted = runtime.nextPrompt(); + const completion = session.run(input); + let isCompleted = false; + void completion.then( + () => { + isCompleted = true; + return undefined; + }, + () => { + isCompleted = true; + return undefined; + }, + ); + await promptStarted; + await waitForImmediate(); + runtime.emit({ + type: "prompt_result", + id: "prompt-local-only", + agentInvoked: false, + }); + return { completed: () => isCompleted, completion }; + } + + async runPromptAfterCorrelatedTrueResult( + input: string, + output: string, + ): Promise<{ completedBeforeTurn: boolean; result: unknown }> { + const session = this.requireSession(); + const runtime = this.omp.latestSession(); + runtime.promptAck = { requestId: "prompt-invoked", agentInvoked: false }; + const promptStarted = runtime.nextPrompt(); + const run = session.run(input); + let completed = false; + void run.then( + () => { + completed = true; + return undefined; + }, + () => { + completed = true; + return undefined; + }, + ); + await promptStarted; + runtime.emit({ + type: "prompt_result", + id: "prompt-invoked", + agentInvoked: true, + }); + await waitForImmediate(); + await waitForImmediate(); + const completedBeforeTurn = completed; + runtime.acceptPrompt(input, "user-1"); + runtime.beginTurn(); + runtime.streamAssistantText(output); + runtime.finishTurn(); + return { completedBeforeTurn, result: await run }; + } + + async runPromptAfterDelayedFalseLocalOnlyResult( + input: string, + output: string, + ): Promise<{ completedBeforeTurn: boolean; result: unknown }> { + const session = this.requireSession(); + const runtime = this.omp.latestSession(); + runtime.promptAck = { requestId: "prompt-1" }; + const promptStarted = runtime.nextPrompt(); + const run = session.run(input); + let completed = false; + void run.then( + () => { + completed = true; + return undefined; + }, + () => { + completed = true; + return undefined; + }, + ); + await promptStarted; + await waitForImmediate(); + runtime.emit({ + type: "prompt_result", + id: "prompt-1", + agentInvoked: false, + }); + await waitForImmediate(); + const completedBeforeTurn = completed; + runtime.acceptPrompt(input, "user-1"); + runtime.beginTurn(); + runtime.streamAssistantText(output); + runtime.finishTurn(); + return { completedBeforeTurn, result: await run }; + } + + async runAutonomousTurn(output: string): Promise { + const runtime = this.omp.latestSession(); + runtime.beginTurn(); + runtime.streamAssistantText(output); + runtime.finishTurn(); + await waitForImmediate(); + } + timeline(): AgentTimelineItem[] { return this.events.flatMap((event) => (event.type === "timeline" ? [event.item] : [])); } @@ -357,6 +484,7 @@ export class OmpHarness { async close(): Promise { await this.requireSession().close(); + await waitForImmediate(); } isClosed(): boolean { From 8b54d358183ec75cde03d48f548a96ee3ca5fbaf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 22:13:03 +0200 Subject: [PATCH 022/420] fix(app): align Changes tab controls --- packages/app/src/git/diff-pane.tsx | 2 +- packages/app/src/panels/diff-panel.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 5be0ddd5591..aff20dd89a2 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1613,7 +1613,7 @@ export function DiffOptionsMenu({ onToggleWrapLines, }: DiffOptionsMenuProps) { const { t } = useTranslation(); - const defaultToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); + const defaultToggleStyle = useMemo(() => buildOverflowButtonStyle(), []); const whitespaceLabel = hideWhitespace ? t("workspace.git.diff.showWhitespace") : t("workspace.git.diff.hideWhitespace"); diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx index 65cc1b4c260..df14c3cb99d 100644 --- a/packages/app/src/panels/diff-panel.tsx +++ b/packages/app/src/panels/diff-panel.tsx @@ -374,7 +374,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], + paddingRight: theme.spacing[2], borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, flexShrink: 0, @@ -383,7 +383,7 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", - gap: theme.spacing[2], + gap: theme.spacing[1], }, body: { flex: 1, From 780c6513f1597854cde776fdf081fa66991f15cf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 22:46:17 +0200 Subject: [PATCH 023/420] chore(acp): refresh provider catalog versions --- packages/app/src/data/acp-provider-catalog.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 32be1e16f58..f97580c3d2f 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -150,10 +150,10 @@ const CATALOG_DATA = [ title: "Dirac", description: "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", - version: "0.4.21", + version: "0.4.22", iconId: "dirac", installLink: "https://dirac.run", - command: ["npx", "-y", "dirac-cli@0.4.21", "--acp"], + command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"], }, { id: "factory-droid", @@ -173,10 +173,10 @@ const CATALOG_DATA = [ id: "fast-agent", title: "fast-agent", description: "Code and build agents with comprehensive multi-provider support", - version: "0.9.20", + version: "0.9.21", iconId: "fast-agent", installLink: "https://fast-agent.ai/acp/", - command: ["uvx", "--from", "fast-agent-acp==0.9.20", "fast-agent-acp", "-x"], + command: ["uvx", "--from", "fast-agent-acp==0.9.21", "fast-agent-acp", "-x"], }, { id: "gemini", @@ -302,10 +302,10 @@ const CATALOG_DATA = [ id: "qoder", title: "Qoder CLI", description: "AI coding assistant with agentic capabilities", - version: "1.1.2", + version: "1.1.3", iconId: "qoder", installLink: "https://qoder.com", - command: ["npx", "-y", "@qoder-ai/qodercli@1.1.2", "--acp"], + command: ["npx", "-y", "@qoder-ai/qodercli@1.1.3", "--acp"], }, { id: "qwen-code", From dd8a111c30dcc6457a7c4eddaddc5346967fa57f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 22:46:24 +0200 Subject: [PATCH 024/420] docs: add 0.2.0-beta.3 changelog --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f91f3f10603..ce069f4665a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.2.0-beta.3 - 2026-07-22 + +### Added + +- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs)) +- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs)) +- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324)) +- Use Oh My Pi's Write Approval mode to allow reads while requiring approval for changes ([#2228](https://github.com/getpaseo/paseo/pull/2228) by [@theslava](https://github.com/theslava)) + +### Improved + +- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter)) +- Oh My Pi advisor results now retain their structure and severity ([#2219](https://github.com/getpaseo/paseo/pull/2219) by [@ebg1223](https://github.com/ebg1223)) + +### Fixed + +- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331)) +- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316)) +- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315)) +- Oh My Pi slash commands now include commands from every supported source ([#2175](https://github.com/getpaseo/paseo/pull/2175) by [@bendavid](https://github.com/bendavid)) +- Oh My Pi chats no longer stay stuck as running after delayed or incomplete completion events ([#2261](https://github.com/getpaseo/paseo/pull/2261), [#2282](https://github.com/getpaseo/paseo/pull/2282) by [@isac322](https://github.com/isac322)) + ## 0.2.0-beta.2 - 2026-07-22 ### Added From 8a1243e8d39800a5f759eea91f973fab2db37b62 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 23:26:47 +0200 Subject: [PATCH 025/420] chore(release): cut 0.2.0-beta.3 --- package-lock.json | 42 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++--- packages/client/package.json | 6 ++-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 +++--- packages/website/package.json | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1062baf7345..9b8d7885428 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35211,7 +35211,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36236,12 +36236,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.0-beta.2", - "@getpaseo/protocol": "0.2.0-beta.2", - "@getpaseo/server": "0.2.0-beta.2", + "@getpaseo/client": "0.2.0-beta.3", + "@getpaseo/protocol": "0.2.0-beta.3", + "@getpaseo/server": "0.2.0-beta.3", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36487,10 +36487,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "dependencies": { - "@getpaseo/protocol": "0.2.0-beta.2", - "@getpaseo/relay": "0.2.0-beta.2", + "@getpaseo/protocol": "0.2.0-beta.3", + "@getpaseo/relay": "0.2.0-beta.3", "zod": "^4.4.3" }, "devDependencies": { @@ -36501,7 +36501,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36744,7 +36744,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37640,7 +37640,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -37872,7 +37872,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "dependencies": { "zod": "^4.4.3" }, @@ -37885,7 +37885,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38103,15 +38103,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.0-beta.2", - "@getpaseo/highlight": "0.2.0-beta.2", - "@getpaseo/protocol": "0.2.0-beta.2", - "@getpaseo/relay": "0.2.0-beta.2", + "@getpaseo/client": "0.2.0-beta.3", + "@getpaseo/highlight": "0.2.0-beta.3", + "@getpaseo/protocol": "0.2.0-beta.3", + "@getpaseo/relay": "0.2.0-beta.3", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38648,7 +38648,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 9c2fc3e38fe..854951506fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 46cbf4b25a0..d360576fe8e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 5e896ceb6c0..67dbf7177b9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -27,9 +27,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.0-beta.2", - "@getpaseo/protocol": "0.2.0-beta.2", - "@getpaseo/server": "0.2.0-beta.2", + "@getpaseo/client": "0.2.0-beta.3", + "@getpaseo/protocol": "0.2.0-beta.3", + "@getpaseo/server": "0.2.0-beta.3", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 4a18c9c3b5a..4dda8d619c0 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "description": "Paseo client SDK package", "files": [ "dist", @@ -35,8 +35,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.2.0-beta.2", - "@getpaseo/relay": "0.2.0-beta.2", + "@getpaseo/protocol": "0.2.0-beta.3", + "@getpaseo/relay": "0.2.0-beta.3", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 282e40a39ab..a8c7ab925d2 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index c52bbf447b3..6bf7a5327a6 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 72a99785ca8..87576d2cdc8 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 09cde68fa7b..60d8eb2d2a5 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 38dfb5199e3..5adfb4d6302 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 2e9a1eabc63..1b5ef131716 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "description": "Paseo backend server", "files": [ "dist/server", @@ -67,10 +67,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.0-beta.2", - "@getpaseo/highlight": "0.2.0-beta.2", - "@getpaseo/protocol": "0.2.0-beta.2", - "@getpaseo/relay": "0.2.0-beta.2", + "@getpaseo/client": "0.2.0-beta.3", + "@getpaseo/highlight": "0.2.0-beta.3", + "@getpaseo/protocol": "0.2.0-beta.3", + "@getpaseo/relay": "0.2.0-beta.3", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 8fc91ef32f4..30a55513ce1 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.2.0-beta.2", + "version": "0.2.0-beta.3", "private": true, "type": "module", "scripts": { From d1f19a5cdd3bfa36b218269669813cc7ef517686 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:34:34 +0000 Subject: [PATCH 026/420] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 83cf1ee765b..8315118765d 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-Ys/R5+0O3SDYeCSd7hatTuCwV9S0kIO8vhmPKGZsvn0= +sha256-x6jiaw7zkCuBRlddLHD0tXQxolXwr7tzIQBfWu5xnUU= From 31c8dc3f05a9edb2908a47a6c64a244b9abc126a Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Thu, 23 Jul 2026 01:01:14 -0700 Subject: [PATCH 027/420] Keep completed OpenCode turns idle (#2336) Ignore post-turn metadata updates for user messages already emitted so completed OpenCode turns remain idle. --- .../agent/providers/opencode-agent.test.ts | 59 +++++++++++++++++++ .../server/agent/providers/opencode-agent.ts | 3 + 2 files changed, 62 insertions(+) diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index e7ac1e858d8..b9af8193c71 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -2728,6 +2728,65 @@ describe("OpenCode provider subagent contract", () => { await parent.close(); } }); + + test("does not start a new autonomous turn when post-turn user message updates arrive for an already emitted message", async () => { + const { parent, openCode } = await createParentSession("ses_parent_post_turn"); + openCode.sessionPromptAsyncEvents = [ + ...userMessageEvents({ + sessionId: "ses_parent_post_turn", + messageId: "msg_user_1", + text: "Hello OpenCode", + }), + ...assistantTurnEvents({ + sessionId: "ses_parent_post_turn", + text: "Response from OpenCode", + }), + ]; + const events: AgentStreamEvent[] = []; + const streamDrained = createTestDeferred(); + parent.subscribe((event) => { + events.push(event); + if (event.type === "provider_subagent") { + streamDrained.resolve(); + } + }); + + try { + await parent.startTurn("Hello OpenCode"); + + await vi.waitFor(() => { + expect(events).toContainEqual(expect.objectContaining({ type: "turn_completed" })); + }); + + // Post-turn message update for the same user message ID that already completed + openCode.emitEvent({ + type: "message.updated", + properties: { + info: { id: "msg_user_1", sessionID: "ses_parent_post_turn", role: "user" }, + }, + }); + + openCode.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_drain_marker", + parentID: "ses_parent_post_turn", + title: "Stream drain marker", + directory: "/workspace/repo", + }, + }, + }); + + await streamDrained.promise; + + // Verify that no new turn_started was emitted after turn_completed + const turnStartedCount = events.filter((e) => e.type === "turn_started").length; + expect(turnStartedCount).toBe(1); + } finally { + await parent.close(); + } + }); test("does not adopt late output from an interrupted Paseo turn", async () => { const { parent, openCode } = await createParentSession("ses_parent_interrupted"); openCode.sessionPromptAsyncEvents = []; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 3ed1dff4299..850cd0b4e2e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -3662,6 +3662,9 @@ class OpenCodeAgentSession implements AgentSession { // busy. That message is the earliest unambiguous boundary for a plugin- // initiated parent turn; session metadata and assistant echoes are not. if (event.type === "message.updated" && event.properties.info.role === "user") { + if (this.emittedUserMessageIds.has(event.properties.info.id)) { + return false; + } return true; } if (!this.externallyDriven) { From 8cf70d10bf438c5f1fb032b7028bba5949ec07a7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 12:17:48 +0200 Subject: [PATCH 028/420] Show workspace commits clearly in Changes (#2350) * feat(commits): distinguish workspace history from base commits Keep every workspace commit visible while bounding base history to ten context commits, and make push and base state readable in the commit rail. * fix(commits): preserve history when base refs disappear Fall back to recent HEAD history for stale saved bases, and reject truncated git output instead of presenting an incomplete commit list. * fix(commits): classify history against the current base Use the furthest-ahead local or remote base for classification, and re-infer base history when saved worktree metadata points to a deleted branch. --- .../git/commits-section/commit-graph-node.tsx | 79 +++++++++++ .../src/git/commits-section/commit-row.tsx | 55 ++++++-- .../git/commits-section/commits-section.tsx | 16 ++- .../app/src/git/commits-section/shared.ts | 37 ----- packages/app/src/git/use-commits-query.ts | 21 ++- 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 +- .../src/messages.checkout-commits.test.ts | 34 ++++- packages/protocol/src/messages.ts | 4 + .../src/server/daemon-client.e2e.test.ts | 1 + .../server/src/server/websocket-server.ts | 2 + .../src/utils/checkout-git.commits.test.ts | 90 ++++++++++++- packages/server/src/utils/checkout-git.ts | 127 ++++++++++++------ 19 files changed, 372 insertions(+), 110 deletions(-) create mode 100644 packages/app/src/git/commits-section/commit-graph-node.tsx delete mode 100644 packages/app/src/git/commits-section/shared.ts diff --git a/packages/app/src/git/commits-section/commit-graph-node.tsx b/packages/app/src/git/commits-section/commit-graph-node.tsx new file mode 100644 index 00000000000..f6ac03d16e4 --- /dev/null +++ b/packages/app/src/git/commits-section/commit-graph-node.tsx @@ -0,0 +1,79 @@ +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query"; + +interface CommitGraphNodeProps { + commit: ClassifiedCheckoutCommit; + isFirst: boolean; + isLast: boolean; +} + +export function CommitGraphNode({ commit, isFirst, isLast }: CommitGraphNodeProps) { + const isOnBase = commit.isOnBase; + const railColor = isOnBase ? styles.railBase : styles.railWorkspace; + const markerColor = isOnBase ? styles.markerBase : styles.markerWorkspace; + + return ( + + {isFirst && isLast ? null : ( + + )} + + + ); +} + +const MARKER_SIZE = 8; +const RAIL_WIDTH = 2; + +const styles = StyleSheet.create((theme) => ({ + container: { + width: MARKER_SIZE, + alignSelf: "stretch", + alignItems: "center", + justifyContent: "center", + position: "relative", + flexShrink: 0, + }, + rail: { + position: "absolute", + top: -theme.spacing[1] - 1, + bottom: -theme.spacing[1] - 1, + width: RAIL_WIDTH, + }, + railFirst: { + top: "50%", + }, + railLast: { + bottom: "50%", + }, + railBase: { + backgroundColor: theme.colors.foregroundMuted, + }, + railWorkspace: { + backgroundColor: theme.colors.accent, + }, + marker: { + width: MARKER_SIZE, + height: MARKER_SIZE, + borderRadius: theme.borderRadius.full, + borderWidth: theme.borderWidth[2], + zIndex: 1, + }, + markerBase: { + backgroundColor: theme.colors.foregroundMuted, + borderColor: theme.colors.foregroundMuted, + }, + markerWorkspace: { + backgroundColor: theme.colors.accent, + borderColor: theme.colors.accent, + }, + markerRing: { + backgroundColor: theme.colors.surface0, + }, +})); diff --git a/packages/app/src/git/commits-section/commit-row.tsx b/packages/app/src/git/commits-section/commit-row.tsx index b5cdb9213db..4da725c9166 100644 --- a/packages/app/src/git/commits-section/commit-row.tsx +++ b/packages/app/src/git/commits-section/commit-row.tsx @@ -1,18 +1,33 @@ import { memo, useCallback } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import type { CheckoutCommit } from "@getpaseo/protocol/messages"; import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron"; +import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query"; import { formatTimeAgo } from "@/utils/time"; -import { dotStyles } from "./shared"; +import { CommitGraphNode } from "./commit-graph-node"; interface CommitRowProps { - commit: CheckoutCommit; + commit: ClassifiedCheckoutCommit; + isFirst: boolean; + isLast: boolean; now: Date; onCommitPress: (sha: string) => void; } -export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: CommitRowProps) { +function commitRowPressableStyle({ + hovered, + pressed, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.row, (Boolean(hovered) || pressed) && styles.rowActive]; +} + +export const CommitRow = memo(function CommitRow({ + commit, + isFirst, + isLast, + now, + onCommitPress, +}: CommitRowProps) { const handlePress = useCallback(() => { onCommitPress(commit.sha); }, [commit.sha, onCommitPress]); @@ -22,16 +37,17 @@ export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: accessibilityRole="button" testID={`commit-row-${commit.shortSha}`} onPress={handlePress} - style={styles.row} + style={commitRowPressableStyle} > - - {commit.shortSha} - - {commit.subject} - + + + + {commit.shortSha} + + + {commit.subject} + + {formatTimeAgo(new Date(commit.authorDate), now)} @@ -49,10 +65,21 @@ const styles = StyleSheet.create((theme) => ({ paddingRight: theme.spacing[2], paddingVertical: theme.spacing[1], }, + rowActive: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + commitDetails: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, shortSha: { fontSize: theme.fontSize.xs, fontFamily: theme.fontFamily.mono, color: theme.colors.foregroundMuted, + width: theme.spacing[16], flexShrink: 0, }, subject: { diff --git a/packages/app/src/git/commits-section/commits-section.tsx b/packages/app/src/git/commits-section/commits-section.tsx index 97107cfb806..931d95e29ea 100644 --- a/packages/app/src/git/commits-section/commits-section.tsx +++ b/packages/app/src/git/commits-section/commits-section.tsx @@ -67,8 +67,15 @@ function CommitsSectionContent({ } return ( - {query.data.commits.map((commit) => ( - + {query.data.commits.map((commit, index) => ( + ))} ); @@ -110,7 +117,10 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP if (query.status === "unsupported") { return null; } - const commitCount = query.status === "loaded" ? query.data.commits.length : null; + const commitCount = + query.status === "loaded" + ? query.data.commits.filter((commit) => !commit.isOnBase).length + : null; return ( diff --git a/packages/app/src/git/commits-section/shared.ts b/packages/app/src/git/commits-section/shared.ts deleted file mode 100644 index e193cefbc43..00000000000 --- a/packages/app/src/git/commits-section/shared.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { StyleSheet } from "react-native-unistyles"; -import type { CheckoutCommit } from "@getpaseo/protocol/messages"; - -export type CheckoutCommitFile = CheckoutCommit["files"][number]; - -export type FilePressHandler = (commit: CheckoutCommit, file: CheckoutCommitFile) => void; - -export const DOT_SIZE = 8; - -// The "on remote" dot is intentionally understated: the local-only ring is the -// state worth noticing (you still have work to push), so the remote fill is -// dimmed toward the background rather than rendered at full-strength green. -const REMOTE_DOT_OPACITY = 0.55; - -/** - * A local-only commit is a hollow ring; a commit that has reached the remote - * is a subtle (dimmed) filled green dot. - */ -export const dotStyles = StyleSheet.create((theme) => ({ - dotLocal: { - width: DOT_SIZE, - height: DOT_SIZE, - borderRadius: theme.borderRadius.full, - backgroundColor: "transparent", - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.foregroundMuted, - flexShrink: 0, - }, - dotRemote: { - width: DOT_SIZE, - height: DOT_SIZE, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.statusSuccess, - opacity: REMOTE_DOT_OPACITY, - flexShrink: 0, - }, -})); diff --git a/packages/app/src/git/use-commits-query.ts b/packages/app/src/git/use-commits-query.ts index 19b24ba80e1..a1c5edbff02 100644 --- a/packages/app/src/git/use-commits-query.ts +++ b/packages/app/src/git/use-commits-query.ts @@ -1,4 +1,5 @@ import type { CheckoutCommit } from "@getpaseo/protocol/messages"; +import invariant from "tiny-invariant"; import { useFetchQuery } from "@/data/query"; import { checkoutCommitsQueryKey } from "@/git/query-keys"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; @@ -16,7 +17,11 @@ interface UseCheckoutCommitsQueryOptions { export interface CheckoutCommitsData { baseRef: string | null; - commits: CheckoutCommit[]; + commits: ClassifiedCheckoutCommit[]; +} + +export interface ClassifiedCheckoutCommit extends CheckoutCommit { + isOnBase: boolean; } export type CheckoutCommitsQueryResult = @@ -69,10 +74,13 @@ export function useCheckoutCommitsQuery({ }: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult { const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. + // COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16. + // COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23. // Single capability-detection site; downstream reads a clean load-state union. const capabilityPresent = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.commitsList === true, + (state) => + state.sessions[serverId]?.serverInfo?.features?.commitsList === true && + state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true, ); const canFetch = Boolean(cwd) && Boolean(client) && isConnected; @@ -84,7 +92,12 @@ export function useCheckoutCommitsQuery({ if (!client) { throw new Error("Host disconnected"); } - return client.listCheckoutCommits(cwd); + const data = await client.listCheckoutCommits(cwd); + const commits = data.commits.map((commit) => { + invariant(commit.isOnBase !== undefined, "Host omitted commit base classification"); + return { ...commit, isOnBase: commit.isOnBase }; + }); + return { baseRef: data.baseRef, commits }; }, enabled: queryEnabled, staleTimeMs: CHECKOUT_COMMITS_STALE_TIME, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 6f6518733fc..44209253071 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -795,7 +795,7 @@ export const ar: TranslationResources = { deletedFile: "تم الحذف", commits: { title: "الإيداعات", - countLabel: "{{count}} من الإيداعات الأخيرة", + countLabel: "{{count}} من إيداعات مساحة العمل", fileDiffEmpty: "لا توجد تغييرات لعرضها", fileDiffError: "تعذّر تحميل فروق الملف", loading: "جارٍ تحميل الإيداعات…", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index bc8bba54617..36440715da3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -805,7 +805,7 @@ export const en = { deletedFile: "Deleted", commits: { title: "Commits", - countLabel: "{{count}} recent commits", + countLabel: "{{count}} workspace commits", fileDiffEmpty: "No changes to display", fileDiffError: "Failed to load file diff", loading: "Loading commits…", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 204273c96c1..9558f1d655b 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -826,7 +826,7 @@ export const es: TranslationResources = { deletedFile: "Eliminado", commits: { title: "Commits", - countLabel: "{{count}} commits recientes", + countLabel: "{{count}} commits del espacio de trabajo", fileDiffEmpty: "No hay cambios para mostrar", fileDiffError: "Error al cargar el diff del archivo", loading: "Cargando commits…", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 07dd95a2ad4..e46900e01c3 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -825,7 +825,7 @@ export const fr: TranslationResources = { deletedFile: "Supprimé", commits: { title: "Commits", - countLabel: "{{count}} commits récents", + countLabel: "{{count}} commits de l’espace de travail", fileDiffEmpty: "Aucune modification à afficher", fileDiffError: "Échec du chargement du diff du fichier", loading: "Chargement des commits…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 5a27447dc93..e0e1771f3b2 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -806,7 +806,7 @@ export const ja: TranslationResources = { deletedFile: "削除済み", commits: { title: "コミット", - countLabel: "最近のコミット数: {{count}}", + countLabel: "ワークスペースのコミット数: {{count}}", fileDiffEmpty: "表示する変更はありません", fileDiffError: "ファイル差分の読み込みに失敗しました", loading: "コミットを読み込み中…", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2fce56ddd1e..7c1b2d9236c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -817,7 +817,7 @@ export const ptBR: TranslationResources = { deletedFile: "Excluído", commits: { title: "Commits", - countLabel: "{{count}} commits recentes", + countLabel: "{{count}} commits do espaço de trabalho", fileDiffEmpty: "Nenhuma alteração para exibir", fileDiffError: "Falha ao carregar diff do arquivo", loading: "Carregando commits…", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 51e83e28d44..aa1e391f1fa 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -817,7 +817,7 @@ export const ru: TranslationResources = { deletedFile: "Удалено", commits: { title: "Коммиты", - countLabel: "{{count}} последних коммитов", + countLabel: "{{count}} коммитов рабочего пространства", fileDiffEmpty: "Нет изменений для отображения", fileDiffError: "Не удалось загрузить различия файла", loading: "Загрузка коммитов…", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index b9ddd3cc76e..27e4b046d36 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -787,7 +787,7 @@ export const zhCN: TranslationResources = { deletedFile: "已删除", commits: { title: "提交", - countLabel: "最近 {{count}} 个提交", + countLabel: "{{count}} 个工作区提交", fileDiffEmpty: "没有可显示的更改", fileDiffError: "加载文件差异失败", loading: "正在加载提交…", diff --git a/packages/protocol/src/messages.checkout-commits.test.ts b/packages/protocol/src/messages.checkout-commits.test.ts index 290f39450ab..8d1c528ae45 100644 --- a/packages/protocol/src/messages.checkout-commits.test.ts +++ b/packages/protocol/src/messages.checkout-commits.test.ts @@ -35,6 +35,7 @@ describe("checkout.commits.list schemas", () => { authorName: "Ada", authorDate: "2026-06-13T10:00:00.000Z", isOnRemote: true, + isOnBase: false, files: [ { path: "src/a.ts", additions: 10, deletions: 2, status: "modified" }, { path: "src/b.ts", additions: 5, deletions: 0, status: "added" }, @@ -47,6 +48,7 @@ describe("checkout.commits.list schemas", () => { authorName: "Ada", authorDate: "2026-06-13T11:00:00.000Z", isOnRemote: false, + isOnBase: true, files: [{ path: "src/c.ts", additions: 1, deletions: 1 }], }, ], @@ -61,10 +63,37 @@ describe("checkout.commits.list schemas", () => { expect(parsed.payload).toEqual(payload); expect(parsed.payload.commits[0]?.isOnRemote).toBe(true); + expect(parsed.payload.commits[0]?.isOnBase).toBe(false); expect(parsed.payload.commits[1]?.isOnRemote).toBe(false); + expect(parsed.payload.commits[1]?.isOnBase).toBe(true); expect(parsed.payload.commits[1]?.files[0]?.status).toBeUndefined(); }); + test("still parses commits from hosts without base classification", () => { + const parsed = CheckoutCommitsListResponseSchema.parse({ + type: "checkout.commits.list.response", + payload: { + cwd: "/tmp/repo", + baseRef: "main", + commits: [ + { + sha: "1111111111111111111111111111111111111111", + shortSha: "1111111", + subject: "Legacy commit", + authorName: "Ada", + authorDate: "2026-06-13T10:00:00.000Z", + isOnRemote: true, + files: [], + }, + ], + error: null, + requestId: "request-commits", + }, + }); + + expect(parsed.payload.commits[0]?.isOnBase).toBeUndefined(); + }); + test("accepts a null baseRef and an error payload", () => { const payload = { cwd: "/tmp/repo", @@ -107,16 +136,17 @@ describe("checkout.commits.list schemas", () => { ).toMatchObject({ type: "checkout.commits.list.response" }); }); - test("accepts the commitsList server_info feature flag", () => { + test("accepts the commit history server_info feature flags", () => { expect( ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "srv_test", features: { commitsList: true, + commitBaseClassification: true, }, }).features, - ).toEqual({ commitsList: true }); + ).toEqual({ commitsList: true, commitBaseClassification: true }); }); test("still parses server_info without the commitsList feature flag", () => { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f6be9128d8a..f2e490766c2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1732,6 +1732,8 @@ const CheckoutCommitSchema = z.object({ authorName: z.string(), authorDate: z.string(), // ISO 8601 isOnRemote: z.boolean(), // false = local-only (unpushed) + // COMPAT(commitBaseClassification): added in v0.2.0, remove optional after 2027-01-23. + isOnBase: z.boolean().optional(), files: z.array(CheckoutCommitFileSchema), }); @@ -2777,6 +2779,8 @@ export const ServerInfoStatusPayloadSchema = z projectCreateDirectory: z.boolean().optional(), // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. commitsList: z.boolean().optional(), + // COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23. + commitBaseClassification: z.boolean().optional(), // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 44c4b51be27..bdc1b269dc8 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1008,6 +1008,7 @@ test("receives server_info on websocket connect", async () => { expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true); expect(serverInfo?.features?.hubRelationship).toBe(true); expect(serverInfo?.features?.commitsList).toBe(true); + expect(serverInfo?.features?.commitBaseClassification).toBe(true); expect(serverInfo?.desktopManaged).toBe(false); expect(serverInfo?.features?.daemonSelfUpdate).toBe(true); expect(serverInfo?.features?.worktreeRestore).toBe(true); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cec656af2a5..35929bb848e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1408,6 +1408,8 @@ export class VoiceAssistantWebSocketServer { projectCreateDirectory: true, // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. commitsList: true, + // COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23. + commitBaseClassification: true, // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: true, // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index 83bb1e7450a..c2d48346b00 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; import { listCheckoutCommits } from "./checkout-git.js"; +import { writePaseoWorktreeMetadata } from "./worktree-metadata.js"; const tempDirs: string[] = []; @@ -73,6 +74,9 @@ describe("listCheckoutCommits", () => { expect(commits[0]?.isOnRemote).toBe(false); expect(commits[1]?.isOnRemote).toBe(true); expect(commits[2]?.isOnRemote).toBe(true); + expect(commits[0]?.isOnBase).toBe(false); + expect(commits[1]?.isOnBase).toBe(false); + expect(commits[2]?.isOnBase).toBe(true); expect(commits[0]?.files).toEqual([ { path: "bar.txt", additions: 1, deletions: 0, status: "added" }, @@ -92,6 +96,26 @@ describe("listCheckoutCommits", () => { const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir }); expect(baseRef).toBeNull(); expect(commits.map((entry) => entry.subject)).toEqual(["initial"]); + expect(commits[0]?.isOnBase).toBe(true); + }); + + it("keeps recent history when the saved base branch no longer exists", async () => { + const { repoDir, tempDir } = initRepoOnMain(); + const worktreesRoot = join(tempDir, "worktrees"); + const worktreeDir = join(worktreesRoot, "repo-hash", "feature"); + mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true }); + git(["worktree", "add", "-b", "feature", worktreeDir], repoDir); + commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work"); + writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" }); + + const { baseRef, commits } = await listCheckoutCommits({ + cwd: worktreeDir, + context: { worktreesRoot }, + }); + + expect(baseRef).toBe("main"); + expect(commits.map((entry) => entry.subject)).toEqual(["Feature work", "initial"]); + expect(commits.map((entry) => entry.isOnBase)).toEqual([false, true]); }); it("marks all commits local-only when there is no remote", async () => { @@ -122,19 +146,77 @@ describe("listCheckoutCommits", () => { ]); }); - it("limits history and unpushed classification to the 20 most recent commits", async () => { + it("keeps local base commits out of workspace history when the local base is ahead", async () => { + const { repoDir, tempDir } = initRepoOnMain(); + addBareRemote(repoDir, tempDir); + git(["push", "-u", "origin", "main"], repoDir); + commitFile(repoDir, "local-base.txt", "base\n", "Local base work"); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "feature.txt", "feature\n", "Feature work"); + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(commits.map(({ subject, isOnBase }) => ({ subject, isOnBase }))).toEqual([ + { subject: "Feature work", isOnBase: false }, + { subject: "Local base work", isOnBase: true }, + { subject: "initial", isOnBase: true }, + ]); + }); + + it("shows every workspace commit followed by at most 10 base commits", async () => { const { repoDir } = initRepoOnMain(); + for (let index = 1; index <= 14; index += 1) { + commitFile(repoDir, "base-history.txt", `${index}\n`, `Base ${index}`); + } + git(["checkout", "-b", "feature"], repoDir); for (let index = 1; index <= 24; index += 1) { - commitFile(repoDir, "history.txt", `${index}\n`, `Commit ${index}`); + commitFile(repoDir, "workspace-history.txt", `${index}\n`, `Workspace ${index}`); } const { commits } = await listCheckoutCommits({ cwd: repoDir }); - expect(commits).toHaveLength(20); + expect(commits).toHaveLength(34); expect(commits.every((entry) => entry.isOnRemote === false)).toBe(true); + expect(commits.slice(0, 24).map((entry) => entry.subject)).toEqual( + Array.from({ length: 24 }, (_, index) => `Workspace ${24 - index}`), + ); + expect(commits.slice(0, 24).every((entry) => entry.isOnBase === false)).toBe(true); + expect(commits.slice(24).map((entry) => entry.subject)).toEqual( + Array.from({ length: 10 }, (_, index) => `Base ${14 - index}`), + ); + expect(commits.slice(24).every((entry) => entry.isOnBase === true)).toBe(true); + }); + + it("starts base context at the fork point when the base branch has advanced", async () => { + const { repoDir } = initRepoOnMain(); + commitFile(repoDir, "shared.txt", "shared\n", "Shared base"); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "feature.txt", "feature\n", "Feature work"); + git(["checkout", "main"], repoDir); + commitFile(repoDir, "newer-base.txt", "newer\n", "Newer base"); + git(["checkout", "feature"], repoDir); + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(commits.map(({ subject, isOnBase }) => ({ subject, isOnBase }))).toEqual([ + { subject: "Feature work", isOnBase: false }, + { subject: "Shared base", isOnBase: true }, + { subject: "initial", isOnBase: true }, + ]); + }); + + it("limits base-branch history to 10 commits", async () => { + const { repoDir } = initRepoOnMain(); + for (let index = 1; index <= 14; index += 1) { + commitFile(repoDir, "history.txt", `${index}\n`, `Commit ${index}`); + } + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + expect(commits.map((entry) => entry.subject)).toEqual( - Array.from({ length: 20 }, (_, index) => `Commit ${24 - index}`), + Array.from({ length: 10 }, (_, index) => `Commit ${14 - index}`), ); + expect(commits.every((entry) => entry.isOnBase === true)).toBe(true); }); it("shows merged branch commits and compares the merge against its first parent", async () => { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index ffac0e5f258..f518adfedee 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1988,8 +1988,9 @@ export async function getCheckoutStatus( }; } -// The explorer is a recent-history view, not a full repository log. -const MAX_CHECKOUT_COMMITS = 20; +// Workspace history stays complete; base history is bounded context until the +// commits list supports paging older base commits. +const CHECKOUT_BASE_COMMIT_LIMIT = 10; // Bytes git emits between fields/records. We split parsed output on these. const COMMIT_FIELD_SEPARATOR = "\x00"; const COMMIT_RECORD_SEPARATOR = "\x1e"; @@ -2009,6 +2010,12 @@ interface ParsedCheckoutCommit { files: CheckoutCommitFile[]; } +interface CheckoutCommitLogInput { + cwd: string; + revision: string; + maxCount?: number; +} + function mapNameStatusLetter(letter: string): CheckoutCommitFileStatus | undefined { switch (letter) { case "A": @@ -2133,14 +2140,11 @@ function parseCheckoutCommitRecords(stdout: string): ParsedCheckoutCommit[] { // Returns commits reachable from HEAD that are not reachable from any remote ref. async function getUnpushedCommitShas(cwd: string, context?: CheckoutContext): Promise> { - const { stdout } = await runGitCommand( - ["rev-list", `--max-count=${MAX_CHECKOUT_COMMITS}`, "HEAD", "--not", "--remotes"], - { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - logger: context?.logger, - }, - ); + const { stdout } = await runGitCommand(["rev-list", "HEAD", "--not", "--remotes"], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); return new Set( stdout .split("\n") @@ -2149,58 +2153,104 @@ async function getUnpushedCommitShas(cwd: string, context?: CheckoutContext): Pr ); } -/** - * Lists the current branch's 20 most recent commits, newest first, each flagged - * local-only vs on-remote with per-commit file +/- stats. - */ +async function getCheckoutCommitRecords({ + cwd, + revision, + maxCount, +}: CheckoutCommitLogInput): Promise { + const args = [ + "log", + revision, + "--diff-merges=first-parent", + `--format=${COMMIT_LOG_FORMAT}`, + "--raw", + "--numstat", + "-M", + ]; + if (maxCount !== undefined) { + args.splice(2, 0, `--max-count=${maxCount}`); + } + + const result = await runGitCommand(args, { cwd, envOverlay: READ_ONLY_GIT_ENV }); + if (result.truncated) { + throw new Error("Commit history exceeded the git output limit"); + } + return parseCheckoutCommitRecords(result.stdout); +} + export interface CheckoutCommitsResult { baseRef: string | null; commits: CheckoutCommit[]; } +async function tryResolveCheckoutCommitsBaseRef( + cwd: string, + baseRef: string | null, + currentBranch: string, +): Promise { + if (!baseRef) { + return null; + } + const normalizedBaseRef = normalizeLocalBranchRefName(baseRef); + if (!normalizedBaseRef || normalizedBaseRef === currentBranch) { + return null; + } + return resolveMostAheadBaseRef(cwd, normalizedBaseRef).catch(() => null); +} + export async function listCheckoutCommits({ cwd, + context, }: { cwd: string; + context?: CheckoutContext; }): Promise { const currentBranch = await getCurrentBranch(cwd); if (!currentBranch) { return { baseRef: null, commits: [] }; } - const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd); + const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context); const normalizedBaseRef = resolvedBaseRef ? normalizeLocalBranchRefName(resolvedBaseRef) : null; - let comparisonBaseRef: string | null = null; - if (resolvedBaseRef && normalizedBaseRef && normalizedBaseRef !== currentBranch) { - try { - comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef); - } catch { - // History does not depend on the configured base being available. - } + let comparisonBaseRef = await tryResolveCheckoutCommitsBaseRef( + cwd, + resolvedBaseRef, + currentBranch, + ); + if (!comparisonBaseRef && normalizedBaseRef && normalizedBaseRef !== currentBranch) { + // Saved worktree metadata can outlive a renamed or deleted base branch. + comparisonBaseRef = await tryResolveCheckoutCommitsBaseRef( + cwd, + await resolveBaseRef(cwd), + currentBranch, + ); } - // Single pass: `--raw` carries the status letter, `--numstat` the +/- counts. - // (`--name-status` cannot be combined with `--numstat` — git emits only one.) - const logResult = await runGitCommand( - [ - "log", - "HEAD", - "--diff-merges=first-parent", - `--max-count=${MAX_CHECKOUT_COMMITS}`, - `--format=${COMMIT_LOG_FORMAT}`, - "--raw", - "--numstat", - "-M", - ], - { cwd, envOverlay: READ_ONLY_GIT_ENV }, - ); + let workspaceRecords: ParsedCheckoutCommit[] = []; + let baseRevision = "HEAD"; + if (comparisonBaseRef) { + const [records, mergeBase] = await Promise.all([ + getCheckoutCommitRecords({ cwd, revision: `${comparisonBaseRef}..HEAD` }), + tryResolveMergeBase(cwd, comparisonBaseRef), + ]); + workspaceRecords = records; + baseRevision = mergeBase ?? ""; + } - const records = parseCheckoutCommitRecords(logResult.stdout); + const baseRecords = baseRevision + ? await getCheckoutCommitRecords({ + cwd, + revision: baseRevision, + maxCount: CHECKOUT_BASE_COMMIT_LIMIT, + }) + : []; + const records = [...workspaceRecords, ...baseRecords]; if (records.length === 0) { return { baseRef: comparisonBaseRef, commits: [] }; } const unpushedShas = await getUnpushedCommitShas(cwd); + const workspaceShas = new Set(workspaceRecords.map((record) => record.sha)); const commits = records.map((record) => ({ sha: record.sha, @@ -2209,6 +2259,7 @@ export async function listCheckoutCommits({ authorName: record.authorName, authorDate: record.authorDate, isOnRemote: !unpushedShas.has(record.sha), + isOnBase: !workspaceShas.has(record.sha), files: record.files, })); From 8e063f0dfcf9137a8d9f7a77ceabad1e93adf83d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 18:24:10 +0200 Subject: [PATCH 029/420] Fix compact composer controls and native scrolling (#2361) * fix(app): refine compact composer controls and native scrolling Consolidate responsive agent controls and model browsing inside the compact sheet. Preserve manual native scrolling by suspending sticky bottom maintenance while the user owns the viewport. * fix(app): suppress recoverable resume refresh errors Resume revalidation can race host reconnection and reject while the host is offline. Treat the refresh as deferred so development LogBox does not surface a recoverable disconnect. * fix(app): stabilize compact model sheet loading Let the model list consume the sheet space above persistent controls as the sheet expands. Keep unresolved defaults in a loading state so Select model only represents a genuinely empty selection. * fix(app): preserve route anchors during native scroll * fix(app): preserve native scroll intent and sheet dismissal * fix(app): keep compact sheet styles composable --- packages/app/e2e/bottom-sheet-reopen.spec.ts | 12 + .../bottom-anchor-controller.test.ts | 222 +++- .../agent-stream/bottom-anchor-controller.ts | 58 + .../app/src/agent-stream/strategy-native.tsx | 76 +- .../src/components/adaptive-modal-sheet.tsx | 108 +- .../components/combined-model-selector.tsx | 926 ++----------- .../src/components/context-window-meter.tsx | 22 +- .../app/src/components/icons/omp-icon.tsx | 2 +- packages/app/src/components/icons/pi-icon.tsx | 2 +- .../src/components/model-browser-view.test.ts | 49 + .../app/src/components/model-browser-view.ts | 40 + packages/app/src/components/model-browser.tsx | 1169 +++++++++++++++++ packages/app/src/components/ui/combobox.tsx | 95 +- .../src/components/workspace-setup-dialog.tsx | 12 - .../src/composer/agent-controls/control.tsx | 173 +++ .../app/src/composer/agent-controls/glyph.tsx | 32 + .../app/src/composer/agent-controls/index.tsx | 998 +++++++------- .../agent-controls/layout-context.tsx | 37 + .../composer/agent-controls/layout.test.ts | 168 +++ .../app/src/composer/agent-controls/layout.ts | 134 ++ .../composer/agent-controls/mode-control.tsx | 176 +-- .../composer/agent-controls/model-sheet.tsx | 275 ++++ .../app/src/composer/draft/workspace-tab.tsx | 83 +- packages/app/src/composer/index.tsx | 92 +- packages/app/src/contexts/session-context.tsx | 5 - .../session-resume-revalidation.test.ts | 17 + .../contexts/session-resume-revalidation.ts | 10 +- .../app/src/hooks/use-agent-form-state.ts | 6 +- packages/app/src/panels/agent-panel.tsx | 15 - .../provider-selection.test.ts | 19 + .../provider-selection/provider-selection.ts | 4 +- .../resolve-agent-form.test.ts | 4 +- .../provider-selection/resolve-agent-form.ts | 2 +- .../app/src/screens/new-workspace-screen.tsx | 9 - 34 files changed, 3249 insertions(+), 1803 deletions(-) create mode 100644 packages/app/src/components/model-browser-view.test.ts create mode 100644 packages/app/src/components/model-browser-view.ts create mode 100644 packages/app/src/components/model-browser.tsx create mode 100644 packages/app/src/composer/agent-controls/control.tsx create mode 100644 packages/app/src/composer/agent-controls/glyph.tsx create mode 100644 packages/app/src/composer/agent-controls/layout-context.tsx create mode 100644 packages/app/src/composer/agent-controls/layout.test.ts create mode 100644 packages/app/src/composer/agent-controls/layout.ts create mode 100644 packages/app/src/composer/agent-controls/model-sheet.tsx diff --git a/packages/app/e2e/bottom-sheet-reopen.spec.ts b/packages/app/e2e/bottom-sheet-reopen.spec.ts index 8c42a5123c3..10ad84ff235 100644 --- a/packages/app/e2e/bottom-sheet-reopen.spec.ts +++ b/packages/app/e2e/bottom-sheet-reopen.spec.ts @@ -123,4 +123,16 @@ test.describe("mobile bottom sheet reopen", () => { await openAndCloseModelSelectorTwice(page); }); }); + + test("model selector closes after model selection", async ({ page }) => { + await withMobileMockAgent(page, async () => { + await openModelSelector(page); + const sheet = page.getByLabel("Bottom Sheet", { exact: true }); + + await sheet.getByText("Ten second stream", { exact: true }).click(); + + await expect(sheet).not.toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible(); + }); + }); }); diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.test.ts b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts index f37b79a23e6..17b6d444594 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.test.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { __private__, deriveBottomAnchorBlockedReason, @@ -126,10 +126,15 @@ function createDriverHarness(input?: { measurementState, nearBottom: input?.isNearBottom ?? true, }; - const scrollToBottom = vi.fn(() => { + const scrollAttempts: boolean[] = []; + let scrollToBottomBehavior = () => { context.nearBottom = true; context.measurementState.offsetY = 720; - }); + }; + const scrollToBottom = (animated: boolean) => { + scrollAttempts.push(animated); + scrollToBottomBehavior(); + }; const modeChanges: BottomAnchorMode[] = []; const driver = __private__.createBottomAnchorControllerDriver({ getAgentId: () => context.agentId, @@ -150,7 +155,10 @@ function createDriverHarness(input?: { context, driver, scheduler, - scrollToBottom, + scrollAttempts, + setScrollToBottomBehavior(next: () => void) { + scrollToBottomBehavior = next; + }, modeChanges, }; } @@ -210,7 +218,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).not.toHaveBeenCalled(); + expect(harness.scrollAttempts).toHaveLength(0); expect(harness.driver.getSnapshot()).toMatchObject({ mode: "sticky-bottom", blockedReason: "waiting_for_history_readiness", @@ -229,8 +237,99 @@ describe("bottom anchor controller driver", () => { harness.driver.reevaluate(); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); + expect(harness.driver.getSnapshot()).toMatchObject({ + blockedReason: null, + pendingRequest: null, + pendingVerification: null, + }); + }); + + it("preserves a blocked route anchor while a user scroll ends at the bottom", () => { + const harness = createDriverHarness({ authoritativeReady: false }); + + harness.driver.applyRouteRequest({ + agentId: "agent-1", + reason: "initial-entry", + requestKey: "route:agent-1:initial-entry", + }); + harness.scheduler.flushAll(); + + harness.driver.beginUserScroll(); + harness.context.authoritativeReady = true; + harness.driver.notifyAuthoritativeHistoryMaybeChanged(); + harness.driver.reevaluate(); + harness.scheduler.flushAll(); + + expect(harness.scrollAttempts).toHaveLength(0); + + harness.driver.endUserScroll({ isNearBottom: true }); + harness.scheduler.flushAll(); + + expect(harness.scrollAttempts).toHaveLength(1); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + blockedReason: null, + pendingRequest: null, + pendingVerification: null, + }); + }); + + it("preserves a blocked route anchor when layout moves after drag release", () => { + const harness = createDriverHarness({ authoritativeReady: false }); + + harness.driver.applyRouteRequest({ + agentId: "agent-1", + reason: "resume", + requestKey: "route:agent-1:resume", + }); + harness.scheduler.flushAll(); + + harness.driver.beginUserScroll(); + harness.context.nearBottom = false; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 0, + }); + harness.context.authoritativeReady = true; + harness.driver.notifyAuthoritativeHistoryMaybeChanged(); + harness.driver.endUserScroll({ isNearBottom: true }); + harness.scheduler.flushAll(); + + expect(harness.scrollAttempts).toHaveLength(1); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + blockedReason: null, + pendingRequest: null, + pendingVerification: null, + }); + }); + + it("lets a user scroll away supersede a blocked route anchor", () => { + const harness = createDriverHarness({ authoritativeReady: false }); + + harness.driver.applyRouteRequest({ + agentId: "agent-1", + reason: "resume", + requestKey: "route:agent-1:resume", + }); + harness.scheduler.flushAll(); + + harness.driver.beginUserScroll(); + harness.context.nearBottom = false; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 48, + }); + harness.driver.endUserScroll({ isNearBottom: false }); + harness.context.authoritativeReady = true; + harness.driver.notifyAuthoritativeHistoryMaybeChanged(); + harness.driver.reevaluate(); + harness.scheduler.flushAll(); + + expect(harness.scrollAttempts).toHaveLength(0); expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "detached", blockedReason: null, pendingRequest: null, pendingVerification: null, @@ -254,7 +353,74 @@ describe("bottom anchor controller driver", () => { harness.scheduler.flushAll(); expect(harness.driver.getSnapshot().mode).toBe("detached"); - expect(harness.scrollToBottom).not.toHaveBeenCalled(); + expect(harness.scrollAttempts).toHaveLength(0); + }); + + it("pauses sticky maintenance while a user scroll owns the viewport", () => { + const harness = createDriverHarness({ + transportBehavior: { + verificationDelayFrames: 2, + verificationRetryMode: "recheck", + }, + }); + + harness.driver.prepareForStickyContentChange(); + harness.driver.beginUserScroll(); + harness.driver.handleContentSizeChange({ + previousContentHeight: 1200, + contentHeight: 1400, + }); + harness.context.nearBottom = false; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 1, + }); + harness.scheduler.flushAll(); + + expect(harness.scrollAttempts).toHaveLength(1); + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + pendingRequest: null, + pendingVerification: null, + }); + + harness.driver.endUserScroll({ isNearBottom: false }); + + expect(harness.driver.getSnapshot().mode).toBe("detached"); + }); + + it("restores sticky maintenance when a user scroll returns to the bottom", () => { + const harness = createDriverHarness({ + transportBehavior: { + verificationDelayFrames: 2, + verificationRetryMode: "recheck", + }, + }); + + harness.driver.beginUserScroll(); + harness.context.nearBottom = false; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: false, + scrollDelta: 48, + }); + harness.driver.handleContentSizeChange({ + previousContentHeight: 1200, + contentHeight: 1400, + }); + harness.context.nearBottom = true; + harness.driver.handleScrollNearBottomChange({ + nextIsNearBottom: true, + scrollDelta: -48, + }); + harness.driver.endUserScroll({ isNearBottom: true }); + harness.scheduler.flushAll(); + + expect(harness.driver.getSnapshot()).toMatchObject({ + mode: "sticky-bottom", + pendingRequest: null, + pendingVerification: null, + }); + expect(harness.scrollAttempts).toHaveLength(1); }); it("switches back to sticky-bottom for explicit jump-to-bottom", () => { @@ -271,7 +437,7 @@ describe("bottom anchor controller driver", () => { expect(harness.modeChanges).toContain("detached"); expect(harness.modeChanges).toContain("sticky-bottom"); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom"); }); @@ -292,7 +458,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(2); + expect(harness.scrollAttempts).toHaveLength(2); }); it("keeps a pending request blocked when stale container measurements arrive", () => { @@ -313,7 +479,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).not.toHaveBeenCalled(); + expect(harness.scrollAttempts).toHaveLength(0); expect(harness.driver.getSnapshot()).toMatchObject({ blockedReason: "waiting_for_measurable_viewport", pendingRequest: { @@ -326,7 +492,7 @@ describe("bottom anchor controller driver", () => { harness.driver.reevaluate(); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot().pendingRequest).toBeNull(); }); @@ -338,7 +504,7 @@ describe("bottom anchor controller driver", () => { }, isNearBottom: false, }); - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 0; }); @@ -348,16 +514,16 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushFrame(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); harness.scheduler.flushFrame(); harness.scheduler.flushFrame(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); harness.context.nearBottom = true; harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot().pendingRequest).toBeNull(); }); @@ -375,7 +541,7 @@ describe("bottom anchor controller driver", () => { isNearBottom: false, }); - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 13476; }); @@ -386,7 +552,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushFrame(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); harness.context.measurementState.contentHeight = 14804; harness.context.nearBottom = false; @@ -409,7 +575,7 @@ describe("bottom anchor controller driver", () => { harness.scheduler.flushFrame(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(2); + expect(harness.scrollAttempts).toHaveLength(2); expect(harness.driver.getSnapshot()).toMatchObject({ blockedReason: "waiting_for_post_layout_verification", pendingRequest: { @@ -435,7 +601,7 @@ describe("bottom anchor controller driver", () => { isNearBottom: false, }); - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = Math.max( 0, harness.context.measurementState.contentHeight - @@ -471,7 +637,7 @@ describe("bottom anchor controller driver", () => { harness.scheduler.flushFrame(); harness.scheduler.flushFrame(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(2); + expect(harness.scrollAttempts).toHaveLength(2); expect(harness.driver.getSnapshot().pendingRequest).toMatchObject({ reason: "resume", }); @@ -480,7 +646,7 @@ describe("bottom anchor controller driver", () => { it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => { const harness = createDriverHarness(); harness.context.nearBottom = false; - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 720; }); @@ -492,7 +658,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(4); + expect(harness.scrollAttempts).toHaveLength(4); expect(harness.driver.getSnapshot()).toMatchObject({ mode: "sticky-bottom", pendingRequest: null, @@ -523,7 +689,7 @@ describe("bottom anchor controller driver", () => { it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => { const harness = createDriverHarness(); harness.context.nearBottom = false; - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 900; }); @@ -533,7 +699,7 @@ describe("bottom anchor controller driver", () => { }); harness.scheduler.flushAll(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(4); + expect(harness.scrollAttempts).toHaveLength(4); expect(harness.driver.getSnapshot()).toMatchObject({ mode: "sticky-bottom", pendingRequest: null, @@ -577,7 +743,7 @@ describe("bottom anchor controller driver", () => { contentMeasuredForKey: null, }), }); - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 0; }); @@ -588,7 +754,7 @@ describe("bottom anchor controller driver", () => { contentHeight: 1348, }); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot()).toMatchObject({ mode: "sticky-bottom", pendingVerification: { @@ -625,14 +791,14 @@ describe("bottom anchor controller driver", () => { contentMeasuredForKey: "native-virtualized", }), }); - harness.scrollToBottom.mockImplementation(() => { + harness.setScrollToBottomBehavior(() => { harness.context.measurementState.offsetY = 0; harness.context.nearBottom = true; }); harness.driver.prepareForStickyContentChange(); - expect(harness.scrollToBottom).toHaveBeenCalledTimes(1); + expect(harness.scrollAttempts).toHaveLength(1); expect(harness.driver.getSnapshot()).toMatchObject({ mode: "sticky-bottom", pendingVerification: { diff --git a/packages/app/src/agent-stream/bottom-anchor-controller.ts b/packages/app/src/agent-stream/bottom-anchor-controller.ts index b9f77c13cf2..35bd6e6b6fc 100644 --- a/packages/app/src/agent-stream/bottom-anchor-controller.ts +++ b/packages/app/src/agent-stream/bottom-anchor-controller.ts @@ -68,6 +68,8 @@ interface BottomAnchorControllerDriver { resetForAgent: () => void; applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void; requestLocalAnchor: (request: BottomAnchorLocalRequest) => void; + beginUserScroll: () => void; + endUserScroll: (params: { isNearBottom: boolean }) => void; detachByUser: () => void; handleViewportMetricsChange: (params: { previousViewportWidth: number; @@ -243,6 +245,7 @@ function createBottomAnchorControllerDriver( let lastRouteRequestKey: string | null = null; let stickyMeasurementRevision = 0; let lastVerifiedStickyMeasurementRevision = 0; + let isUserScrollActive = false; const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => { if (blockedReason === nextBlockedReason) { @@ -411,10 +414,14 @@ function createBottomAnchorControllerDriver( | "viewport_change" | "content_size_change" | "scroll_near_bottom_change" + | "user_scroll_end" | "history_readiness_change" | "manual_reevaluate" | "retry_scroll", ) => { + if (isUserScrollActive) { + return; + } if (attemptHandle) { return; } @@ -481,6 +488,7 @@ function createBottomAnchorControllerDriver( cancelPendingAttempt(); stickyMeasurementRevision = 0; lastVerifiedStickyMeasurementRevision = 0; + isUserScrollActive = false; mode = "sticky-bottom"; input.onModeChange("sticky-bottom"); }, @@ -497,6 +505,38 @@ function createBottomAnchorControllerDriver( requestLocalAnchor(request) { createRequest(request); }, + beginUserScroll() { + isUserScrollActive = true; + cancelPendingAttempt(); + }, + endUserScroll(params) { + isUserScrollActive = false; + if (params.isNearBottom) { + if (mode === "detached") { + setModeInternal("sticky-bottom"); + pendingVerification = { requestId: null, retries: 0 }; + evaluate(false, "user_scroll_end"); + return; + } + if (pendingRequest) { + evaluate(false, "user_scroll_end"); + return; + } + if ( + !input.isNearBottom() || + stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision + ) { + pendingVerification = { requestId: null, retries: 0 }; + evaluate(false, "user_scroll_end"); + return; + } + markStickyMeasurementVerified(); + return; + } + if (mode === "sticky-bottom") { + this.detachByUser(); + } + }, detachByUser() { if (mode === "detached") { return; @@ -511,6 +551,9 @@ function createBottomAnchorControllerDriver( ) { markStickyMeasurementChanged(); } + if (isUserScrollActive) { + return; + } const shouldRestick = __private__.shouldRestickOnViewportChange({ mode, previousViewportWidth: params.previousViewportWidth, @@ -529,6 +572,9 @@ function createBottomAnchorControllerDriver( if (params.previousContentHeight !== params.contentHeight) { markStickyMeasurementChanged(); } + if (isUserScrollActive) { + return; + } const shouldRestick = __private__.shouldRestickOnContentChange({ mode, previousContentHeight: params.previousContentHeight, @@ -558,6 +604,9 @@ function createBottomAnchorControllerDriver( return; } markStickyMeasurementChanged(); + if (isUserScrollActive) { + return; + } if (!pendingRequest) { pendingVerification = { requestId: null, retries: 0 }; if (attemptHandle) { @@ -571,6 +620,9 @@ function createBottomAnchorControllerDriver( }, handleScrollNearBottomChange(params) { const { nextIsNearBottom, scrollDelta } = params; + if (isUserScrollActive) { + return; + } if ( nextIsNearBottom && mode === "sticky-bottom" && @@ -737,6 +789,12 @@ export function useBottomAnchorController(input: { requestLocalAnchor(request: BottomAnchorLocalRequest) { driverRef.current?.requestLocalAnchor(request); }, + beginUserScroll() { + driverRef.current?.beginUserScroll(); + }, + endUserScroll(params: { isNearBottom: boolean }) { + driverRef.current?.endUserScroll(params); + }, detachByUser() { driverRef.current?.detachByUser(); }, diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 4463aae6fdd..a28c611e5a9 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -89,6 +89,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentMeasuredForKey: null as string | null, }); const scrollOffsetYRef = useRef(0); + const isUserScrollActiveRef = useRef(false); + const userScrollEndFrameIdRef = useRef(null); const programmaticScrollEventBudgetRef = useRef(0); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const nativeViewportSettlingFrameIdRef = useRef(null); @@ -129,6 +131,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } }, []); + const clearPendingUserScrollEnd = useCallback(() => { + if (userScrollEndFrameIdRef.current !== null) { + cancelAnimationFrame(userScrollEndFrameIdRef.current); + userScrollEndFrameIdRef.current = null; + } + }, []); + const markNativeViewportSettling = useCallback(() => { clearNativeViewportSettling(); setIsNativeViewportSettling(true); @@ -208,6 +217,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentMeasuredForKey: null, }; scrollOffsetYRef.current = 0; + isUserScrollActiveRef.current = false; + clearPendingUserScrollEnd(); clearNativeViewportSettling(); setIsNativeViewportSettling(false); historyStartReadyRef.current = false; @@ -216,8 +227,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); return () => { cancelAnimationFrame(frame); + clearPendingUserScrollEnd(); }; - }, [agentId, clearNativeViewportSettling]); + }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]); useEffect(() => { const keyboardEvents = [ @@ -266,6 +278,19 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }; }, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]); + const isScrollEventNearBottom = useStableEvent( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + return isNearBottomForStreamRenderStrategy({ + strategy, + offsetY: contentOffset.y, + threshold: 32, + contentHeight: contentSize.height, + viewportHeight: layoutMeasurement.height, + }); + }, + ); + const handleScroll = useStableEvent((event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const previousOffsetY = scrollOffsetYRef.current; @@ -280,13 +305,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentMeasuredForKey: "native-virtualized", }; - const nearBottom = isNearBottomForStreamRenderStrategy({ - strategy, - offsetY: contentOffset.y, - threshold: 32, - contentHeight: streamViewportMetricsRef.current.contentHeight, - viewportHeight: streamViewportMetricsRef.current.viewportHeight, - }); + const nearBottom = isScrollEventNearBottom(event); onNearBottomChange(nearBottom); const distanceFromOldestEdge = @@ -301,7 +320,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat onNearHistoryStart(); } - if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) { + if ( + !isUserScrollActiveRef.current && + programmaticScrollEventBudgetRef.current > 0 && + contentOffset.y <= 8 + ) { programmaticScrollEventBudgetRef.current -= 1; } else { programmaticScrollEventBudgetRef.current = 0; @@ -312,6 +335,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } }); + const handleScrollBeginDrag = useStableEvent(() => { + clearPendingUserScrollEnd(); + isUserScrollActiveRef.current = true; + bottomAnchorController.beginUserScroll(); + }); + + // Defer drag end so momentum can take ownership, but capture the terminal + // gesture position now because layout may move the viewport in the meantime. + const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent) => { + const isNearBottom = isScrollEventNearBottom(event); + clearPendingUserScrollEnd(); + userScrollEndFrameIdRef.current = requestAnimationFrame(() => { + userScrollEndFrameIdRef.current = null; + isUserScrollActiveRef.current = false; + bottomAnchorController.endUserScroll({ isNearBottom }); + }); + }); + + const handleMomentumScrollBegin = useStableEvent(() => { + clearPendingUserScrollEnd(); + }); + + const handleMomentumScrollEnd = useStableEvent( + (event: NativeSyntheticEvent) => { + const isNearBottom = isScrollEventNearBottom(event); + clearPendingUserScrollEnd(); + isUserScrollActiveRef.current = false; + bottomAnchorController.endUserScroll({ isNearBottom }); + }, + ); + const handleListLayout = useStableEvent((event: LayoutChangeEvent) => { const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth; const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight; @@ -419,6 +473,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat style={listStyle} onLayout={handleListLayout} onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollBegin={handleMomentumScrollBegin} + onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} maintainVisibleContentPosition={maintainVisibleContentPosition} diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 9b91a375e2e..07c157fa89c 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -3,7 +3,7 @@ import type { ReactNode, Ref } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native"; -import type { TextInputProps } from "react-native"; +import type { StyleProp, TextInputProps, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; @@ -11,9 +11,10 @@ import { BottomSheetBackdrop, BottomSheetScrollView, BottomSheetTextInput, + useBottomSheetInternal, type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; -import Animated from "react-native-reanimated"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { ArrowLeft, Search, X } from "lucide-react-native"; import { IsolatedBottomSheetModal, @@ -203,6 +204,14 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[4], minHeight: 0, }, + bottomSheetVisibleContent: { + minHeight: 0, + overflow: "hidden", + }, + bottomSheetVisibleScroll: { + flex: 1, + minHeight: 0, + }, desktopStaticContent: { flexShrink: 1, minHeight: 0, @@ -248,6 +257,39 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) { return ; } +function BottomSheetVisibleContent({ children }: { children: ReactNode }) { + const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } = + useBottomSheetInternal(); + const visibleContentStyle = useAnimatedStyle(() => { + const { containerHeight, handleHeight } = animatedLayoutState.get(); + if (containerHeight < 0 || handleHeight < 0) { + return { height: 0 }; + } + + const initialDetentPosition = animatedDetentsState.get().detents?.[0]; + const contentPosition = + initialDetentPosition == null + ? animatedPosition.get() + : Math.min(animatedPosition.get(), initialDetentPosition); + + return { + height: Math.max( + 0, + containerHeight - + contentPosition - + handleHeight - + animatedKeyboardState.get().heightWithinContainer, + ), + }; + }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]); + + return ( + + {children} + + ); +} + export type AdaptiveTextInputProps = TextInputProps & { initialValue?: string; resetKey?: string | number; @@ -452,12 +494,16 @@ export interface AdaptiveModalSheetProps { children: ReactNode; /** Sticky footer rendered below the scrollable content. */ footer?: ReactNode; + footerContainerStyle?: StyleProp; snapPoints?: string[]; testID?: string; /** Override the max width of the desktop card. */ desktopMaxWidth?: number; scrollable?: boolean; presentation?: "push" | "replace"; + contentContainerStyle?: StyleProp; + /** Size compact sheet content to the live snap height instead of its largest snap point. */ + sizeContentToCurrentSnapPoint?: boolean; } export function AdaptiveModalSheet({ @@ -467,11 +513,14 @@ export function AdaptiveModalSheet({ onDismiss, children, footer, + footerContainerStyle, snapPoints, testID, desktopMaxWidth, scrollable = true, presentation, + contentContainerStyle, + sizeContentToCurrentSnapPoint = false, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -490,31 +539,37 @@ export function AdaptiveModalSheet({ [footer, insets.bottom, isMobile, theme.spacing], ); const bottomSheetContentStyle = useMemo( + // Gorhom spreads this outer array into StyleSheet.compose, which accepts two arguments on web. () => [ styles.bottomSheetContent, - compactSafeAreaPadding.contentPaddingBottom != null - ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } - : null, + [ + contentContainerStyle, + compactSafeAreaPadding.contentPaddingBottom != null + ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } + : null, + ], ], - [compactSafeAreaPadding.contentPaddingBottom], + [compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle], ); const bottomSheetStaticContentStyle = useMemo( () => [ styles.bottomSheetStaticContent, + contentContainerStyle, compactSafeAreaPadding.contentPaddingBottom != null ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } : null, ], - [compactSafeAreaPadding.contentPaddingBottom], + [compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle], ); const footerStyle = useMemo( () => [ styles.footer, + footerContainerStyle, compactSafeAreaPadding.footerPaddingBottom != null ? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom } : null, ], - [compactSafeAreaPadding.footerPaddingBottom], + [compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle], ); const handleIndicatorStyle = useMemo( () => ({ backgroundColor: theme.colors.palette.zinc[600] }), @@ -599,6 +654,25 @@ export function AdaptiveModalSheet({ }, [visible, isMobile, notifyNativeModalDismiss]); if (isMobile) { + const sheetContent = ( + <> + + {scrollable ? ( + + {children} + + ) : ( + {children} + )} + {footer ? {footer} : null} + + ); + return ( - - {scrollable ? ( - - {children} - + {sizeContentToCurrentSnapPoint ? ( + {sheetContent} ) : ( - {children} + sheetContent )} - {footer ? {footer} : null} ); } @@ -640,7 +706,7 @@ export function AdaptiveModalSheet({ @@ -648,7 +714,7 @@ export function AdaptiveModalSheet({ ) : ( - {children} + {children} )} {footer ? {footer} : null} diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index c11a11a18b1..179e5313247 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -1,131 +1,25 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { useTranslation } from "react-i18next"; -import { - View, - Text, - Pressable, - type GestureResponderEvent, - type PressableStateCallbackType, -} from "react-native"; -import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { isNative, isWeb as platformIsWeb } from "@/constants/platform"; -import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { SheetHeader } from "@/components/adaptive-modal-sheet"; -import { useProviderSettingsStore } from "@/stores/provider-settings-store"; -import { Button } from "@/components/ui/button"; +import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox"; +import { ModelBrowser, ModelProviderGlyph, useModelBrowser } from "@/components/model-browser"; +import { isNative, isWeb } from "@/constants/platform"; +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import { - Combobox, - ComboboxItem, - type ComboboxOption, - type ComboboxProps, -} from "@/components/ui/combobox"; -import { getProviderIcon } from "@/components/provider-icons"; -import { - buildSelectedTriggerLabel, - filterAndRankModelRows, - getAllProviderModelRows, - getProviderModelRows, - resolveSelectedModelLabel, - type ProviderSelectionModelRow, - type ProviderSelectorProvider, -} from "@/provider-selection/provider-selection"; -const IS_WEB = platformIsWeb; const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = []; - -function noop() {} - -function favoriteButtonStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [ - styles.favoriteButton, - Boolean(hovered) && styles.favoriteButtonHovered, - pressed && styles.favoriteButtonPressed, - ]; -} - -function drillDownRowStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [ - styles.drillDownRow, - Boolean(hovered) && styles.drillDownRowHovered, - pressed && styles.drillDownRowPressed, - ]; -} - -const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220; -const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400; -const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80; -const DESKTOP_MODEL_ROW_HEIGHT = 40; - -const ThemedAlertTriangle = withUnistyles(AlertTriangle); -const ThemedChevronRight = withUnistyles(ChevronRight); +const EMPTY_FAVORITE_KEYS = new Set(); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const ThemedSearch = withUnistyles(Search); -const ThemedSettings = withUnistyles(Settings); -const ThemedStar = withUnistyles(Star); const foregroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({ - color: disabled ? theme.colors.border : theme.colors.foregroundMuted, -}); - -const favoriteStarMapping = - (isFavorite: boolean, hovered: boolean) => - (theme: Theme): { color: string; fill: string } => { - const favoriteColor = theme.colors.palette.amber[500]; - if (isFavorite) { - return { color: favoriteColor, fill: favoriteColor }; - } - return { - color: hovered ? theme.colors.foregroundMuted : theme.colors.border, - fill: "transparent", - }; - }; - -type ProviderGlyphTone = "muted" | "foreground"; - -function ProviderGlyph({ - provider, - size, - tone = "muted", -}: { - provider: string; - size: number; - tone?: ProviderGlyphTone; -}) { - const Icon = getProviderIcon(provider); - const color = - tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color; - return ; -} - -function HeaderSettingsIcon({ disabled }: { disabled: boolean }) { - const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]); - return ; -} - -function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) { - const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]); - return ; -} - -type SelectorView = - | { kind: "all" } - | { kind: "provider"; providerId: string; providerLabel: string }; +function noop() {} interface CombinedModelSelectorProps { providers: ProviderSelectorProvider[]; @@ -160,444 +54,10 @@ interface CombinedModelSelectorProps { * (the composer's layout). */ triggerFill?: boolean; -} - -interface SelectorContentProps { - view: SelectorView; - providers: ProviderSelectorProvider[]; - selectedProvider: string; - selectedModel: string; - searchQuery: string; - favoriteKeys: Set; - onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; - onDrillDown: (providerId: string, providerLabel: string) => void; - onRetryProvider?: (provider: AgentProvider) => void; - isRetryingProvider: boolean; -} - -function normalizeSearchQuery(value: string): string { - return value.trim().toLowerCase(); -} - -function sortFavoritesFirst( - rows: ProviderSelectionModelRow[], - favoriteKeys: Set, -): ProviderSelectionModelRow[] { - const favorites: ProviderSelectionModelRow[] = []; - const rest: ProviderSelectionModelRow[] = []; - for (const row of rows) { - if (favoriteKeys.has(row.favoriteKey)) { - favorites.push(row); - } else { - rest.push(row); - } - } - return [...favorites, ...rest]; -} - -function ModelRow({ - row, - isSelected, - isFavorite, - elevated = false, - onPress, - onToggleFavorite, -}: { - row: ProviderSelectionModelRow; - isSelected: boolean; - isFavorite: boolean; - elevated?: boolean; - onPress: () => void; - onToggleFavorite?: (provider: string, modelId: string) => void; -}) { - const { t } = useTranslation(); - - const handleToggleFavorite = useCallback( - (event: GestureResponderEvent) => { - event.stopPropagation(); - onToggleFavorite?.(row.provider, row.modelId); - }, - [onToggleFavorite, row.modelId, row.provider], - ); - - const leadingSlot = useMemo( - () => , - [row.provider], - ); - const trailingSlot = useMemo( - () => - onToggleFavorite ? ( - - {({ hovered }) => } - - ) : null, - [onToggleFavorite, handleToggleFavorite, isFavorite, row.provider, row.modelId, t], - ); - - return ( - - ); -} - -interface SelectableModelRowProps { - row: ProviderSelectionModelRow; - isSelected: boolean; - isFavorite: boolean; - elevated?: boolean; - onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; -} - -function SelectableModelRow({ - row, - isSelected, - isFavorite, - elevated, - onSelect, - onToggleFavorite, -}: SelectableModelRowProps) { - const handlePress = useCallback(() => { - onSelect(row.provider, row.modelId); - }, [onSelect, row.provider, row.modelId]); - return ( - - ); -} - -function FavoritesSection({ - favoriteRows, - selectedProvider, - selectedModel, - favoriteKeys, - onSelect, - onToggleFavorite, -}: { - favoriteRows: ProviderSelectionModelRow[]; - selectedProvider: string; - selectedModel: string; - favoriteKeys: Set; - onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; -}) { - const { t } = useTranslation(); - if (favoriteRows.length === 0) { - return null; - } - - return ( - - - {t("modelSelector.favorites")} - - {favoriteRows.map((row) => ( - - ))} - - ); -} - -interface GroupProviderButtonProps { - provider: ProviderSelectorProvider; - onDrillDown: (providerId: string, providerLabel: string) => void; -} - -function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) { - return [ - styles.rowIconButton, - Boolean(hovered) && styles.rowIconButtonHovered, - pressed && styles.rowIconButtonPressed, - ]; -} - -function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) { - const { t } = useTranslation(); - const selection = provider.modelSelection; - - const handlePress = useCallback(() => { - onDrillDown(provider.id, provider.label); - }, [onDrillDown, provider.id, provider.label]); - - let stateNode: React.ReactNode; - if (selection.kind === "models") { - const count = selection.rows.length; - stateNode = ( - - {t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", { - count, - })} - - ); - } else if (selection.kind === "loading") { - stateNode = ( - - - - - {t("modelSelector.loadingShort")} - - ); - } else { - stateNode = ( - - - {t("modelSelector.error")} - - ); - } - - return ( - - - {provider.label} - - {stateNode} - - - - ); -} - -function GroupedProviderRows({ - providers, - onDrillDown, -}: { - providers: ProviderSelectorProvider[]; - onDrillDown: (providerId: string, providerLabel: string) => void; -}) { - return ( - - {providers.map((provider, index) => ( - - {index > 0 ? : null} - - - ))} - - ); -} - -function ProviderModelRows({ - rows, - selectedProvider, - selectedModel, - favoriteKeys, - onSelect, - onToggleFavorite, - normalizedQuery, -}: { - rows: ProviderSelectionModelRow[]; - selectedProvider: string; - selectedModel: string; - favoriteKeys: Set; - onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; - normalizedQuery: string; -}) { - const isMobile = useIsCompactFormFactor(); - const useVirtualizedList = isMobile && isNative; - const displayRows = useMemo( - () => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)), - [favoriteKeys, normalizedQuery, rows], - ); - const renderItem = useCallback( - ({ item }: { item: ProviderSelectionModelRow }) => ( - - ), - [favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider], - ); - const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []); - - if (useVirtualizedList) { - return ( - - ); - } - - return ( - - {displayRows.map((row) => ( - {renderItem({ item: row })} - ))} - - ); -} - -function ProviderErrorEmptyState({ - providerId, - message, - onRetryProvider, - isRetryingProvider, -}: { - providerId: string; - message: string; - onRetryProvider?: (provider: AgentProvider) => void; - isRetryingProvider: boolean; -}) { - const { t } = useTranslation(); - const handleRetry = useCallback(() => { - onRetryProvider?.(providerId); - }, [onRetryProvider, providerId]); - return ( - - - {message} - {onRetryProvider ? ( - - ) : null} - - ); -} - -function SelectorContent({ - view, - providers, - selectedProvider, - selectedModel, - searchQuery, - favoriteKeys, - onSelect, - onToggleFavorite, - onDrillDown, - onRetryProvider, - isRetryingProvider, -}: SelectorContentProps) { - const { t } = useTranslation(); - const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); - const selectedViewProvider = useMemo( - () => - view.kind === "provider" - ? providers.find((provider) => provider.id === view.providerId) - : null, - [providers, view], - ); - const visibleRows = useMemo( - () => - selectedViewProvider - ? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery) - : [], - [normalizedQuery, selectedViewProvider], - ); - const favoriteRows = useMemo( - () => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)), - [favoriteKeys, providers], - ); - const hasResults = favoriteRows.length > 0 || providers.length > 0; - const emptyState = ( - - - {t("modelSelector.noMatches")} - - ); - - if (view.kind === "provider") { - if (!selectedViewProvider) { - return emptyState; - } - const drillSelection = selectedViewProvider.modelSelection; - if (drillSelection.kind === "loading") { - return ( - - - - - {t("modelSelector.loadingShort")} - - ); - } - if (drillSelection.kind === "error") { - return ( - - ); - } - if (visibleRows.length === 0) { - return emptyState; - } - - return ( - - ); - } - - return ( - - - - {providers.length > 0 ? ( - - ) : null} - - {!hasResults ? emptyState : null} - - ); + toolbar?: { + glyphSize: number; + showCaret: boolean; + }; } export function CombinedModelSelector({ @@ -606,7 +66,7 @@ export function CombinedModelSelector({ selectedModel, onSelect, isLoading, - favoriteKeys = new Set(), + favoriteKeys = EMPTY_FAVORITE_KEYS, onToggleFavorite, renderTrigger, onOpen, @@ -618,115 +78,53 @@ export function CombinedModelSelector({ desktopPlacement, desktopMinWidth, triggerFill = false, + toolbar, }: CombinedModelSelectorProps) { const { t } = useTranslation(); const anchorRef = useRef(null); const [isOpen, setIsOpen] = useState(false); - const [isContentReady, setIsContentReady] = useState(platformIsWeb); - const [view, setView] = useState({ kind: "all" }); - const [searchQuery, setSearchQuery] = useState(""); - const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); - - // Single-provider mode: only one provider → skip Level 1 entirely - const singleProviderView = useMemo(() => { - if (providers.length !== 1) return null; - const provider = providers[0]; - if (!provider) return null; - return { kind: "provider", providerId: provider.id, providerLabel: provider.label }; - }, [providers]); - - const computeInitialView = useCallback((): SelectorView => { - if (singleProviderView) return singleProviderView; - - const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`; - if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) { - const provider = providers.find((entry) => entry.id === selectedProvider); - if (provider) - return { kind: "provider", providerId: provider.id, providerLabel: provider.label }; - } - - return { kind: "all" }; - }, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providers]); + const [isContentReady, setIsContentReady] = useState(isWeb); + const browser = useModelBrowser({ + providers, + selectedProvider, + selectedModel, + isLoading, + favoriteKeys, + serverId, + }); + const { prepareToOpen, reset } = browser; const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); - setView(computeInitialView()); if (open) { + prepareToOpen(); onOpen?.(); - } else { - setSearchQuery(""); - bumpSearchResetKey(); - onClose?.(); + return; } + reset(); + onClose?.(); }, - [onOpen, onClose, computeInitialView], + [onClose, onOpen, prepareToOpen, reset], ); const handleSelect = useCallback( (provider: string, modelId: string) => { onSelect(provider, modelId); - setIsOpen(false); - setSearchQuery(""); - bumpSearchResetKey(); + handleOpenChange(false); }, - [onSelect], + [handleOpenChange, onSelect], ); - const hasSelectedProvider = selectedProvider.trim().length > 0; - - const selectedModelLabel = useMemo(() => { - return resolveSelectedModelLabel({ - providers, - selectedProvider, - selectedModel, - isLoading, - }); - }, [isLoading, providers, selectedModel, selectedProvider]); - - const desktopFixedHeight = useMemo(() => { - if (view.kind !== "provider") { - return undefined; - } - const provider = providers.find((entry) => entry.id === view.providerId); - if (!provider || provider.modelSelection.kind !== "models") { - return DESKTOP_PROVIDER_VIEW_MIN_HEIGHT; - } - const modelCount = getProviderModelRows(provider).length; - return Math.min( - Math.max( - DESKTOP_PROVIDER_VIEW_MIN_HEIGHT, - DESKTOP_PROVIDER_VIEW_BASE_HEIGHT + modelCount * DESKTOP_MODEL_ROW_HEIGHT, - ), - DESKTOP_PROVIDER_VIEW_MAX_HEIGHT, - ); - }, [providers, view]); - - const triggerLabel = useMemo(() => { - if ( - selectedModelLabel === t("modelSelector.loading") || - selectedModelLabel === t("modelSelector.selectModel") - ) { - return selectedModelLabel; - } - - return buildSelectedTriggerLabel(selectedModelLabel); - }, [selectedModelLabel, t]); - useEffect(() => { - if (platformIsWeb) { - return () => {}; - } - + if (isWeb) return () => {}; if (!isOpen) { setIsContentReady(false); return () => {}; } - const frame = requestAnimationFrame(() => { setIsContentReady(true); }); - return () => cancelAnimationFrame(frame); }, [isOpen]); @@ -736,8 +134,6 @@ export function CombinedModelSelector({ const triggerStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => { - // Fill mode: transparent full-width passthrough. The trigger paints its own - // hover/pressed state from the args, so the wrapper must not double-paint. if (triggerFill) { return [ styles.trigger, @@ -757,67 +153,20 @@ export function CombinedModelSelector({ [disabled, isOpen, renderTrigger, triggerFill], ); - const handleBackToAll = useCallback(() => { - setView({ kind: "all" }); - setSearchQuery(""); - bumpSearchResetKey(); - }, []); - - const handleDrillDown = useCallback((providerId: string, providerLabel: string) => { - setView({ kind: "provider", providerId, providerLabel }); - }, []); - - const handleSearchQueryChange = useCallback((value: string) => { - setSearchQuery(value); - }, []); - - const openProviderSettings = useCallback(() => { - if (!serverId || view.kind !== "provider") return; - useProviderSettingsStore.getState().open({ serverId, provider: view.providerId }); - }, [serverId, view]); - - const sheetHeader = useMemo(() => { - if (view.kind === "all") { - return { title: t("modelSelector.title") }; - } - const headerActions = ( - - - - ); - return { - title: view.providerLabel, - leading: , - back: singleProviderView ? undefined : { onPress: handleBackToAll }, - actions: headerActions, - search: { - onChange: handleSearchQueryChange, - resetKey: `${view.providerId}:${searchResetKey}`, - placeholder: t("modelSelector.searchPlaceholder"), - autoFocus: platformIsWeb, - testID: "model-search-input", - }, - }; - }, [ - view, - singleProviderView, - serverId, - openProviderSettings, - handleBackToAll, - handleSearchQueryChange, - searchResetKey, - t, - ]); + const selectorBody = isContentReady ? ( + + ) : ( + + + {t("modelSelector.loadingSelector")} + + ); return ( <> @@ -829,12 +178,14 @@ export function CombinedModelSelector({ onPress={handleTriggerPress} style={triggerStyle} accessibilityRole="button" - accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })} + accessibilityLabel={t("modelSelector.selectedModel", { + model: browser.selectedModelLabel, + })} testID="combined-model-selector" > {({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => renderTrigger({ - selectedModelLabel: triggerLabel, + selectedModelLabel: browser.triggerLabel, onPress: handleTriggerPress, disabled, isOpen, @@ -851,14 +202,22 @@ export function CombinedModelSelector({ onPress={handleTriggerPress} style={triggerStyle} accessibilityRole="button" - accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })} + accessibilityLabel={t("modelSelector.selectedModel", { + model: browser.selectedModelLabel, + })} testID="combined-model-selector" + chevron={toolbar?.showCaret === false ? null : undefined} > - {hasSelectedProvider ? ( - + {selectedProvider.trim().length > 0 ? ( + + + ) : null} - {triggerLabel} + {browser.triggerLabel} )} @@ -871,36 +230,21 @@ export function CombinedModelSelector({ anchorRef={anchorRef} desktopPlacement={desktopPlacement} desktopMinWidth={desktopMinWidth} - desktopFixedHeight={desktopFixedHeight} - header={sheetHeader} - mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative} + desktopFixedHeight={browser.desktopFixedHeight} + header={browser.header} + mobileChildrenScrollEnabled={!browser.isProviderView || !isNative} + mobileChildrenContentContainerStyle={styles.mobileBrowserContent} > - {isContentReady ? ( - - ) : ( - - - {t("modelSelector.loadingSelector")} - - )} + {selectorBody} ); } const styles = StyleSheet.create((theme) => ({ + mobileBrowserContent: { + paddingHorizontal: 0, + }, trigger: { height: 28, minWidth: 0, @@ -915,6 +259,16 @@ const styles = StyleSheet.create((theme) => ({ triggerHovered: { backgroundColor: theme.colors.surface2, }, + toolbarGlyph16: { + width: 16, + height: 16, + flexShrink: 0, + }, + toolbarGlyph20: { + width: 20, + height: 20, + flexShrink: 0, + }, triggerPressed: { backgroundColor: theme.colors.surface0, }, @@ -933,8 +287,6 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: 0, height: "auto", }, - // Stretch the wrapper (and, via column + stretch, its single child) to the - // full width of the field, with no background or rounding of its own. triggerFill: { alignSelf: "stretch", flexShrink: 0, @@ -943,116 +295,6 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: "transparent", borderRadius: 0, }, - favoritesContainer: { - backgroundColor: theme.colors.surface1, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - }, - separator: { - height: 1, - backgroundColor: theme.colors.border, - }, - sectionHeading: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingTop: theme.spacing[2], - paddingBottom: theme.spacing[1], - ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), - }, - sectionHeadingText: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - }, - drillDownRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - minHeight: 36, - ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), - }, - drillDownRowHovered: { - backgroundColor: theme.colors.surface1, - }, - drillDownRowPressed: { - backgroundColor: theme.colors.surface2, - }, - drillDownText: { - flex: 1, - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - }, - drillDownTrailing: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - drillDownCount: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, - rowStateInline: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flexShrink: 1, - minWidth: 0, - }, - rowErrorText: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - maxWidth: 140, - }, - rowIconButton: { - width: 24, - height: 24, - borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", - }, - rowSpinner: { - transform: [{ scale: 0.7 }], - }, - rowIconButtonHovered: { - backgroundColor: theme.colors.surface2, - }, - rowIconButtonPressed: { - backgroundColor: theme.colors.surface1, - }, - emptyState: { - paddingVertical: theme.spacing[4], - alignItems: "center", - gap: theme.spacing[2], - }, - emptyStateText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - virtualizedModelList: { - flex: 1, - }, - virtualizedModelListContent: { - paddingHorizontal: theme.spacing[2], - paddingTop: theme.spacing[1], - paddingBottom: theme.spacing[8], - }, - favoriteButton: { - width: 24, - height: 24, - borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", - }, - favoriteButtonHovered: { - backgroundColor: theme.colors.surface2, - }, - favoriteButtonPressed: { - backgroundColor: theme.colors.surface1, - }, sheetLoadingState: { minHeight: 160, justifyContent: "center", @@ -1063,10 +305,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - providerIconMuted: { - color: theme.colors.foregroundMuted, - }, - providerIconForeground: { - color: theme.colors.foreground, - }, })); diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index c7955d8a194..1046d11e9c8 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -18,17 +18,16 @@ interface ContextWindowMeterProps { provider?: string | null; /** Reserve the meter footprint and show a loading ring while usage is pending. */ pending?: boolean; + /** Optional glyph envelope for icon-toolbar alignment. */ + glyphSize?: number; } const SVG_SIZE = 14; const COMPACT_SVG_SIZE = 12; -const CENTER = SVG_SIZE / 2; const COMPACT_CENTER = COMPACT_SVG_SIZE / 2; -const RADIUS = 6; const COMPACT_RADIUS = 5; const STROKE_WIDTH = 2; const COMPACT_STROKE_WIDTH = 1.75; -const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS; function isValidMaxTokens(value: number): boolean { @@ -74,7 +73,7 @@ function getMeterColors( return { progress: theme.colors.foregroundMuted, track }; } -function getMeterGeometry(showPercentage: boolean) { +function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { if (showPercentage) { return { svgSize: COMPACT_SVG_SIZE, @@ -85,12 +84,14 @@ function getMeterGeometry(showPercentage: boolean) { containerStyle: styles.containerWithLabel, }; } + const resolvedSize = glyphSize ?? SVG_SIZE; + const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH; return { - svgSize: SVG_SIZE, - center: CENTER, - radius: RADIUS, - strokeWidth: STROKE_WIDTH, - circumference: CIRCUMFERENCE, + svgSize: resolvedSize, + center: resolvedSize / 2, + radius: (resolvedSize - resolvedStrokeWidth) / 2, + strokeWidth: resolvedStrokeWidth, + circumference: Math.PI * (resolvedSize - resolvedStrokeWidth), containerStyle: styles.container, }; } @@ -103,6 +104,7 @@ export function ContextWindowMeter({ serverId, provider, pending = false, + glyphSize, }: ContextWindowMeterProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -123,7 +125,7 @@ export function ContextWindowMeter({ [refreshProviderUsage], ); - const geometry = getMeterGeometry(showPercentage); + const geometry = getMeterGeometry(showPercentage, glyphSize); // No usage yet: reserve the footprint with a track-only ring while a session is // active so the real ring fades in without shifting siblings. Render nothing when diff --git a/packages/app/src/components/icons/omp-icon.tsx b/packages/app/src/components/icons/omp-icon.tsx index 23780a5a8aa..76c6169d899 100644 --- a/packages/app/src/components/icons/omp-icon.tsx +++ b/packages/app/src/components/icons/omp-icon.tsx @@ -7,7 +7,7 @@ interface OmpIconProps { export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) { return ( - + ); diff --git a/packages/app/src/components/icons/pi-icon.tsx b/packages/app/src/components/icons/pi-icon.tsx index 6dc97fa2909..81d09834799 100644 --- a/packages/app/src/components/icons/pi-icon.tsx +++ b/packages/app/src/components/icons/pi-icon.tsx @@ -7,7 +7,7 @@ interface PiIconProps { export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) { return ( - + { + const codex = provider("codex", "Codex"); + const pi = provider("pi", "Pi"); + + it("opens a sole provider directly", () => { + expect( + resolveInitialModelBrowserView({ + providers: [pi], + selectedProvider: "", + selectedModel: "", + favoriteKeys: new Set(), + }), + ).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" }); + }); + + it("opens the selected provider when its model is not a favorite", () => { + expect( + resolveInitialModelBrowserView({ + providers: [codex, pi], + selectedProvider: "pi", + selectedModel: "pi-pro", + favoriteKeys: new Set(), + }), + ).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" }); + }); + + it("opens the provider overview when the selected model is a favorite", () => { + expect( + resolveInitialModelBrowserView({ + providers: [codex, pi], + selectedProvider: "pi", + selectedModel: "pi-pro", + favoriteKeys: new Set(["pi:pi-pro"]), + }), + ).toEqual({ kind: "all" }); + }); +}); diff --git a/packages/app/src/components/model-browser-view.ts b/packages/app/src/components/model-browser-view.ts new file mode 100644 index 00000000000..9452b956d61 --- /dev/null +++ b/packages/app/src/components/model-browser-view.ts @@ -0,0 +1,40 @@ +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; + +export type ModelBrowserView = + | { kind: "all" } + | { kind: "provider"; providerId: string; providerLabel: string }; + +export function resolveInitialModelBrowserView({ + providers, + selectedProvider, + selectedModel, + favoriteKeys, +}: { + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; +}): ModelBrowserView { + const singleProvider = providers.length === 1 ? providers[0] : undefined; + if (singleProvider) { + return { + kind: "provider", + providerId: singleProvider.id, + providerLabel: singleProvider.label, + }; + } + + const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`; + const shouldOpenSelectedProvider = + selectedProvider.length > 0 && + selectedModel.length > 0 && + !favoriteKeys.has(selectedFavoriteKey); + if (shouldOpenSelectedProvider) { + const provider = providers.find((entry) => entry.id === selectedProvider); + if (provider) { + return { kind: "provider", providerId: provider.id, providerLabel: provider.label }; + } + } + + return { kind: "all" }; +} diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx new file mode 100644 index 00000000000..7f656c8c4da --- /dev/null +++ b/packages/app/src/components/model-browser.tsx @@ -0,0 +1,1169 @@ +import { createContext, useCallback, useContext, useMemo, useReducer, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FlatList, + Platform, + Pressable, + ScrollView, + Text, + View, + type AccessibilityActionEvent, + type GestureResponderEvent, + type PressableStateCallbackType, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { AlertTriangle, Check, ChevronRight, Search, Settings, Star } from "lucide-react-native"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { getProviderIcon } from "@/components/provider-icons"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative, isWeb } from "@/constants/platform"; +import { + buildSelectedTriggerLabel, + filterAndRankModelRows, + getAllProviderModelRows, + getProviderModelRows, + resolveSelectedModelLabel, + type ProviderSelectionModelRow, + type ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; +import { useProviderSettingsStore } from "@/stores/provider-settings-store"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { + resolveInitialModelBrowserView, + type ModelBrowserView, +} from "@/components/model-browser-view"; + +const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220; +const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400; +const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80; +const DESKTOP_MODEL_ROW_HEIGHT = 40; + +const ThemedAlertTriangle = withUnistyles(AlertTriangle); +const ThemedCheck = withUnistyles(Check); +const ThemedChevronRight = withUnistyles(ChevronRight); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const ThemedSearch = withUnistyles(Search); +const ThemedSettings = withUnistyles(Settings); +const ThemedStar = withUnistyles(Star); + +const IndependentScrollGestureContext = createContext | null>( + null, +); + +const foregroundMutedMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({ + color: disabled ? theme.colors.border : theme.colors.foregroundMuted, +}); + +const favoriteStarMapping = + (isFavorite: boolean, hovered: boolean) => + (theme: Theme): { color: string; fill: string } => { + const favoriteColor = theme.colors.palette.amber[500]; + if (isFavorite) { + return { color: favoriteColor, fill: favoriteColor }; + } + return { + color: hovered ? theme.colors.foregroundMuted : theme.colors.border, + fill: "transparent", + }; + }; + +interface ModelBrowserInput { + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + isLoading: boolean; + favoriteKeys: Set; + serverId?: string | null; +} + +export interface ModelBrowserState { + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; + view: ModelBrowserView; + searchQuery: string; + header: SheetHeader; + selectedModelLabel: string; + triggerLabel: string; + desktopFixedHeight: number | undefined; + isProviderView: boolean; + prepareToOpen: () => void; + reset: () => void; + drillDown: (providerId: string, providerLabel: string) => void; +} + +interface ModelBrowserProps { + state: ModelBrowserState; + onSelect: (provider: string, modelId: string) => void; + onToggleFavorite?: (provider: string, modelId: string) => void; + onRetryProvider?: (provider: AgentProvider) => void; + isRetryingProvider?: boolean; + scrolling?: "sheet" | "independent"; +} + +interface ModelBrowserContentProps extends Omit { + view: ModelBrowserView; + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + searchQuery: string; + favoriteKeys: Set; + onDrillDown: (providerId: string, providerLabel: string) => void; + scrolling: "sheet" | "independent"; +} + +type ProviderGlyphTone = "muted" | "foreground"; + +export function ModelProviderGlyph({ + provider, + size, + tone = "muted", +}: { + provider: string; + size: number; + tone?: ProviderGlyphTone; +}) { + const Icon = getProviderIcon(provider); + const color = + tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color; + return ; +} + +function HeaderSettingsIcon({ disabled }: { disabled: boolean }) { + const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]); + return ; +} + +function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) { + const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]); + return ; +} + +function favoriteButtonStyle({ + hovered, + pressed, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [ + styles.favoriteButton, + Boolean(hovered) && styles.favoriteButtonHovered, + pressed && styles.favoriteButtonPressed, + ]; +} + +function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) { + return [ + styles.rowIconButton, + Boolean(hovered) && styles.rowIconButtonHovered, + pressed && styles.rowIconButtonPressed, + ]; +} + +function resolveDesktopFixedHeight( + view: ModelBrowserView, + providers: ProviderSelectorProvider[], +): number | undefined { + if (view.kind !== "provider") { + return undefined; + } + const provider = providers.find((entry) => entry.id === view.providerId); + if (!provider || provider.modelSelection.kind !== "models") { + return DESKTOP_PROVIDER_VIEW_MIN_HEIGHT; + } + const modelCount = getProviderModelRows(provider).length; + return Math.min( + Math.max( + DESKTOP_PROVIDER_VIEW_MIN_HEIGHT, + DESKTOP_PROVIDER_VIEW_BASE_HEIGHT + modelCount * DESKTOP_MODEL_ROW_HEIGHT, + ), + DESKTOP_PROVIDER_VIEW_MAX_HEIGHT, + ); +} + +export function useModelBrowser({ + providers, + selectedProvider, + selectedModel, + isLoading, + favoriteKeys, + serverId = null, +}: ModelBrowserInput): ModelBrowserState { + const { t } = useTranslation(); + const [view, setView] = useState({ kind: "all" }); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); + + const initialView = useMemo( + () => + resolveInitialModelBrowserView({ + providers, + selectedProvider, + selectedModel, + favoriteKeys, + }), + [favoriteKeys, providers, selectedModel, selectedProvider], + ); + + const prepareToOpen = useCallback(() => { + setView(initialView); + }, [initialView]); + + const reset = useCallback(() => { + setSearchQuery(""); + bumpSearchResetKey(); + }, []); + + const handleBackToAll = useCallback(() => { + setView({ kind: "all" }); + reset(); + }, [reset]); + + const drillDown = useCallback((providerId: string, providerLabel: string) => { + setView({ kind: "provider", providerId, providerLabel }); + }, []); + + const handleSearchQueryChange = useCallback((value: string) => { + setSearchQuery(value); + }, []); + + const openProviderSettings = useCallback(() => { + if (!serverId || view.kind !== "provider") return; + useProviderSettingsStore.getState().open({ serverId, provider: view.providerId }); + }, [serverId, view]); + + const singleProviderView = providers.length === 1; + const header = useMemo(() => { + if (view.kind === "all") { + return { title: t("modelSelector.title") }; + } + return { + title: view.providerLabel, + leading: ( + + ), + back: singleProviderView ? undefined : { onPress: handleBackToAll }, + actions: ( + + + + ), + search: { + onChange: handleSearchQueryChange, + resetKey: `${view.providerId}:${searchResetKey}`, + placeholder: t("modelSelector.searchPlaceholder"), + autoFocus: isWeb, + testID: "model-search-input", + }, + }; + }, [ + handleBackToAll, + handleSearchQueryChange, + openProviderSettings, + searchResetKey, + serverId, + singleProviderView, + t, + view, + ]); + + const selectedModelLabel = useMemo( + () => + resolveSelectedModelLabel({ + providers, + selectedProvider, + selectedModel, + isLoading, + }), + [isLoading, providers, selectedModel, selectedProvider], + ); + + const triggerLabel = useMemo(() => { + const isPlaceholder = + selectedModelLabel === t("modelSelector.loading") || + selectedModelLabel === t("modelSelector.selectModel"); + return isPlaceholder ? selectedModelLabel : buildSelectedTriggerLabel(selectedModelLabel); + }, [selectedModelLabel, t]); + + const desktopFixedHeight = useMemo( + () => resolveDesktopFixedHeight(view, providers), + [providers, view], + ); + + return { + providers, + selectedProvider, + selectedModel, + favoriteKeys, + view, + searchQuery, + header, + selectedModelLabel, + triggerLabel, + desktopFixedHeight, + isProviderView: view.kind === "provider", + prepareToOpen, + reset, + drillDown, + }; +} + +function normalizeSearchQuery(value: string): string { + return value.trim().toLowerCase(); +} + +function sortFavoritesFirst( + rows: ProviderSelectionModelRow[], + favoriteKeys: Set, +): ProviderSelectionModelRow[] { + const favorites: ProviderSelectionModelRow[] = []; + const rest: ProviderSelectionModelRow[] = []; + for (const row of rows) { + if (favoriteKeys.has(row.favoriteKey)) { + favorites.push(row); + } else { + rest.push(row); + } + } + return [...favorites, ...rest]; +} + +interface ModelBrowserPressableProps { + children: React.ReactNode | ((state: PressableStateCallbackType) => React.ReactNode); + style?: + | StyleProp + | ((state: PressableStateCallbackType & { hovered?: boolean }) => StyleProp); + onPress: () => void; + hitSlop?: number; + accessibilityLabel?: string; + testID?: string; +} + +function ModelBrowserPressable({ + children, + style, + onPress, + hitSlop, + accessibilityLabel, + testID, +}: ModelBrowserPressableProps) { + const independentScrollGesture = useContext(IndependentScrollGestureContext); + const [pressed, setPressed] = useState(false); + // Android's scroll handler must keep the pointer stream until release so a + // fling survives leaving the short viewport. A simultaneous Tap keeps rows + // interactive, while maxDistance makes a real scroll fail instead of select. + const tapGesture = useMemo(() => { + const gesture = Gesture.Tap() + .maxDistance(8) + .shouldCancelWhenOutside(true) + .runOnJS(true) + .onBegin(() => setPressed(true)) + .onEnd((_event, success) => { + if (success) onPress(); + }) + .onFinalize(() => setPressed(false)); + if (hitSlop !== undefined) gesture.hitSlop(hitSlop); + if (independentScrollGesture) { + gesture.simultaneousWithExternalGesture(independentScrollGesture); + } + return gesture; + }, [hitSlop, independentScrollGesture, onPress]); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + onPress(); + }, + [onPress], + ); + const handleAccessibilityAction = useCallback( + (event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === "activate") onPress(); + }, + [onPress], + ); + + if (!independentScrollGesture) { + return ( + + {children} + + ); + } + + const state = { pressed }; + const resolvedStyle = typeof style === "function" ? style(state) : style; + const resolvedChildren = typeof children === "function" ? children(state) : children; + return ( + + + {resolvedChildren} + + + ); +} + +type ModelBrowserRowTone = "default" | "elevated" | "drillDown"; + +function ModelBrowserRow({ + label, + description, + leadingSlot, + trailingSlot, + selected = false, + selectionIndicator = false, + tone = "default", + spacing = "model", + onPress, + testID, +}: { + label: string; + description?: string; + leadingSlot: React.ReactNode; + trailingSlot?: React.ReactNode; + selected?: boolean; + selectionIndicator?: boolean; + tone?: ModelBrowserRowTone; + spacing?: "model" | "provider"; + onPress: () => void; + testID?: string; +}) { + const pressableStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.browserRow, + spacing === "model" && styles.browserModelRow, + Boolean(hovered) && + (tone === "elevated" ? styles.browserRowHoveredElevated : styles.browserRowHovered), + pressed && (tone === "default" ? styles.browserRowPressed : styles.browserRowPressedElevated), + ], + [spacing, tone], + ); + const contentStyle = useMemo( + () => [styles.browserRowText, description && styles.browserRowTextInline], + [description], + ); + const hasTrailing = selected || trailingSlot; + + return ( + + + {leadingSlot} + + + {label} + + {description ? ( + + {description} + + ) : null} + + {hasTrailing ? ( + + {selectionIndicator ? ( + + {selected ? ( + + ) : null} + + ) : null} + {trailingSlot} + + ) : null} + + + ); +} + +function ModelRow({ + row, + isSelected, + isFavorite, + elevated = false, + onPress, + onToggleFavorite, +}: { + row: ProviderSelectionModelRow; + isSelected: boolean; + isFavorite: boolean; + elevated?: boolean; + onPress: () => void; + onToggleFavorite?: (provider: string, modelId: string) => void; +}) { + const { t } = useTranslation(); + const handleToggleFavorite = useCallback( + () => onToggleFavorite?.(row.provider, row.modelId), + [onToggleFavorite, row.modelId, row.provider], + ); + const leadingSlot = useMemo( + () => , + [row.provider], + ); + const trailingSlot = useMemo( + () => + onToggleFavorite ? ( + + {({ hovered }) => } + + ) : null, + [handleToggleFavorite, isFavorite, onToggleFavorite, row.modelId, row.provider, t], + ); + + return ( + + ); +} + +function SelectableModelRow({ + row, + isSelected, + isFavorite, + elevated, + onSelect, + onToggleFavorite, +}: { + row: ProviderSelectionModelRow; + isSelected: boolean; + isFavorite: boolean; + elevated?: boolean; + onSelect: (provider: string, modelId: string) => void; + onToggleFavorite?: (provider: string, modelId: string) => void; +}) { + const handlePress = useCallback(() => { + onSelect(row.provider, row.modelId); + }, [onSelect, row.modelId, row.provider]); + return ( + + ); +} + +function FavoritesSection({ + favoriteRows, + selectedProvider, + selectedModel, + favoriteKeys, + onSelect, + onToggleFavorite, +}: { + favoriteRows: ProviderSelectionModelRow[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; + onSelect: (provider: string, modelId: string) => void; + onToggleFavorite?: (provider: string, modelId: string) => void; +}) { + const { t } = useTranslation(); + if (favoriteRows.length === 0) return null; + return ( + + + {t("modelSelector.favorites")} + + {favoriteRows.map((row) => ( + + ))} + + ); +} + +function GroupProviderButton({ + provider, + onDrillDown, +}: { + provider: ProviderSelectorProvider; + onDrillDown: (providerId: string, providerLabel: string) => void; +}) { + const { t } = useTranslation(); + const selection = provider.modelSelection; + const handlePress = useCallback(() => { + onDrillDown(provider.id, provider.label); + }, [onDrillDown, provider.id, provider.label]); + + const stateNode = useMemo(() => { + if (selection.kind === "models") { + const count = selection.rows.length; + return ( + + {t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", { + count, + })} + + ); + } + if (selection.kind === "loading") { + return ( + + + + + {t("modelSelector.loadingShort")} + + ); + } + return ( + + + {t("modelSelector.error")} + + ); + }, [selection, t]); + const leadingSlot = useMemo( + () => , + [provider.id], + ); + const trailingSlot = useMemo( + () => ( + + {stateNode} + + + ), + [stateNode], + ); + + return ( + + ); +} + +function GroupedProviderRows({ + providers, + onDrillDown, +}: { + providers: ProviderSelectorProvider[]; + onDrillDown: (providerId: string, providerLabel: string) => void; +}) { + return ( + + {providers.map((provider, index) => ( + + {index > 0 ? : null} + + + ))} + + ); +} + +function IndependentScrollBoundary({ children }: { children: React.ReactElement }) { + // Prevent the parent sheet from cancelling Android's native scroll when the + // finger crosses this viewport; receiving ACTION_UP is what preserves fling. + const nativeScrollGesture = useMemo( + () => + Gesture.Native() + .shouldActivateOnStart(true) + .shouldCancelWhenOutside(false) + .disallowInterruption(true), + [], + ); + + if (Platform.OS !== "android") { + return children; + } + + return ( + + {children} + + ); +} + +function IndependentModelList({ + rows, + renderItem, +}: { + rows: ProviderSelectionModelRow[]; + renderItem: ({ item }: { item: ProviderSelectionModelRow }) => React.ReactElement; +}) { + return ( + + + + ); +} + +function getModelRowKey(row: ProviderSelectionModelRow): string { + return row.favoriteKey; +} + +function IndependentProviderList({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +function ProviderModelRows({ + rows, + selectedProvider, + selectedModel, + favoriteKeys, + onSelect, + onToggleFavorite, + normalizedQuery, + scrolling, +}: { + rows: ProviderSelectionModelRow[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; + onSelect: (provider: string, modelId: string) => void; + onToggleFavorite?: (provider: string, modelId: string) => void; + normalizedQuery: string; + scrolling: "sheet" | "independent"; +}) { + const isCompact = useIsCompactFormFactor(); + const displayRows = useMemo( + () => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)), + [favoriteKeys, normalizedQuery, rows], + ); + const renderItem = useCallback( + ({ item }: { item: ProviderSelectionModelRow }) => ( + + ), + [favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider], + ); + const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []); + + if (scrolling === "independent") { + return ; + } + + if (isCompact && isNative) { + return ( + + ); + } + + return ( + + {displayRows.map((row) => ( + {renderItem({ item: row })} + ))} + + ); +} + +function ProviderErrorEmptyState({ + providerId, + message, + onRetryProvider, + isRetryingProvider, +}: { + providerId: string; + message: string; + onRetryProvider?: (provider: AgentProvider) => void; + isRetryingProvider: boolean; +}) { + const { t } = useTranslation(); + const handleRetry = useCallback(() => { + onRetryProvider?.(providerId); + }, [onRetryProvider, providerId]); + return ( + + + {message} + {onRetryProvider ? ( + + ) : null} + + ); +} + +function ModelBrowserContent({ + view, + providers, + selectedProvider, + selectedModel, + searchQuery, + favoriteKeys, + onSelect, + onToggleFavorite, + onDrillDown, + onRetryProvider, + isRetryingProvider = false, + scrolling, +}: ModelBrowserContentProps) { + const { t } = useTranslation(); + const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); + const selectedViewProvider = useMemo( + () => + view.kind === "provider" + ? providers.find((provider) => provider.id === view.providerId) + : null, + [providers, view], + ); + const visibleRows = useMemo( + () => + selectedViewProvider + ? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery) + : [], + [normalizedQuery, selectedViewProvider], + ); + const favoriteRows = useMemo( + () => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)), + [favoriteKeys, providers], + ); + const hasResults = favoriteRows.length > 0 || providers.length > 0; + const emptyState = ( + + + {t("modelSelector.noMatches")} + + ); + + if (view.kind === "provider") { + if (!selectedViewProvider) return emptyState; + const selection = selectedViewProvider.modelSelection; + if (selection.kind === "loading") { + return ( + + + + + {t("modelSelector.loadingShort")} + + ); + } + if (selection.kind === "error") { + return ( + + ); + } + if (visibleRows.length === 0) return emptyState; + return ( + + ); + } + + const allProvidersContent = ( + + + {providers.length > 0 ? ( + + ) : null} + {!hasResults ? emptyState : null} + + ); + + return scrolling === "independent" ? ( + {allProvidersContent} + ) : ( + allProvidersContent + ); +} + +export function ModelBrowser({ + state, + onSelect, + onToggleFavorite, + onRetryProvider, + isRetryingProvider = false, + scrolling = "sheet", +}: ModelBrowserProps) { + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + favoritesContainer: { + backgroundColor: theme.colors.surface1, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + separator: { + height: 1, + backgroundColor: theme.colors.border, + }, + sectionHeading: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: isWeb ? theme.spacing[3] : theme.spacing[6], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, + sectionHeadingText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + browserRow: { + flexDirection: "row", + paddingVertical: theme.spacing[2], + minHeight: 36, + }, + browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] }, + browserRowHovered: { + backgroundColor: theme.colors.surface1, + }, + browserRowHoveredElevated: { + backgroundColor: theme.colors.surface2, + }, + browserRowPressed: { + backgroundColor: theme.colors.surface1, + }, + browserRowPressedElevated: { + backgroundColor: theme.colors.surface2, + }, + browserRowContent: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: isWeb ? theme.spacing[3] : theme.spacing[6], + }, + browserRowLeading: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + browserRowText: { + flex: 1, + flexShrink: 1, + }, + browserRowTextInline: { + flexDirection: "row", + alignItems: "baseline", + gap: theme.spacing[2], + }, + browserRowLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + flexShrink: 0, + }, + browserRowDescription: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, + browserRowTrailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + marginLeft: "auto", + }, + browserRowSelection: { + width: 16, + alignItems: "center", + justifyContent: "center", + }, + drillDownTrailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + drillDownCount: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + rowStateInline: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flexShrink: 1, + minWidth: 0, + }, + rowIconButton: { + width: 24, + height: 24, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + rowSpinner: { + transform: [{ scale: 0.7 }], + }, + rowIconButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + rowIconButtonPressed: { + backgroundColor: theme.colors.surface1, + }, + emptyState: { + paddingVertical: theme.spacing[4], + alignItems: "center", + gap: theme.spacing[2], + }, + emptyStateText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + virtualizedModelList: { + flex: 1, + }, + virtualizedModelListContent: { + paddingTop: theme.spacing[1], + paddingBottom: theme.spacing[8], + }, + virtualizedProviderListContent: { + paddingTop: 0, + }, + favoriteButton: { + width: 24, + height: 24, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + favoriteButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + favoriteButtonPressed: { + backgroundColor: theme.colors.surface1, + }, + providerIconMuted: { + color: theme.colors.foregroundMuted, + }, + providerIconForeground: { + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 81c8f0ef0c2..2d862a13359 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -24,6 +24,7 @@ import { type ViewStyle, } from "react-native"; import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { @@ -100,6 +101,8 @@ export interface ComboboxProps { */ header?: SheetHeader; mobileChildrenScrollEnabled?: boolean; + /** Overrides the mobile scroll container spacing for custom child content. */ + mobileChildrenContentContainerStyle?: StyleProp; presentation?: "push" | "replace"; open?: boolean; onOpenChange?: (open: boolean) => void; @@ -934,6 +937,7 @@ interface MobileBodyProps { searchable: boolean; hasChildren: boolean; mobileChildrenScrollEnabled: boolean; + mobileChildrenContentContainerStyle: StyleProp; presentation?: "push" | "replace"; searchResetKey: number; searchPlaceholder: string; @@ -947,6 +951,7 @@ interface MobileBodyProps { handleSelect: (id: string) => void; renderOption: RenderOptionFn | undefined; children: ReactNode; + safeAreaBottom: number; } function MobileComboboxBody(props: MobileBodyProps): ReactElement { @@ -966,6 +971,10 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], ); + const frameStyle = useMemo( + () => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }], + [props.safeAreaBottom], + ); const body = props.hasChildren ? ( props.children @@ -996,40 +1005,46 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { keyboardBlurBehavior="none" presentation={props.presentation} > - {props.header ? ( - - ) : ( - <> - - - {props.title} - - - {props.stickyHeader} - {!props.hasChildren && props.searchable ? ( - - ) : null} - - )} - {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( - body - ) : ( - - {body} - - )} - {props.footer ? {props.footer} : null} + + {props.header ? ( + + ) : ( + <> + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + )} + {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( + body + ) : ( + + {body} + + )} + {props.footer ? {props.footer} : null} + ); } @@ -1222,6 +1237,7 @@ export function Combobox({ title, header, mobileChildrenScrollEnabled = true, + mobileChildrenContentContainerStyle, presentation, open, onOpenChange, @@ -1241,6 +1257,7 @@ export function Combobox({ const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch"); const resolvedTitle = title ?? t("common.actions.select"); const isMobile = useIsCompactFormFactor(); + const safeAreaInsets = useSafeAreaInsets(); const titleColor = theme.colors.foreground; const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition); const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition); @@ -1515,6 +1532,7 @@ export function Combobox({ searchable={searchable} hasChildren={hasChildren} mobileChildrenScrollEnabled={mobileChildrenScrollEnabled} + mobileChildrenContentContainerStyle={mobileChildrenContentContainerStyle} presentation={presentation} searchResetKey={searchResetKey} searchPlaceholder={effectiveSearchPlaceholder} @@ -1527,6 +1545,7 @@ export function Combobox({ emptyText={resolvedEmptyText} handleSelect={handleSelect} renderOption={renderOption} + safeAreaBottom={safeAreaInsets.bottom} > {children} @@ -1569,6 +1588,14 @@ export function Combobox({ } const styles = StyleSheet.create((theme) => ({ + mobileSheetFrame: { + flex: 1, + minHeight: 0, + }, + mobileSheetBody: { + flex: 1, + minHeight: 0, + }, searchInputContainer: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 1ebe3ea9c05..e26cfedbc78 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -6,11 +6,9 @@ import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; -import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; @@ -382,7 +380,6 @@ export function WorkspaceSetupDialog() { const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle); const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); - const isCompact = useIsCompactFormFactor(); const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]); const agentControlsWithDisabled = useMemo( () => @@ -395,14 +392,6 @@ export function WorkspaceSetupDialog() { [composerState, pendingAction], ); - const composerFooter = useMemo( - () => - isCompact && agentControlsWithDisabled ? ( - - ) : undefined, - [isCompact, agentControlsWithDisabled], - ); - const subtitleContent = useMemo( () => ( @@ -457,7 +446,6 @@ export function WorkspaceSetupDialog() { commandDraftConfig={composerState?.commandDraftConfig} agentControls={agentControlsWithDisabled} inputWrapperStyle={styles.composerInputWrapper} - footer={composerFooter} /> diff --git a/packages/app/src/composer/agent-controls/control.tsx b/packages/app/src/composer/agent-controls/control.tsx new file mode 100644 index 00000000000..f8d23390f9e --- /dev/null +++ b/packages/app/src/composer/agent-controls/control.tsx @@ -0,0 +1,173 @@ +import { forwardRef, useCallback, type ComponentType } from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; +import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; +import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; + +export interface AgentControlIconProps { + size?: number; + color?: string; +} + +export type AgentControlIcon = ComponentType; + +interface AgentControlTriggerProps { + icon: AgentControlIcon; + iconColor?: string; + surface: "toolbar" | "sheet"; + label: string; + value?: string; + showToolbarLabel?: boolean; + showCaret?: boolean; + open?: boolean; + disabled?: boolean; + onPress: () => void; + accessibilityLabel: string; + testID?: string; +} + +export const AgentControlTrigger = forwardRef( + function AgentControlTrigger( + { + icon: Icon, + iconColor, + surface, + label, + value, + showToolbarLabel = true, + showCaret = false, + open = false, + disabled = false, + onPress, + accessibilityLabel, + testID, + }, + ref, + ) { + const { glyphSize } = useComposerControlLayout(); + const isSheet = surface === "sheet"; + const resolvedGlyphSize = isSheet ? 16 : glyphSize; + const resolvedIconColor = iconColor ?? styles.iconColor.color; + const showValue = isSheet || showToolbarLabel; + const triggerStyle = useCallback( + ({ pressed, hovered }: PressableStateCallbackType) => [ + isSheet ? styles.sheetRow : styles.toolbarControl, + !isSheet && !showToolbarLabel && styles.toolbarIconOnly, + hovered && (isSheet ? styles.sheetRowInteractive : styles.hovered), + (pressed || open) && (isSheet ? styles.sheetRowInteractive : styles.pressed), + disabled && styles.disabled, + ], + [disabled, isSheet, open, showToolbarLabel], + ); + + return ( + + {isSheet ? ( + + + + ) : ( + + + + )} + {isSheet ? ( + + {label} + + ) : null} + {showValue ? ( + + {value ?? label} + + ) : null} + + ); + }, +); + +const styles = StyleSheet.create((theme) => ({ + toolbarControl: { + height: 28, + minWidth: 0, + flexShrink: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius["2xl"], + backgroundColor: "transparent", + }, + toolbarIconOnly: { + width: 28, + flexShrink: 0, + paddingHorizontal: 0, + justifyContent: "center", + }, + toolbarValue: { + minWidth: 0, + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + sheetRow: { + minHeight: 44, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginHorizontal: -theme.spacing[1], + paddingHorizontal: theme.spacing[4], + borderRadius: theme.borderRadius["2xl"], + backgroundColor: theme.colors.surface1, + }, + sheetRowInteractive: { + backgroundColor: theme.colors.surface2, + }, + sheetGlyph: { + width: 20, + height: 20, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, + sheetLabel: { + flex: 1, + minWidth: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + sheetValue: { + maxWidth: "45%", + minWidth: 0, + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + hovered: { + backgroundColor: theme.colors.surface2, + }, + pressed: { + backgroundColor: theme.colors.surface0, + }, + disabled: { + opacity: 0.5, + }, + iconColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/composer/agent-controls/glyph.tsx b/packages/app/src/composer/agent-controls/glyph.tsx new file mode 100644 index 00000000000..73b2a220c15 --- /dev/null +++ b/packages/app/src/composer/agent-controls/glyph.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import { StyleSheet, View } from "react-native"; + +export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) { + return ( + = 20 ? styles.native : styles.web} + accessibilityElementsHidden + importantForAccessibility="no-hide-descendants" + pointerEvents="none" + > + {children} + + ); +} + +const styles = StyleSheet.create({ + web: { + width: 16, + height: 16, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, + native: { + width: 20, + height: 20, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index a7ca3f62444..40b109f9635 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -1,11 +1,11 @@ import { memo, useCallback, + useEffect, useMemo, useRef, useState, type ReactElement, - type ReactNode, type RefObject, } from "react"; import { useTranslation } from "react-i18next"; @@ -14,6 +14,8 @@ import { Text, Pressable, Keyboard, + useWindowDimensions, + type LayoutChangeEvent, type PressableStateCallbackType, type StyleProp, type ViewStyle, @@ -21,9 +23,7 @@ import { import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { Brain, ListTodo, Settings2, ShieldCheck, Zap } from "lucide-react-native"; -import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; -import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { buildProviderSelectorProviders, @@ -39,9 +39,12 @@ import { toggleFavoriteModel, useFormPreferences, } from "@/hooks/use-form-preferences"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; -import { DraftAgentModeControl, AgentModeControl } from "@/composer/agent-controls/mode-control"; +import { + AgentModeControl, + useLiveAgentModeControl, + type AgentModeControlValue, +} from "@/composer/agent-controls/mode-control"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { @@ -65,6 +68,18 @@ import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { useCommandCenterActions } from "@/command-center/provider"; import { buildModelChoiceContributions } from "@/command-center/model-contributions"; import { getCommandCenterProviderIcon } from "@/command-center/provider-icon"; +import { isNative } from "@/constants/platform"; +import { + resolveComposerControlDensity, + resolveComposerControlPresentation, + resolveComposerToolbarGlyphSize, + type ComposerControlDensity, + type ComposerControlPresentation, +} from "@/composer/agent-controls/layout"; +import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout-context"; +import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; +import { AgentControlTrigger } from "@/composer/agent-controls/control"; +import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; interface AgentControlOption { id: string; @@ -96,8 +111,7 @@ interface ControlledAgentControlsProps { onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; isRetryingModelProvider?: boolean; - /** Extra elements rendered inline with the agent controls (desktop only). */ - desktopExtras?: ReactNode; + modeControl?: AgentModeControlValue | null; modelSelectorServerId?: string | null; isCompactLayout?: boolean; } @@ -186,14 +200,6 @@ function getFeatureIconColor( } } -// Mobile agent controls only — strip namespace prefix so providers like OpenCode -// show "gpt-5.5" instead of "openrouter/gpt-5.5". Full label still appears in -// the model picker. -function shortModelLabel(label: string): string { - const i = label.lastIndexOf("/"); - return i === -1 ? label : label.slice(i + 1); -} - type ActiveSheet = "thinking" | "features" | null; function resolveHasAnyControl({ @@ -201,20 +207,20 @@ function resolveHasAnyControl({ canSelectModel, thinkingOptions, features, - hasDesktopExtras, + hasMode, }: { providerOptions: AgentControlOption[] | undefined; canSelectModel: boolean; thinkingOptions: AgentControlOption[] | undefined; features: AgentFeature[] | undefined; - hasDesktopExtras: boolean; + hasMode: boolean; }) { return ( Boolean(providerOptions?.length) || canSelectModel || Boolean(thinkingOptions?.length) || Boolean(features?.length) || - hasDesktopExtras + hasMode ); } @@ -298,24 +304,23 @@ function pickDesktopModel({ modelId, currentProvider, onSelectModel, + onSelectProviderAndModel, }: { nextProviderId: string; modelId: string; currentProvider: string; onSelectModel?: (modelId: string) => void; + onSelectProviderAndModel?: (provider: string, modelId: string) => void; }) { + if (onSelectProviderAndModel) { + onSelectProviderAndModel(nextProviderId, modelId); + return; + } if (nextProviderId === currentProvider) { onSelectModel?.(modelId); } } -function resolveProviderIcon(provider: string) { - if (provider.trim().length === 0) { - return null; - } - return getProviderIcon(provider); -} - type AgentControlsSlice = { provider: string; cwd: string | null; @@ -413,7 +418,7 @@ function ControlledAgentControls({ onModelSelectorOpen, onRetryModelProvider, isRetryingModelProvider = false, - desktopExtras, + modeControl, modelSelectorServerId = null, isCompactLayout, }: ControlledAgentControlsProps) { @@ -421,8 +426,13 @@ function ControlledAgentControls({ const { t } = useTranslation(); const isCompactFormFactor = useIsCompactFormFactor(); const isCompact = isCompactLayout ?? isCompactFormFactor; + const { fontScale } = useWindowDimensions(); const [activeSheet, setActiveSheet] = useState(null); const [openSelector, setOpenSelector] = useState(null); + const initialDensity: ComposerControlDensity = isCompact ? "tight" : "full"; + const [density, setDensity] = useState(initialDensity); + const densityRef = useRef(initialDensity); + const availableWidthRef = useRef(0); const providerAnchorRef = useRef(null); const _modelAnchorRef = useRef(null); @@ -451,15 +461,72 @@ function ControlledAgentControls({ formattedThinkingOptions[0]?.label ?? t("agentControls.thinking.unknown"), ); - const ProviderIcon = resolveProviderIcon(provider); - const hasAnyControl = resolveHasAnyControl({ providerOptions, canSelectModel, thinkingOptions, features, - hasDesktopExtras: desktopExtras !== null && desktopExtras !== undefined, + hasMode: modeControl !== null && modeControl !== undefined, }); + const featureControls = useMemo( + () => + (features ?? []).map((feature) => { + if (feature.type === "toggle") return { type: "toggle" as const }; + const selectedOption = feature.options.find((option) => option.id === feature.value); + return { + type: "select" as const, + label: selectedOption?.label ?? feature.label, + }; + }), + [features], + ); + const controlPresence = useMemo( + () => ({ + hasModel: canSelectModel, + hasThinking: canSelectThinking, + hasMode: modeControl !== null && modeControl !== undefined, + features: featureControls, + fontScale, + }), + [canSelectModel, canSelectThinking, featureControls, fontScale, modeControl], + ); + const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]); + const layoutContextValue = useMemo( + () => ({ + glyphSize: resolveComposerToolbarGlyphSize(isNative ? "native" : "web"), + presentation, + }), + [presentation], + ); + + const updateDensityForWidth = useCallback( + (availableWidth: number) => { + const nextDensity = resolveComposerControlDensity({ + availableWidth, + currentDensity: densityRef.current, + controls: controlPresence, + }); + if (nextDensity === densityRef.current) return; + densityRef.current = nextDensity; + setDensity(nextDensity); + }, + [controlPresence], + ); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + const availableWidth = event.nativeEvent.layout.width; + availableWidthRef.current = availableWidth; + updateDensityForWidth(availableWidth); + }, + [updateDensityForWidth], + ); + + useEffect(() => { + if (availableWidthRef.current > 0) { + updateDensityForWidth(availableWidthRef.current); + } + }, [updateDensityForWidth]); const modelDisabled = disabled; @@ -495,6 +562,12 @@ function ControlledAgentControls({ buildOpenChangeHandler(selector, setOpenSelector, onDropdownClose), [onDropdownClose], ); + const handleSheetOpenChange = useCallback( + (selector: AgentControlSelector) => (nextOpen: boolean) => { + setOpenSelector(nextOpen ? selector : null); + }, + [], + ); const handleProviderPress = useCallback(() => { handleOpenChange("provider")(openSelector !== "provider"); @@ -518,9 +591,15 @@ function ControlledAgentControls({ const handleDesktopModelSelect = useCallback( (nextProviderId: string, modelId: string) => { - pickDesktopModel({ nextProviderId, modelId, currentProvider: provider, onSelectModel }); + pickDesktopModel({ + nextProviderId, + modelId, + currentProvider: provider, + onSelectModel, + onSelectProviderAndModel, + }); }, - [onSelectModel, provider], + [onSelectModel, onSelectProviderAndModel, provider], ); const providerPressableStyle = useMemo( @@ -534,17 +613,6 @@ function ControlledAgentControls({ [canSelectProvider, disabled, openSelector], ); - const thinkingPressableStyle = useMemo( - () => - makeBadgePressableStyle( - styles.modeBadge, - styles.disabledBadge, - disabled || !canSelectThinking, - openSelector === "thinking", - ), - [canSelectThinking, disabled, openSelector], - ); - const handleOpenSheet = useCallback((sheet: Exclude) => { Keyboard.dismiss(); setActiveSheet(sheet); @@ -552,7 +620,8 @@ function ControlledAgentControls({ const handleCloseSheet = useCallback(() => { setActiveSheet(null); - }, []); + if (!isCompact) onDropdownClose?.(); + }, [isCompact, onDropdownClose]); const handleSelectThinkingAndClose = useCallback( (thinkingOptionId: string) => { @@ -581,85 +650,94 @@ function ControlledAgentControls({ } return ( - - {!isCompact ? ( - - ) : ( - - )} - + + + {!isCompact ? ( + + ) : ( + + )} + + ); } @@ -694,7 +772,6 @@ interface DesktopAgentControlsContentProps { providerAnchorRef: RefObject; thinkingAnchorRef: RefObject; providerPressableStyle: (state: PressableStateCallbackType) => StyleProp; - thinkingPressableStyle: (state: PressableStateCallbackType) => StyleProp; handleProviderPress: () => void; handleThinkingPress: () => void; handleProviderSelect: (id: string) => void; @@ -703,13 +780,19 @@ interface DesktopAgentControlsContentProps { handleProviderOpenChange: (open: boolean) => void; handleThinkingOpenChange: (open: boolean) => void; handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; + handleNestedOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; renderThinkingOption: (args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void; }) => ReactElement; - extras?: ReactNode; + modeControl?: AgentModeControlValue | null; + presentation: ComposerControlPresentation; + glyphSize: number; + activeSheet: ActiveSheet; + handleOpenSheet: (sheet: Exclude) => void; + handleCloseSheet: () => void; modelSelectorServerId: string | null; } @@ -748,7 +831,6 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { providerAnchorRef, thinkingAnchorRef, providerPressableStyle, - thinkingPressableStyle, handleProviderPress, handleThinkingPress, handleProviderSelect, @@ -757,11 +839,25 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { handleProviderOpenChange, handleThinkingOpenChange, handleOpenChange, + handleNestedOpenChange, renderThinkingOption, - extras, + modeControl, + presentation, + glyphSize, + activeSheet, + handleOpenSheet, + handleCloseSheet, modelSelectorServerId, } = props; - + const modelToolbar = useMemo( + () => ({ glyphSize, showCaret: presentation.showCarets }), + [glyphSize, presentation.showCarets], + ); + const featuresSheetHeader = useMemo( + () => ({ title: t("agentControls.features.title") }), + [t], + ); + const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); return ( <> {providerOptions && providerOptions.length > 0 ? ( @@ -794,7 +890,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { {canSelectModel ? ( - + @@ -824,21 +921,22 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { <> - - - {displayThinking} - + /> {t(getAgentControlHintKey("thinking"))} @@ -858,18 +956,53 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { ) : null} - {extras} + {modeControl ? : null} - {features?.map((feature) => ( - - ))} + {presentation.aggregateFeatures && features?.length ? ( + <> + + + + + + + {features.map((feature) => ( + + ))} + + + ) : ( + features?.map((feature) => ( + + )) + )} ); } @@ -894,7 +1027,7 @@ interface SheetAgentControlsContentProps { modelDisabled: boolean; comboboxThinkingOptions: ComboboxOption[]; openSelector: AgentControlSelector | null; - ProviderIcon: ReturnType | null; + displayThinking: string; activeSheet: ActiveSheet; handleOpenSheet: (sheet: Exclude) => void; handleCloseSheet: () => void; @@ -907,11 +1040,12 @@ interface SheetAgentControlsContentProps { active: boolean; onPress: () => void; }) => ReactElement; + modeControl?: AgentModeControlValue | null; + glyphSize: number; modelSelectorServerId: string | null; } function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const { provider, @@ -933,7 +1067,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { modelDisabled, comboboxThinkingOptions, openSelector, - ProviderIcon, + displayThinking, activeSheet, handleOpenSheet, handleCloseSheet, @@ -941,20 +1075,16 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { handleSelectThinkingAndClose, handleOpenChange, renderThinkingOption, + modeControl, + glyphSize, modelSelectorServerId, } = props; const thinkingAnchorRef = useRef(null); const hasThinking = comboboxThinkingOptions.length > 0; - const hasFeatures = Boolean(features && features.length > 0); - const featuresSheetHeader = useMemo( - () => ({ title: t("agentControls.features.title") }), - [t], - ); const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]); - const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); const handleThinkingSheetOpenChange = useCallback( (nextOpen: boolean) => { if (nextOpen) { @@ -966,123 +1096,74 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { [handleCloseSheet, handleOpenSheet], ); - const renderModelTrigger = useCallback( - ({ - selectedModelLabel, - }: { - selectedModelLabel: string; - onPress: () => void; - disabled: boolean; - isOpen: boolean; - }) => ( - - {ProviderIcon ? ( - - ) : null} - - {shortModelLabel(selectedModelLabel)} - - - ), - [ProviderIcon, theme.iconSize.lg, theme.colors.foregroundMuted], - ); - - const thinkingButtonStyle = makeBadgePressableStyle( - styles.modeIconBadge, - styles.disabledBadge, - disabled || !canSelectThinking, - activeSheet === "thinking", - ); - const featuresButtonStyle = makeBadgePressableStyle( - styles.modeIconBadge, - styles.disabledBadge, - disabled, - activeSheet === "features", - ); - - return ( - <> - {canSelectModel ? ( - - ) : null} - + const sheetControls = ( + {hasThinking ? ( - - - + <> + + + ) : null} - {hasFeatures ? ( - - - - ) : null} + {modeControl ? : null} - {hasThinking ? ( - ( + - ) : null} - - - {(features ?? []).map((feature) => ( - - ))} - - + ))} + ); + + return canSelectModel ? ( + + {sheetControls} + + ) : null; } function DesktopFeatureItem({ @@ -1091,26 +1172,34 @@ function DesktopFeatureItem({ openSelector, handleOpenChange, onSetFeature, + onActionComplete, }: { feature: AgentFeature; disabled: boolean; openSelector: AgentControlSelector | null; handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; onSetFeature?: (featureId: string, value: unknown) => void; + onActionComplete?: () => void; }) { const { theme } = useUnistyles(); const featureSelector: AgentControlSelector = `feature-${feature.id}`; + const featureAnchorRef = useRef(null); const handleFeatureOpenChange = useMemo( () => handleOpenChange(featureSelector), [handleOpenChange, featureSelector], ); + const handleSelectPress = useCallback( + () => handleFeatureOpenChange(openSelector !== featureSelector), + [featureSelector, handleFeatureOpenChange, openSelector], + ); const handleTogglePress = useCallback(() => { if (feature.type === "toggle") { onSetFeature?.(feature.id, !feature.value); + onActionComplete?.(); } - }, [feature, onSetFeature]); + }, [feature, onActionComplete, onSetFeature]); const handleSelectOption = useCallback( (optionId: string) => { @@ -1118,25 +1207,12 @@ function DesktopFeatureItem({ }, [feature.id, onSetFeature], ); - - const togglePressableStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType) => [ - styles.modeIconBadge, - hovered && styles.modeBadgeHovered, - pressed && styles.modeBadgePressed, - disabled && styles.disabledBadge, - ], - [disabled], - ); - - const selectPressableStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType) => [ - styles.modeBadge, - hovered && styles.modeBadgeHovered, - (pressed || openSelector === featureSelector) && styles.modeBadgePressed, - disabled && styles.disabledBadge, - ], - [disabled, openSelector, featureSelector], + const comboboxOptions = useMemo( + () => + feature.type === "select" + ? feature.options.map((option) => ({ id: option.id, label: option.label })) + : [], + [feature], ); if (feature.type === "toggle") { @@ -1144,24 +1220,22 @@ function DesktopFeatureItem({ return ( - - - + /> {getFeatureTooltip(feature)} @@ -1174,35 +1248,36 @@ function DesktopFeatureItem({ const FeatureIcon = getFeatureIcon(feature.icon); const selectedOption = feature.options.find((o) => o.id === feature.value); return ( - + <> - - - {selectedOption?.label ?? feature.label} - + /> {getFeatureTooltip(feature)} - - {feature.options.map((option) => ( - - ))} - - + + ); } @@ -1225,11 +1300,17 @@ function SheetFeatureItem({ const { theme } = useUnistyles(); const { t } = useTranslation(); const featureSelector: AgentControlSelector = `feature-${feature.id}`; + const featureAnchorRef = useRef(null); const handleFeatureOpenChange = useMemo( () => handleOpenChange(featureSelector), [handleOpenChange, featureSelector], ); + const handleSelectPress = useCallback( + () => handleFeatureOpenChange(openSelector !== featureSelector), + [featureSelector, handleFeatureOpenChange, openSelector], + ); + const sheetHeader = useMemo(() => ({ title: feature.label }), [feature.label]); const handleTogglePress = useCallback(() => { if (feature.type === "toggle") { @@ -1243,101 +1324,70 @@ function SheetFeatureItem({ }, [feature.id, onSetFeature], ); - - const togglePressableStyle = useCallback( - ({ pressed }: PressableStateCallbackType) => [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - disabled && styles.disabledSheetSelect, - ], - [disabled], + const comboboxOptions = useMemo( + () => + feature.type === "select" + ? feature.options.map((option) => ({ id: option.id, label: option.label })) + : [], + [feature], ); if (feature.type === "toggle") { const FeatureIcon = getFeatureIcon(feature.icon); return ( - - - - {feature.label} - - {feature.value ? t("agentControls.features.on") : t("agentControls.features.off")} - - - + ); } if (feature.type === "select") { + const FeatureIcon = getFeatureIcon(feature.icon); const selectedOption = feature.options.find((o) => o.id === feature.value); return ( - - + + - - {selectedOption?.label ?? feature.label} - - - {feature.options.map((option) => ( - - ))} - - - + anchorRef={featureAnchorRef} + presentation="push" + header={sheetHeader} + /> + ); } return null; } -function FeatureOptionMenuItem({ - option, - selected, - onSelect, -}: { - option: { id: string; label: string }; - selected: boolean; - onSelect: (optionId: string) => void; -}) { - const handleSelect = useCallback(() => { - onSelect(option.id); - }, [onSelect, option.id]); - - return ( - - {option.label} - - ); -} - function ThinkingComboboxOption({ option, selected, @@ -1377,6 +1427,7 @@ export const AgentControls = memo(function AgentControls({ ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const toast = useToast(); + const modeControl = useLiveAgentModeControl(serverId, agentId); const { entries: snapshotEntries, @@ -1565,18 +1616,6 @@ export const AgentControls = memo(function AgentControls({ [refreshSnapshot], ); - const modeChip = useMemo( - () => ( - - ), - [serverId, agentId, isCompactLayout], - ); - if (!agent) { return null; } @@ -1601,7 +1640,7 @@ export const AgentControls = memo(function AgentControls({ isRetryingModelProvider={snapshotIsRefreshing} onDropdownClose={onDropdownClose} disabled={!client} - desktopExtras={modeChip} + modeControl={modeControl} modelSelectorServerId={serverId} isCompactLayout={isCompactLayout} /> @@ -1636,9 +1675,6 @@ export function DraftAgentControls({ isCompactLayout, }: DraftAgentControlsProps) { const { preferences, updatePreferences } = useFormPreferences(); - const isCompactFormFactor = useIsCompactFormFactor(); - const isCompact = isCompactLayout ?? isCompactFormFactor; - const mappedThinkingOptions = useMemo(() => { return toThinkingControlOptions(thinkingOptions); }, [thinkingOptions]); @@ -1673,70 +1709,21 @@ export function DraftAgentControls({ [updatePreferences], ); - const draftModeChip = useMemo( - () => ( - - ), - [ - selectedProvider, - providerDefinitions, - modeOptions, - selectedMode, - onSelectMode, - disabled, - isCompactLayout, - ], + const modeControl = useMemo( + () => + selectedProvider && modeOptions.length > 0 + ? { + provider: selectedProvider, + providerDefinitions, + modeOptions, + selectedModeId: selectedMode, + onSelectMode, + disabled, + } + : null, + [selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled], ); - if (!isCompact) { - return ( - - - {selectedProvider ? ( - 0 ? mappedThinkingOptions : undefined} - selectedThinkingOptionId={effectiveSelectedThinkingOption} - onSelectThinkingOption={onSelectThinkingOption} - features={features} - onSetFeature={onSetFeature} - onDropdownClose={onDropdownClose} - onRetryModelProvider={onRetryModelProvider} - isRetryingModelProvider={isRetryingModelProvider} - disabled={disabled} - desktopExtras={draftModeChip} - isCompactLayout={isCompactLayout} - /> - ) : null} - - ); - } - return ( @@ -1765,9 +1754,13 @@ export function DraftAgentControls({ const styles = StyleSheet.create((theme) => ({ container: { + minWidth: 0, + flexGrow: 1, + flexShrink: 1, flexDirection: "row", - alignItems: "flex-end", + alignItems: "center", gap: theme.spacing[1], + overflow: "hidden", }, modeBadge: { height: 28, @@ -1778,11 +1771,22 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius["2xl"], }, + modelControl: { + minWidth: 0, + flexShrink: 1, + }, + toolbarCaret: { + width: 14, + height: 14, + flexShrink: 0, + }, modeIconBadge: { width: 28, height: 28, alignItems: "center", justifyContent: "center", + paddingHorizontal: 0, + flexShrink: 0, backgroundColor: "transparent", borderRadius: theme.borderRadius.full, }, @@ -1796,6 +1800,8 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0.5, }, modeBadgeText: { + minWidth: 0, + flexShrink: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, @@ -1805,47 +1811,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.4, }, - prefsButton: { - height: 28, - minWidth: 0, - flexShrink: 1, - flexDirection: "row", - alignItems: "center", + combinedSheetControls: { gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], - }, - prefsButtonText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - flexShrink: 1, - }, - sheetSection: { - gap: theme.spacing[2], - }, - sheetSelect: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.surface2, - backgroundColor: theme.colors.surface0, - }, - sheetSelectPressed: { - backgroundColor: theme.colors.surface2, - }, - disabledSheetSelect: { - opacity: 0.5, - }, - sheetSelectText: { - flex: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, }, })); diff --git a/packages/app/src/composer/agent-controls/layout-context.tsx b/packages/app/src/composer/agent-controls/layout-context.tsx new file mode 100644 index 00000000000..edf8fec7aa5 --- /dev/null +++ b/packages/app/src/composer/agent-controls/layout-context.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { ComposerControlPresentation } from "@/composer/agent-controls/layout"; + +interface ComposerControlLayoutValue { + glyphSize: number; + presentation: ComposerControlPresentation; +} + +const DEFAULT_LAYOUT: ComposerControlLayoutValue = { + glyphSize: 16, + presentation: { + showCarets: true, + showThinkingLabel: true, + showModeLabel: true, + aggregateFeatures: false, + }, +}; + +const ComposerControlLayoutContext = createContext(DEFAULT_LAYOUT); + +export function ComposerControlLayoutProvider({ + value, + children, +}: { + value: ComposerControlLayoutValue; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useComposerControlLayout(): ComposerControlLayoutValue { + return useContext(ComposerControlLayoutContext); +} diff --git a/packages/app/src/composer/agent-controls/layout.test.ts b/packages/app/src/composer/agent-controls/layout.test.ts new file mode 100644 index 00000000000..2e52972b1c2 --- /dev/null +++ b/packages/app/src/composer/agent-controls/layout.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + COMPOSER_TOOLBAR_GEOMETRY, + resolveComposerControlDensity, + resolveComposerControlPresentation, + resolveComposerToolbarGlyphSize, +} from "./layout"; + +describe("composer control layout", () => { + it("removes labels in priority order as the toolbar narrows", () => { + expect(resolveComposerControlPresentation("full")).toEqual({ + showCarets: true, + showThinkingLabel: true, + showModeLabel: true, + aggregateFeatures: false, + }); + expect(resolveComposerControlPresentation("condensed")).toEqual({ + showCarets: false, + showThinkingLabel: false, + showModeLabel: true, + aggregateFeatures: true, + }); + expect(resolveComposerControlPresentation("tight")).toEqual({ + showCarets: false, + showThinkingLabel: false, + showModeLabel: false, + aggregateFeatures: true, + }); + }); + + it("uses local available width and hysteresis to avoid density churn", () => { + const controls = { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "toggle" as const }], + fontScale: 1, + }; + + expect( + resolveComposerControlDensity({ + availableWidth: 420, + currentDensity: "full", + controls, + }), + ).toBe("full"); + expect( + resolveComposerControlDensity({ + availableWidth: 380, + currentDensity: "full", + controls, + }), + ).toBe("condensed"); + expect( + resolveComposerControlDensity({ + availableWidth: 290, + currentDensity: "condensed", + controls, + }), + ).toBe("condensed"); + expect( + resolveComposerControlDensity({ + availableWidth: 280, + currentDensity: "condensed", + controls, + }), + ).toBe("tight"); + expect( + resolveComposerControlDensity({ + availableWidth: 300, + currentDensity: "tight", + controls, + }), + ).toBe("tight"); + expect( + resolveComposerControlDensity({ + availableWidth: 312, + currentDensity: "tight", + controls, + }), + ).toBe("condensed"); + }); + + it("budgets extra features and larger text before restoring full labels", () => { + const base = { + availableWidth: 430, + currentDensity: "condensed" as const, + }; + + expect( + resolveComposerControlDensity({ + ...base, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "toggle" }], + fontScale: 1, + }, + }), + ).toBe("full"); + expect( + resolveComposerControlDensity({ + ...base, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "toggle" }, { type: "select", label: "Tools" }], + fontScale: 1, + }, + }), + ).toBe("condensed"); + expect( + resolveComposerControlDensity({ + ...base, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [{ type: "toggle" }], + fontScale: 1.25, + }, + }), + ).toBe("condensed"); + }); + + it("condenses before a labeled feature would overflow", () => { + const base = { + availableWidth: 430, + currentDensity: "full" as const, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + fontScale: 1, + }, + }; + + expect( + resolveComposerControlDensity({ + ...base, + controls: { ...base.controls, features: [{ type: "toggle" }] }, + }), + ).toBe("full"); + expect( + resolveComposerControlDensity({ + ...base, + controls: { + ...base.controls, + features: [{ type: "select", label: "A much longer localized feature label" }], + }, + }), + ).toBe("condensed"); + }); + + it("gives every toolbar control one shell and one platform glyph envelope", () => { + expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({ + controlSize: 28, + controlGap: 4, + iconLabelGap: 4, + labelPadding: 8, + caretSize: 14, + }); + expect(resolveComposerToolbarGlyphSize("web")).toBe(16); + expect(resolveComposerToolbarGlyphSize("native")).toBe(20); + }); +}); diff --git a/packages/app/src/composer/agent-controls/layout.ts b/packages/app/src/composer/agent-controls/layout.ts new file mode 100644 index 00000000000..6f7c78cc739 --- /dev/null +++ b/packages/app/src/composer/agent-controls/layout.ts @@ -0,0 +1,134 @@ +export type ComposerControlDensity = "full" | "condensed" | "tight"; + +export interface ComposerControlPresence { + hasModel: boolean; + hasThinking: boolean; + hasMode: boolean; + features: readonly ComposerFeatureControlPresence[]; + fontScale: number; +} + +export type ComposerFeatureControlPresence = { type: "toggle" } | { type: "select"; label: string }; + +export interface ComposerControlPresentation { + showCarets: boolean; + showThinkingLabel: boolean; + showModeLabel: boolean; + aggregateFeatures: boolean; +} + +export const COMPOSER_TOOLBAR_GEOMETRY = { + controlSize: 28, + controlGap: 4, + iconLabelGap: 4, + labelPadding: 8, + caretSize: 14, +} as const; + +const DENSITY_HYSTERESIS = 12; + +function normalizedFontScale(fontScale: number): number { + return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1; +} + +function sumControlWidths(widths: number[]): number { + if (widths.length === 0) return 0; + return ( + widths.reduce((total, width) => total + width, 0) + + (widths.length - 1) * COMPOSER_TOOLBAR_GEOMETRY.controlGap + ); +} + +function estimateLabelWidth(label: string, fontScale: number): number { + return Array.from(label).length * 7 * fontScale; +} + +function resolveFeatureControlWidth( + feature: ComposerFeatureControlPresence, + fontScale: number, +): number { + if (feature.type === "toggle") return COMPOSER_TOOLBAR_GEOMETRY.controlSize; + return ( + COMPOSER_TOOLBAR_GEOMETRY.controlSize + + COMPOSER_TOOLBAR_GEOMETRY.iconLabelGap + + COMPOSER_TOOLBAR_GEOMETRY.labelPadding * 2 + + estimateLabelWidth(feature.label, fontScale) + ); +} + +function resolveCondensedFloor(controls: ComposerControlPresence): number { + const fontScale = normalizedFontScale(controls.fontScale); + const widths: number[] = []; + if (controls.hasModel) widths.push(36 + 60 * fontScale); + if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize); + if (controls.hasMode) widths.push(36 + 96 * fontScale); + if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize); + return sumControlWidths(widths); +} + +function resolveFullFloor(controls: ComposerControlPresence): number { + const fontScale = normalizedFontScale(controls.fontScale); + const widths: number[] = []; + if (controls.hasModel) widths.push(50 + 70 * fontScale); + if (controls.hasThinking) widths.push(54 + 48 * fontScale); + if (controls.hasMode) widths.push(54 + 96 * fontScale); + for (const feature of controls.features) { + widths.push(resolveFeatureControlWidth(feature, fontScale)); + } + return sumControlWidths(widths); +} + +export function resolveComposerControlDensity(input: { + availableWidth: number; + currentDensity: ComposerControlDensity; + controls: ComposerControlPresence; +}): ComposerControlDensity { + const fullFloor = resolveFullFloor(input.controls); + const condensedFloor = resolveCondensedFloor(input.controls); + + if (input.currentDensity === "full") { + if (input.availableWidth >= fullFloor - DENSITY_HYSTERESIS) return "full"; + return input.availableWidth >= condensedFloor ? "condensed" : "tight"; + } + + if (input.currentDensity === "condensed") { + if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full"; + if (input.availableWidth < condensedFloor - DENSITY_HYSTERESIS) return "tight"; + return "condensed"; + } + + if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full"; + if (input.availableWidth >= condensedFloor + DENSITY_HYSTERESIS) return "condensed"; + return "tight"; +} + +export function resolveComposerControlPresentation( + density: ComposerControlDensity, +): ComposerControlPresentation { + if (density === "full") { + return { + showCarets: true, + showThinkingLabel: true, + showModeLabel: true, + aggregateFeatures: false, + }; + } + if (density === "condensed") { + return { + showCarets: false, + showThinkingLabel: false, + showModeLabel: true, + aggregateFeatures: true, + }; + } + return { + showCarets: false, + showThinkingLabel: false, + showModeLabel: false, + aggregateFeatures: true, + }; +} + +export function resolveComposerToolbarGlyphSize(platform: "web" | "native"): number { + return platform === "native" ? 20 : 16; +} diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index e129b1e53e1..d80e289f854 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -1,5 +1,4 @@ import { - memo, useCallback, useMemo, useRef, @@ -8,7 +7,7 @@ import { type ReactElement, } from "react"; import { useTranslation } from "react-i18next"; -import { Text, View, type PressableStateCallbackType } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; @@ -22,7 +21,6 @@ import { ShieldPlus, ShieldQuestionMark, } from "lucide-react-native"; -import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -32,7 +30,6 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; import { useToast } from "@/contexts/toast-context"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils"; @@ -41,15 +38,11 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { resolveNextAgentModeId } from "@/composer/agent-controls/mode"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; -import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types"; +import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; +import { AgentControlTrigger } from "@/composer/agent-controls/control"; +import type { AgentMode } from "@getpaseo/protocol/agent-types"; import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; -export type AgentModeControlPlacement = "toolbar" | "footer"; - -function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompact: boolean) { - return placement === "footer" ? isCompact : !isCompact; -} - interface ModeIconProps { size?: number; color?: string; @@ -102,7 +95,7 @@ function ModeComboboxOption({ ); } -interface AgentModeControlViewProps { +export interface AgentModeControlValue { provider: string; providerDefinitions: AgentProviderDefinition[]; modeOptions: AgentMode[]; @@ -115,20 +108,24 @@ function normalizeSearchQuery(value: string): string { return value.trim().toLowerCase(); } -function AgentModeControlView({ +export function AgentModeControl({ provider, providerDefinitions, modeOptions, selectedModeId, onSelectMode, disabled = false, -}: AgentModeControlViewProps) { + surface = "toolbar", + onClose, +}: AgentModeControlValue & { surface?: "toolbar" | "sheet"; onClose?: () => void }) { const { theme } = useUnistyles(); + const { presentation } = useComposerControlLayout(); const { t } = useTranslation(); const { isActiveComposer } = useComposerKeyboardScope(); const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode"); const anchorRef = useRef(null); const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`); + const openRef = useRef(false); const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -140,7 +137,7 @@ function AgentModeControlView({ const visuals = selectedMode ? getModeVisuals(provider, selectedMode.id, providerDefinitions) : undefined; - const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : undefined; + const Icon = visuals?.icon ? (MODE_ICONS[visuals.icon] ?? Bot) : Bot; const iconColor = theme.colors.foregroundMuted; const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : ""; @@ -154,10 +151,18 @@ function AgentModeControlView({ return allOptions.filter((o) => o.label.toLowerCase().includes(q)); }, [allOptions, searchQuery]); - const handleOpenChange = useCallback((next: boolean) => { - setOpen(next); - if (!next) setSearchQuery(""); - }, []); + const handleOpenChange = useCallback( + (next: boolean) => { + const wasOpen = openRef.current; + openRef.current = next; + setOpen(next); + if (!next) { + setSearchQuery(""); + if (wasOpen) onClose?.(); + } + }, + [onClose], + ); const handlePress = useCallback(() => handleOpenChange(!open), [handleOpenChange, open]); const handleSelect = useCallback( @@ -208,18 +213,6 @@ function AgentModeControlView({ [provider, providerDefinitions, theme.colors.foreground], ); - const pressableStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType) => [ - styles.chip, - hovered && styles.chipHovered, - (pressed || open) && styles.chipPressed, - disabled && styles.chipDisabled, - ], - [open, disabled], - ); - - const labelStyle = styles.chipLabel; - const sheetHeader = useMemo( () => ({ title: t("agentControls.mode.title"), @@ -238,21 +231,23 @@ function AgentModeControlView({ <> - - {Icon ? : null} - {selectedModeLabel} - + /> @@ -282,21 +277,10 @@ function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean { return a === b || JSON.stringify(a) === JSON.stringify(b); } -interface AgentModeControlProps { - serverId: string; - agentId: string; - placement: AgentModeControlPlacement; - isCompactLayout?: boolean; -} - -export const AgentModeControl = memo(function AgentModeControl({ - serverId, - agentId, - placement, - isCompactLayout, -}: AgentModeControlProps) { - const isCompactFormFactor = useIsCompactFormFactor(); - const isCompact = isCompactLayout ?? isCompactFormFactor; +export function useLiveAgentModeControl( + serverId: string, + agentId: string, +): AgentModeControlValue | null { const slice = useSessionStore( useShallow((state) => { const agent = state.sessions[serverId]?.agents?.get(agentId); @@ -349,82 +333,20 @@ export const AgentModeControl = memo(function AgentModeControl({ [agentId, client, slice?.provider, toast, updatePreferences], ); - if (!slice || availableModes.length === 0) return null; - if (!shouldRenderForPlacement(placement, isCompact)) return null; - - return ( - - ); -}); - -export interface DraftAgentModeControlProps { - selectedProvider: AgentProvider | null; - providerDefinitions: AgentProviderDefinition[]; - modeOptions: AgentMode[]; - selectedMode: string; - onSelectMode: (modeId: string) => void; - disabled?: boolean; - placement: AgentModeControlPlacement; - isCompactLayout?: boolean; -} - -export function DraftAgentModeControl({ - selectedProvider, - providerDefinitions, - modeOptions, - selectedMode, - onSelectMode, - disabled, - placement, - isCompactLayout, -}: DraftAgentModeControlProps) { - const isCompactFormFactor = useIsCompactFormFactor(); - const isCompact = isCompactLayout ?? isCompactFormFactor; - if (!selectedProvider || modeOptions.length === 0) return null; - if (!shouldRenderForPlacement(placement, isCompact)) return null; - return ( - - ); + return useMemo(() => { + if (!slice || availableModes.length === 0) return null; + return { + provider: slice.provider, + providerDefinitions, + modeOptions: availableModes, + selectedModeId: slice.currentModeId, + onSelectMode: handleSelectMode, + disabled: !client, + }; + }, [availableModes, client, handleSelectMode, providerDefinitions, slice]); } const styles = StyleSheet.create((theme) => ({ - chip: { - height: 28, - flexDirection: "row", - alignItems: "center", - backgroundColor: "transparent", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], - }, - chipHovered: { - backgroundColor: theme.colors.surface2, - }, - chipPressed: { - backgroundColor: theme.colors.surface0, - }, - chipDisabled: { - opacity: 0.5, - }, - chipLabel: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, tooltipRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx new file mode 100644 index 00000000000..b3ba7f1ee52 --- /dev/null +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -0,0 +1,275 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; +import { getProviderIcon } from "@/components/provider-icons"; +import { ModelBrowser, useModelBrowser } from "@/components/model-browser"; +import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; +import { useIsCompactFormFactor } from "@/constants/layout"; + +const SNAP_POINTS = ["80%", "90%"]; +const MODEL_LIST_TOP_INSET = 4; +const MODEL_ROW_STRIDE = 44; +const MODEL_VIEWPORT_VISIBLE_ROWS = 4.5; +const FIXED_MODEL_VIEWPORT_HEIGHT = + MODEL_LIST_TOP_INSET + MODEL_ROW_STRIDE * MODEL_VIEWPORT_VISIBLE_ROWS; + +interface CompactModelSheetProps { + providers: ProviderSelectorProvider[]; + selectedProvider: string; + selectedModel: string; + onSelect: (provider: string, modelId: string) => void; + isLoading: boolean; + favoriteKeys: Set; + onToggleFavorite?: (provider: string, modelId: string) => void; + onOpen?: () => void; + onClose?: () => void; + onRetryProvider?: (provider: AgentProvider) => void; + isRetryingProvider?: boolean; + disabled?: boolean; + serverId?: string | null; + glyphSize: number; + children: ReactNode; +} + +function shortModelLabel(label: string): string { + const separatorIndex = label.lastIndexOf("/"); + return separatorIndex === -1 ? label : label.slice(separatorIndex + 1); +} + +export function CompactModelSheet({ + providers, + selectedProvider, + selectedModel, + onSelect, + isLoading, + favoriteKeys, + onToggleFavorite, + onOpen, + onClose, + onRetryProvider, + isRetryingProvider = false, + disabled = false, + serverId = null, + glyphSize, + children, +}: CompactModelSheetProps) { + const { t } = useTranslation(); + const usesBottomSheet = useIsCompactFormFactor(); + const [isOpen, setIsOpen] = useState(false); + const browser = useModelBrowser({ + providers, + selectedProvider, + selectedModel, + isLoading, + favoriteKeys, + serverId, + }); + const { prepareToOpen, reset } = browser; + const ProviderIcon = + selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null; + const compactFooter = useMemo( + () => + usesBottomSheet ? ( + + + {children} + + ) : undefined, + [children, usesBottomSheet], + ); + + const open = useCallback(() => { + Keyboard.dismiss(); + prepareToOpen(); + setIsOpen(true); + onOpen?.(); + }, [onOpen, prepareToOpen]); + + const close = useCallback(() => { + setIsOpen(false); + reset(); + onClose?.(); + }, [onClose, reset]); + + const handleSelect = useCallback( + (provider: string, modelId: string) => { + onSelect(provider, modelId); + close(); + }, + [close, onSelect], + ); + + const toggle = useCallback(() => { + if (isOpen) { + close(); + return; + } + open(); + }, [close, isOpen, open]); + + const triggerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType) => [ + styles.trigger, + hovered && styles.triggerHovered, + (pressed || isOpen) && styles.triggerPressed, + disabled && styles.triggerDisabled, + ], + [disabled, isOpen], + ); + + return ( + <> + + {ProviderIcon ? ( + + + + ) : null} + + {shortModelLabel(browser.triggerLabel)} + + + + + + + + {!usesBottomSheet ? ( + <> + + + {children} + + + ) : null} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + height: 28, + minWidth: 0, + flexShrink: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius["2xl"], + backgroundColor: "transparent", + }, + triggerHovered: { + backgroundColor: theme.colors.surface2, + }, + triggerPressed: { + backgroundColor: theme.colors.surface0, + }, + triggerDisabled: { + opacity: 0.5, + }, + triggerText: { + minWidth: 0, + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + providerIcon: { + color: theme.colors.foregroundMuted, + }, + sheetBody: { + paddingHorizontal: 0, + paddingTop: 0, + paddingBottom: 0, + gap: 0, + }, + modelViewport: { + overflow: "hidden", + backgroundColor: theme.colors.surfaceSidebar, + }, + flexibleModelViewport: { + flex: 1, + minHeight: 0, + }, + fixedModelViewport: { + height: FIXED_MODEL_VIEWPORT_HEIGHT, + minHeight: FIXED_MODEL_VIEWPORT_HEIGHT, + }, + modelViewportDivider: { + height: 1, + flexShrink: 0, + backgroundColor: theme.colors.border, + }, + controlsScroll: { + flex: 1, + minHeight: 0, + }, + compactFooterContainer: { + flexDirection: "column", + alignItems: "stretch", + justifyContent: "flex-start", + gap: 0, + paddingHorizontal: 0, + paddingTop: 0, + borderTopWidth: 0, + }, + compactFooter: { + minWidth: 0, + }, + compactControlsContent: { + paddingBottom: 0, + }, + controlsContent: { + paddingHorizontal: theme.spacing[2], + paddingTop: theme.spacing[3], + paddingBottom: theme.spacing[3], + gap: theme.spacing[1], + }, +})); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 975b51dde17..490b0a49510 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -9,7 +9,6 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; -import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { ComposerImportPill } from "@/composer/draft/import-pill"; import { AgentStreamView } from "@/agent-stream/view"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; @@ -609,57 +608,6 @@ export function WorkspaceDraftAgentTab({ focusInputRef.current = focus; }, []); - const handleProviderSelectWithFocus = useCallback( - (provider: Parameters[0]) => { - composerState.setProviderFromUser(provider); - focusInputRef.current?.(); - }, - [composerState], - ); - - const handleModeSelectWithFocus = useCallback( - (modeId: string) => { - composerState.setModeFromUser(modeId); - focusInputRef.current?.(); - }, - [composerState], - ); - - const handleModelSelectWithFocus = useCallback( - (modelId: string) => { - composerState.setModelFromUser(modelId); - focusInputRef.current?.(); - }, - [composerState], - ); - - const handleProviderAndModelSelectWithFocus = useCallback( - ( - provider: Parameters[0], - modelId: string, - ) => { - composerState.setProviderAndModelFromUser(provider, modelId); - focusInputRef.current?.(); - }, - [composerState], - ); - - const handleThinkingOptionSelectWithFocus = useCallback( - (optionId: string) => { - composerState.setThinkingOptionFromUser(optionId); - focusInputRef.current?.(); - }, - [composerState], - ); - - const handleSetFeatureWithFocus = useCallback( - (featureId: string, value: unknown) => { - composerState.agentControls.onSetFeature?.(featureId, value); - focusInputRef.current?.(); - }, - [composerState], - ); - const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ mode: "translate", }); @@ -680,39 +628,11 @@ export function WorkspaceDraftAgentTab({ const composerAgentControls = useMemo( () => ({ ...composerState.agentControls, - onSelectProvider: handleProviderSelectWithFocus, - onSelectMode: handleModeSelectWithFocus, - onSelectModel: handleModelSelectWithFocus, - onSelectProviderAndModel: handleProviderAndModelSelectWithFocus, - onSelectThinkingOption: handleThinkingOptionSelectWithFocus, - onSetFeature: handleSetFeatureWithFocus, onDropdownClose: handleDropdownCloseFocus, disabled: isSubmitting, }), - [ - composerState.agentControls, - handleProviderSelectWithFocus, - handleModeSelectWithFocus, - handleModelSelectWithFocus, - handleProviderAndModelSelectWithFocus, - handleThinkingOptionSelectWithFocus, - handleSetFeatureWithFocus, - handleDropdownCloseFocus, - isSubmitting, - ], - ); - const composerFooter = useMemo( - () => - isCompactComposerLayout ? ( - - ) : undefined, - [isCompactComposerLayout, composerAgentControls], + [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); - return ( @@ -770,7 +690,6 @@ export function WorkspaceDraftAgentTab({ onFocusInput={handleFocusInputCallback} commandDraftConfig={composerState.commandDraftConfig} agentControls={composerAgentControls} - footer={composerFooter} isCompactLayout={isCompactComposerLayout} /> diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index a920cbc99e6..c7e9cc38a20 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -232,6 +232,7 @@ function renderContextWindowMeter( serverId: string, provider: string | null, pending: boolean, + glyphSize: number, ): ReactElement | null { const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null; if (!hasData && !pending) { @@ -246,21 +247,16 @@ function renderContextWindowMeter( serverId={serverId} provider={provider} pending={pending} + glyphSize={glyphSize} /> ); } function resolveContextWindowPlacement( meter: ReactElement | null, - isMobile: boolean, -): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } { - if (isMobile) { - return { beforeVoiceContent: null, footerInlineContent: meter }; - } - return { - beforeVoiceContent: {meter}, - footerInlineContent: null, - }; + reserveSlot: boolean, +): ReactNode { + return reserveSlot ? {meter} : null; } interface RenderLeftContentArgs { @@ -302,23 +298,6 @@ interface RenderAttachmentTrayArgs { }; } -function renderComposerFooter( - footer: ReactNode, - footerInlineContent: ReactNode, -): ReactElement | null { - if (!footer && !footerInlineContent) return null; - return ( - - - - {footer} - {footerInlineContent} - - - - ); -} - function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { const { selectedAttachments, @@ -861,8 +840,6 @@ interface ComposerProps { agentControls?: DraftAgentControlsProps; /** Extra styles merged onto the message input wrapper (e.g. elevated background). */ inputWrapperStyle?: import("react-native").ViewStyle; - /** Rendered below the input, inside the keyboard-shifted container. */ - footer?: ReactNode; /** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */ externalKeyboardShift?: boolean; /** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */ @@ -1072,7 +1049,6 @@ export function Composer({ onAttentionPromptSend, agentControls, inputWrapperStyle, - footer, externalKeyboardShift, isCompactLayout: isCompactLayoutOverride, }: ComposerProps) { @@ -1800,6 +1776,7 @@ export function Composer({ const contextWindowPending = agentState.status === "initializing" || agentState.status === "running"; + const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize; const contextWindowMeter = useMemo( () => @@ -1807,24 +1784,25 @@ export function Composer({ contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, - isCompactLayout, + false, serverId, agentState.provider, contextWindowPending, + contextWindowMeterGlyphSize, ), [ contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, - isCompactLayout, serverId, agentState.provider, contextWindowPending, + contextWindowMeterGlyphSize, ], ); - const { beforeVoiceContent, footerInlineContent } = useMemo( - () => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout), - [contextWindowMeter, isCompactLayout], + const beforeVoiceContent = useMemo( + () => resolveContextWindowPlacement(contextWindowMeter, hasAgent), + [contextWindowMeter, hasAgent], ); const hasGithubAttachment = useMemo( @@ -2155,7 +2133,6 @@ export function Composer({ - {renderComposerFooter(footer, footerInlineContent)} ); @@ -2191,50 +2168,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ maxWidth: MAX_CONTENT_WIDTH, gap: theme.spacing[3], }, - footer: { - width: "100%", - paddingHorizontal: theme.spacing[4], - // Negative margin pulls the footer up against the input area's paddingBottom. - // On mobile, leave a 3px gap (no token sits below spacing[1]); desktop keeps more. - marginTop: { - xs: -(theme.spacing[4] - 3), - md: -theme.spacing[3], - }, - alignItems: "center", - paddingBottom: { - xs: 0, - md: theme.spacing[2], - }, - }, - footerContent: { - width: "100%", - maxWidth: MAX_CONTENT_WIDTH, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - // On mobile, the negative margins below cancel each glyph's internal padding - // to reach the composer border; this inset adds a small visual gap from it. - paddingLeft: { - xs: 5, - md: 10, - }, - paddingRight: { - xs: 5, - md: 10, - }, - }, - footerLeft: { - flexShrink: 1, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - // On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal) - // so its icon aligns to the composer border before the footer inset is applied. - marginLeft: { - xs: -theme.spacing[2], - md: 0, - }, - }, messageInputContainer: { position: "relative", width: "100%", @@ -2257,6 +2190,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ contextWindowMeterSlot: { width: 28, height: 28, + flexShrink: 0, alignItems: "center", justifyContent: "center", }, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index e0819ad7a9d..14d36f183ad 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -485,11 +485,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId, bumpHistorySyncGeneration, refreshDirectories: () => getHostRuntimeStore().refreshDirectories(serverId), - }).catch((error) => { - console.error("[SessionProvider] resume revalidation failed", { - serverId, - error: toErrorMessage(error), - }); }); }, [bumpHistorySyncGeneration, serverId], diff --git a/packages/app/src/contexts/session-resume-revalidation.test.ts b/packages/app/src/contexts/session-resume-revalidation.test.ts index ebc90b68a27..8f3cc858cd2 100644 --- a/packages/app/src/contexts/session-resume-revalidation.test.ts +++ b/packages/app/src/contexts/session-resume-revalidation.test.ts @@ -32,4 +32,21 @@ describe("session resume revalidation", () => { expect(revalidated).toBe(false); expect(calls).toEqual([]); }); + + it("defers stale resume revalidation while the host is disconnected", async () => { + const calls: string[] = []; + + const revalidated = await revalidateSessionAfterResume({ + awayMs: SESSION_STALE_AFTER_MS, + serverId: "server", + bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`), + refreshDirectories: async () => { + calls.push("directories"); + throw new Error("Host server is not connected"); + }, + }); + + expect(revalidated).toBe(false); + expect(calls).toEqual(["history:server", "directories"]); + }); }); diff --git a/packages/app/src/contexts/session-resume-revalidation.ts b/packages/app/src/contexts/session-resume-revalidation.ts index 8360316f5a9..139bf3e01c8 100644 --- a/packages/app/src/contexts/session-resume-revalidation.ts +++ b/packages/app/src/contexts/session-resume-revalidation.ts @@ -10,7 +10,11 @@ export async function revalidateSessionAfterResume(input: { return false; } - input.bumpHistorySyncGeneration(input.serverId); - await input.refreshDirectories(); - return true; + try { + input.bumpHistorySyncGeneration(input.serverId); + await input.refreshDirectories(); + return true; + } catch { + return false; + } } diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index e20c6a90be3..af650e33851 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -308,7 +308,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const modelSelectorProviders = snapshotModelSelectorProviders; const availableModels = snapshotSelectedProviderModels; const modeOptions = snapshotSelectedProviderModes; - const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading; + const isModelSelectionLoading = + resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading; + const isAllModelsLoading = isModelSelectionLoading; const combinedInitialValues = useMemo( () => combineInitialValues(initialValues, initialServerId), @@ -567,7 +569,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg () => availableThinkingOptionsRaw ?? [], [availableThinkingOptionsRaw], ); - const isModelLoading = snapshotIsLoading || selectedProviderIsLoading; + const isModelLoading = isModelSelectionLoading; const modelError = snapshotError; const workingDirIsEmpty = !formState.workingDir.trim(); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 2c3dab2d893..50890040166 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -24,7 +24,6 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { SidebarCallout } from "@/components/sidebar-callout"; import { Composer } from "@/composer"; -import { AgentModeControl } from "@/composer/agent-controls/mode-control"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { getProviderIcon } from "@/components/provider-icons"; import { @@ -1529,19 +1528,6 @@ function ActiveAgentComposer({ [insets.bottom, composerKeyboardStyle], ); - const composerFooter = useMemo( - () => - isCompactComposerLayout ? ( - - ) : undefined, - [isCompactComposerLayout, serverId, agentId], - ); - return ( diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index 94417485b58..06c76f74b92 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -260,6 +260,25 @@ describe("combined model selector data", () => { ).toBe("Default"); }); + it("distinguishes a loading selection from a resolved empty selection", () => { + expect( + resolveSelectedModelLabel({ + providers: [], + selectedProvider: "", + selectedModel: "", + isLoading: true, + }), + ).toBe("Loading..."); + expect( + resolveSelectedModelLabel({ + providers: [], + selectedProvider: "", + selectedModel: "", + isLoading: false, + }), + ).toBe("Select model"); + }); + it("keeps a stored selected model visible when current snapshot rows no longer offer it", () => { const providers = buildSelectableProviderSelectorProviders([ snapshotEntry({ diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index f6ecb32c96e..c100ecc6783 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -157,7 +157,9 @@ export function resolveSelectedModelLabel(input: { }): string { const selectedProvider = input.selectedProvider.trim(); if (!selectedProvider) { - return i18n.t("providerSelection.selectModel"); + return input.isLoading + ? i18n.t("providerSelection.loading") + : i18n.t("providerSelection.selectModel"); } const provider = input.providers.find((entry) => entry.id === selectedProvider); diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index a10de7b128d..b58ee145b99 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -1096,7 +1096,7 @@ describe("resolveAgentForm", () => { }); describe("RESET", () => { - it("resets userModified flags while keeping form state", () => { + it("keeps form values but marks them unresolved for the next open", () => { const state = makeState( { provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" }, { provider: true, modeId: true, model: true }, @@ -1106,7 +1106,7 @@ describe("resolveAgentForm", () => { expect(next.userModified).toEqual(INITIAL_USER_MODIFIED); expect(next.form).toEqual(state.form); - expect(next.resolution.status).toBe("completed"); + expect(next.resolution.status).toBe("pending"); }); }); diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index 56e84f5b199..7c818ee609a 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -56,8 +56,8 @@ export const INITIAL_USER_MODIFIED: UserModifiedFields = { workingDir: false, }; -export const INITIAL_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "completed" }; export const PENDING_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "pending" }; +export const INITIAL_AGENT_FORM_RESOLUTION = PENDING_AGENT_FORM_RESOLUTION; type ProviderPrefs = NonNullable[AgentProvider]; diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index e8863d0d223..5c0f60cb87b 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -12,7 +12,6 @@ import { useQuery } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; -import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; import { resolveComposerAttachmentSubmitFormat, splitComposerAttachmentsForSubmit, @@ -2109,13 +2108,6 @@ export function NewWorkspaceScreen({ }, }); - const composerFooter = useMemo( - () => - agentControlsWithDisabled ? ( - - ) : null, - [agentControlsWithDisabled], - ); const screenHeaderLeft = useMemo(() => , []); return ( @@ -2154,7 +2146,6 @@ export function NewWorkspaceScreen({ autoFocus commandDraftConfig={composerState?.commandDraftConfig} agentControls={agentControlsWithDisabled} - footer={composerFooter} /> {errorMessage ? {errorMessage} : null} From 5e47cff58ecfa5c3d642aa7ff2a2089f0a0ae4b0 Mon Sep 17 00:00:00 2001 From: Byeonghoon Yoo Date: Fri, 24 Jul 2026 01:41:36 +0900 Subject: [PATCH 030/420] fix(omp): honor hidden custom messages (#2280) --- .../server/agent/providers/omp/agent.test.ts | 17 +++++++ .../src/server/agent/providers/omp/agent.ts | 21 ++++---- .../agent/providers/omp/custom-message.ts | 7 +++ .../providers/omp/history-mapper.test.ts | 48 +++++++++++++++++++ .../agent/providers/omp/message-history.ts | 4 ++ .../providers/omp/test-utils/omp-harness.ts | 15 ++++-- .../providers/omp/v17-rpc-compat.test.ts | 17 ++++++- 7 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/server/agent/providers/omp/custom-message.ts diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index fe2fc374927..35020ca45c6 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -260,6 +260,23 @@ describe("OMP agent client and session", () => { expect(omp.completedTurnCount()).toBe(1); }); + test("omits live custom messages when display is false", async () => { + const omp = new OmpHarness(); + await omp.start(); + + await expect( + omp.runPromptAfterExtensionNotice("hello OMP", "model turn completed", false), + ).resolves.toMatchObject({ finalText: expect.stringContaining("model turn completed") }); + expect(omp.timeline()).toEqual([ + { type: "user_message", text: "hello OMP", messageId: "user-1" }, + { + type: "assistant_message", + text: "model turn completed", + messageId: "omp-assistant-1", + }, + ]); + }); + test("does not complete a queued model turn from OMP's local-only hint", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index eec64cee470..c0654a6274d 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -66,6 +66,7 @@ import { } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; +import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; import { materializeProviderImage } from "../provider-image-output.js"; import { OmpCliRuntime } from "./cli-runtime.js"; @@ -2064,15 +2065,17 @@ export class OmpAgentSession implements AgentSession { return; } if (event.message.role === "custom") { - const text = getUserMessageText(event.message.content); - if (text) { - const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text); - this.emit({ - type: "timeline", - provider: this.provider, - turnId, - item: advisorItem ?? { type: "assistant_message", text }, - }); + if (shouldDisplayOmpCustomMessage(event.message)) { + const text = getUserMessageText(event.message.content); + if (text) { + const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text); + this.emit({ + type: "timeline", + provider: this.provider, + turnId, + item: advisorItem ?? { type: "assistant_message", text }, + }); + } } if (!this.activeTurnHasUserMessage) { this.completeTurn(turnId, []); diff --git a/packages/server/src/server/agent/providers/omp/custom-message.ts b/packages/server/src/server/agent/providers/omp/custom-message.ts new file mode 100644 index 00000000000..e84095b4f03 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/custom-message.ts @@ -0,0 +1,7 @@ +import type { OmpAgentMessage } from "./rpc-types.js"; + +type OmpCustomMessage = Extract; + +export function shouldDisplayOmpCustomMessage(message: OmpCustomMessage): boolean { + return Reflect.get(message, "display") !== false; +} diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index 353f3c98f82..db67f0cbe3a 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -209,6 +209,54 @@ describe("OMP history mapper", () => { ]); }); + test("omits replayed custom messages only when display is false", async () => { + await expect( + collectHistory( + [ + { role: "user", content: "first prompt" }, + { role: "custom", content: "hidden reminder", display: false }, + { role: "custom", content: "visible explicit custom", display: true }, + { role: "custom", content: "visible legacy custom" }, + { + role: "assistant", + content: [{ type: "text", text: "assistant reply" }], + responseId: "assistant-history", + }, + ], + [{ id: "entry-user-1", text: "first prompt" }], + ), + ).resolves.toEqual([ + { + type: "timeline", + provider: "omp", + item: { + type: "user_message", + text: "first prompt", + messageId: "entry-user-1", + }, + }, + { + type: "timeline", + provider: "omp", + item: { type: "assistant_message", text: "visible explicit custom" }, + }, + { + type: "timeline", + provider: "omp", + item: { type: "assistant_message", text: "visible legacy custom" }, + }, + { + type: "timeline", + provider: "omp", + item: { + type: "assistant_message", + text: "assistant reply", + messageId: "assistant-history", + }, + }, + ]); + }); + test("suppresses replayed raw todo tool calls through the OMP detail hook", async () => { await expect( collectHistory([ diff --git a/packages/server/src/server/agent/providers/omp/message-history.ts b/packages/server/src/server/agent/providers/omp/message-history.ts index 04a6b287723..b030ec513ce 100644 --- a/packages/server/src/server/agent/providers/omp/message-history.ts +++ b/packages/server/src/server/agent/providers/omp/message-history.ts @@ -1,5 +1,6 @@ import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js"; import type { OmpAgentMessage, OmpImageContent, OmpTextContent } from "./rpc-types.js"; +import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { extractTextFromToolResult, mapToolDetail, @@ -117,6 +118,9 @@ export class OmpHistoryMapper { private mapCustomMessage( message: Extract, ): AgentStreamEvent[] { + if (!shouldDisplayOmpCustomMessage(message)) { + return []; + } const text = getUserMessageText(message.content); const mappedEvent = text ? this.hooks.mapCustomMessage?.(message, text, this.provider) : null; if (mappedEvent) { diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index c7e3c905e9c..00fed1ac894 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -201,16 +201,25 @@ export class OmpHarness { return { completion }; } - async runPromptAfterExtensionNotice(input: string, output: string): Promise { + async runPromptAfterExtensionNotice( + input: string, + output: string, + display?: boolean, + ): Promise { const session = this.requireSession(); const promptStarted = this.omp.latestSession().nextPrompt(); const run = session.run(input); await promptStarted; const runtime = this.omp.latestSession(); + const message = { + role: "custom" as const, + content: "extension inventory changed", + ...(display === undefined ? {} : { display }), + }; runtime.beginTurn(); runtime.acceptPrompt(input, "user-1"); - runtime.acceptCustomMessage("extension inventory changed"); - runtime.finishTurn({ role: "custom", content: "extension inventory changed" }); + runtime.emit({ type: "message_end", message }); + runtime.finishTurn(message); runtime.beginTurn(); runtime.streamAssistantText(output); runtime.finishTurn(); diff --git a/packages/server/src/server/agent/providers/omp/v17-rpc-compat.test.ts b/packages/server/src/server/agent/providers/omp/v17-rpc-compat.test.ts index 5807945aa30..8a472361d21 100644 --- a/packages/server/src/server/agent/providers/omp/v17-rpc-compat.test.ts +++ b/packages/server/src/server/agent/providers/omp/v17-rpc-compat.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "vitest"; import { parseToolArgs, parseToolResult } from "./tool-call-detail.js"; -import { OmpAvailableCommandsUpdateEventSchema } from "./rpc-types.js"; +import { OmpAgentMessageSchema, OmpAvailableCommandsUpdateEventSchema } from "./rpc-types.js"; import { mapOmpToolDetail } from "./tool-call-mapper.js"; +import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; describe("OMP 17 RPC compatibility", () => { test("parses source-attributed command updates", () => { @@ -16,6 +17,20 @@ describe("OMP 17 RPC compatibility", () => { ]); }); + test("keeps non-false custom display metadata backward compatible", () => { + const message = OmpAgentMessageSchema.parse({ + role: "custom", + content: "visible custom message", + display: null, + }); + + expect(message).toMatchObject({ display: null }); + if (message.role !== "custom") { + throw new Error("Expected a custom OMP message"); + } + expect(shouldDisplayOmpCustomMessage(message)).toBe(true); + }); + test("maps subscribed custom tool events without assuming built-in names", () => { const event = { type: "tool_execution_start", From e8fb9bda6c4a2ebc4bccf33e330f9fdc01ae54cd Mon Sep 17 00:00:00 2001 From: yz <43207690+yzim@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:45:00 +0800 Subject: [PATCH 031/420] fix(app): size iPad selector popovers (#2360) --- packages/app/src/composer/agent-controls/index.tsx | 1 + packages/app/src/composer/agent-controls/mode-control.tsx | 1 + packages/app/src/screens/new-workspace-screen.tsx | 2 ++ 3 files changed, 4 insertions(+) diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 40b109f9635..dc9b0bee017 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -951,6 +951,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { onOpenChange={handleThinkingOpenChange} anchorRef={thinkingAnchorRef} desktopPlacement="top-start" + desktopMinWidth={200} renderOption={renderThinkingOption} /> diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index d80e289f854..c4c049c7778 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -264,6 +264,7 @@ export function AgentModeControl({ onOpenChange={handleOpenChange} anchorRef={anchorRef} desktopPlacement="top-start" + desktopMinWidth={260} header={sheetHeader} renderOption={renderOption} /> diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 5c0f60cb87b..d846cafd00d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1381,6 +1381,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme open={project.openState} onOpenChange={project.onOpenChange} desktopPlacement="bottom-start" + desktopMinWidth={360} anchorRef={project.anchorRef} emptyText="No projects available." renderOption={project.renderOption} @@ -1401,6 +1402,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme searchable={false} title="Host" desktopPlacement="bottom-start" + desktopMinWidth={200} hostOptionTestID={newWorkspaceHostOptionTestID} > Date: Thu, 23 Jul 2026 19:08:46 +0200 Subject: [PATCH 032/420] Publish smaller Android APKs for each architecture (#2349) * feat(android): support F-Droid ABI-split builds * test(android): remove source-fragment ABI assertions --- docs/android.md | 10 ++++ .../app/plugins/with-fdroid-autolinking.js | 59 +++++++++++++++---- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/docs/android.md b/docs/android.md index 69a31b5b57d..c8f1309960f 100644 --- a/docs/android.md +++ b/docs/android.md @@ -120,6 +120,16 @@ PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications. +For a single-ABI APK, pass React Native's architecture property to Gradle: + +```bash +PASEO_FDROID_BUILD=1 ./gradlew assembleRelease \ + -PreactNativeArchitectures=arm64-v8a \ + --no-daemon --max-workers=1 -Dorg.gradle.parallel=false +``` + +Supported values are `armeabi-v7a`, `arm64-v8a`, `x86`, and `x86_64`. The F-Droid profile filters native libraries to that ABI and changes the APK version code to `baseVersionCode * 10 + abiSuffix`, where the suffixes are ordered `1` through `4` in that same sequence. F-Droid metadata should use four build blocks with `VercodeOperation` entries `10 * %c + 1` through `10 * %c + 4` and pass the matching `reactNativeArchitectures` value in each build command. Builds without a single architecture keep the base version code. + Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies `expo-gradle-jvmargs` with `-Xmx4096m` and `-XX:MaxMetaspaceSize=1024m` so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules. The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized. diff --git a/packages/app/plugins/with-fdroid-autolinking.js b/packages/app/plugins/with-fdroid-autolinking.js index a24d4aa34db..0a924a6261a 100644 --- a/packages/app/plugins/with-fdroid-autolinking.js +++ b/packages/app/plugins/with-fdroid-autolinking.js @@ -11,6 +11,51 @@ const EXCLUDED_ANDROID_MODULES = [ "expo-dev-menu-interface", ]; +const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes +def paseoAbiVersionCodes = [ + "armeabi-v7a": 1, + "arm64-v8a": 2, + "x86": 3, + "x86_64": 4, +] +def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "") + .toString() + .split(",") + .collect { it.trim() } + .findAll { !it.isEmpty() } + +if (paseoArchitectures.size() == 1) { + def paseoAbi = paseoArchitectures[0] + def paseoAbiVersionCode = paseoAbiVersionCodes[paseoAbi] + if (paseoAbiVersionCode == null) { + throw new GradleException("Unsupported Paseo Android ABI: " + paseoAbi) + } + android.defaultConfig.versionCode = android.defaultConfig.versionCode * 10 + paseoAbiVersionCode +} +`; + +function configureFdroidAppBuildGradle(contents) { + let configuredContents = contents; + + if (!configuredContents.includes("dependenciesInfo {")) { + const androidBlock = "android {"; + if (!configuredContents.includes(androidBlock)) { + throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle"); + } + + configuredContents = configuredContents.replace( + androidBlock, + `${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`, + ); + } + + if (!configuredContents.includes("// Paseo F-Droid single-ABI version codes")) { + configuredContents = `${configuredContents.trimEnd()}\n\n${FDROID_ABI_VERSION_CODE_BLOCK}`; + } + + return configuredContents; +} + function withFdroidAutolinking(config) { config = withDangerousMod(config, [ "android", @@ -65,19 +110,7 @@ function withFdroidAutolinking(config) { }); return withAppBuildGradle(config, (modConfig) => { - if (modConfig.modResults.contents.includes("dependenciesInfo {")) { - return modConfig; - } - - const androidBlock = "android {"; - if (!modConfig.modResults.contents.includes(androidBlock)) { - throw new Error("Could not disable F-Droid dependency metadata in app/build.gradle"); - } - - modConfig.modResults.contents = modConfig.modResults.contents.replace( - androidBlock, - `${androidBlock}\n dependenciesInfo {\n includeInApk = false\n includeInBundle = false\n }`, - ); + modConfig.modResults.contents = configureFdroidAppBuildGradle(modConfig.modResults.contents); return modConfig; }); } From 1ffa2f821a14c073867c430fd3d406c82294e1a4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 19:16:28 +0200 Subject: [PATCH 033/420] Stop archived workspaces from running background Git checks (#2355) * fix(workspaces): stop archived workspace subscriptions Workspace mutations were persisted globally but projected only through the initiating session, leaving other sessions' Git observers alive after archive. Publish mutations from the shared registry so every session releases its local observer and receives exactly one update. * fix(workspaces): serialize workspace mutation observers * fix(workspaces): finish mutation cleanup Queued workspace mutations now stop at the session cleanup boundary and remove any observer created across an asynchronous cleanup race. Project removal can still enrich an earlier removal delta for legacy clients. * fix(workspaces): honor global mutation boundaries Serialize project and workspace lifecycle notifications per session, keep filtered subscriptions from owning unrelated Git observers, and broadcast final project removals to every session that saw the project. Update strict Session harnesses for the registry subscriptions. * fix(workspaces): preserve initial agent status Carry initial-agent intent through global workspace mutations and publish the optimistic running status until the agent contribution takes over. This removes the invalid transient Done state exposed by global subscriptions. --- packages/server/src/server/bootstrap.ts | 14 +- .../src/server/paseo-worktree-service.ts | 1 + packages/server/src/server/session.ts | 236 ++++++++- .../session.workspace-git-watch.test.ts | 2 + ...on.workspace-resolution-invariants.test.ts | 2 + .../src/server/session.workspaces.test.ts | 501 +++++++++++++++++- .../workspace-provisioning-service.ts | 15 +- .../snapshot-mutation-ownership.test.ts | 2 + ...orkspace-archive-record-scoped.e2e.test.ts | 89 +++- .../server/src/server/workspace-registry.ts | 90 +++- 10 files changed, 910 insertions(+), 42 deletions(-) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4d3d48929ef..b22cb0e1a50 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -178,6 +178,7 @@ import type { } from "./agent/provider-launch-config.js"; import type { PersistedConfig } from "./persisted-config.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js"; +import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; @@ -867,18 +868,13 @@ export async function createPaseoDaemon( workspaceGitService, }); const archiveWorkspaceRecordExternal = async (workspaceId: string) => { - const sessions = wsServer?.listTrustedSessions() ?? []; - if (sessions.length > 0) { - await Promise.all( - sessions.map((session) => session.archiveWorkspaceRecordForExternalMutation(workspaceId)), - ); - return; - } - - await archivePersistedWorkspaceRecord({ + const existingWorkspace = await archivePersistedWorkspaceRecord({ workspaceId, workspaceRegistry, }); + if (!existingWorkspace || existingWorkspace.archivedAt) return; + scriptRuntimeStore.removeForWorkspace(workspaceId); + releaseWorkspaceServicePortPlan(workspaceId); }; // external path→workspace adapter, not ownership: archive-by-path requests that // arrive with a worktree path and no workspaceId (old clients / CLI). diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 818d9875396..f09a8133803 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -91,6 +91,7 @@ export async function createPaseoWorktree( branch: createdWorktree.worktree.branchName || null, baseBranch: resolveIntentBaseBranch(createdWorktree.intent), title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext), + expectsInitialAgent: Boolean(input.firstAgentContext), }); deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2192ad69b94..1c8de87a009 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -128,7 +128,9 @@ import { resolveWorkspaceName, type PersistedProjectRecord, type PersistedWorkspaceRecord, + type ProjectMutation, type ProjectRegistry, + type WorkspaceMutation, type WorkspaceRegistry, } from "./workspace-registry.js"; import { wrapSpokenInput } from "./voice-config.js"; @@ -364,6 +366,7 @@ interface WorkspaceUpdatesSubscriptionState { isBootstrapping: boolean; pendingUpdatesByWorkspaceId: Map; lastEmittedByWorkspaceId: Map; + visibleEmptyProjectIds?: Set; } class SessionRequestError extends Error { @@ -585,6 +588,10 @@ export class Session { private readonly daemonConfigStore: DaemonConfigStore; private readonly pushTokenStore: PushTokenStore; private unsubscribeAgentEvents: (() => void) | null = null; + private unsubscribeProjectMutations: (() => void) | null = null; + private unsubscribeWorkspaceMutations: (() => void) | null = null; + private registryMutationQueue: Promise = Promise.resolve(); + private isCleanedUp = false; private viewedTimelineAgentIds = new Set(); private readonly viewedTimelineAgentIdsBySource = new Map>(); private readonly clientCapabilitiesBySource = new Map>(); @@ -981,6 +988,7 @@ export class Session { }); this.subscribeToAgentEvents(); + this.subscribeToRegistryMutations(); this.sessionLogger.trace({}, "agent.session.lifecycle.created"); } @@ -1127,18 +1135,26 @@ export class Session { await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true }); } - private async emitCreatedWorkspaceUpdate(workspace: WorkspaceDescriptorPayload): Promise { + private async emitCreatedWorkspaceUpdate( + workspace: WorkspaceDescriptorPayload, + optimisticStatus?: WorkspaceDescriptorPayload["status"], + ): Promise { if (this.workspaceUpdatesSubscription) { - await this.emitWorkspaceUpdateForWorkspaceId(workspace.id); + await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.id], { + skipReconcile: true, + ...(optimisticStatus ? { optimisticStatus } : {}), + }); return; } // COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12. // Older clients create before subscribing and require the causal update beside the response. - this.emit({ type: "workspace_update", payload: { kind: "upsert", workspace } }); - } - - async archiveWorkspaceRecordForExternalMutation(workspaceId: string): Promise { - await this.archiveWorkspaceRecord(workspaceId); + this.emit({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: optimisticStatus ? { ...workspace, status: optimisticStatus } : workspace, + }, + }); } markWorkspaceArchivingForExternalMutation( @@ -1348,6 +1364,137 @@ export class Session { this.providerCatalogSession.start(); } + private subscribeToRegistryMutations(): void { + this.unsubscribeProjectMutations?.(); + this.unsubscribeProjectMutations = + this.projectRegistry.subscribeToMutations?.((mutation) => + this.enqueueRegistryMutation(() => this.handleProjectMutation(mutation)), + ) ?? null; + this.unsubscribeWorkspaceMutations?.(); + this.unsubscribeWorkspaceMutations = + this.workspaceRegistry.subscribeToMutations?.((mutation) => + this.enqueueRegistryMutation(() => this.handleWorkspaceMutation(mutation)), + ) ?? null; + } + + private enqueueRegistryMutation(handleMutation: () => Promise): Promise { + const next = this.registryMutationQueue.then(handleMutation); + this.registryMutationQueue = next.catch(() => {}); + return next; + } + + private async handleWorkspaceMutation(mutation: WorkspaceMutation): Promise { + try { + if (this.isCleanedUp) { + return; + } + if ( + mutation.kind === "archive" || + mutation.kind === "remove" || + mutation.workspace?.archivedAt + ) { + this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); + } else { + await this.syncWorkspaceMutationObserver(mutation); + } + if (this.isCleanedUp) { + return; + } + await this.emitWorkspaceUpdatesForWorkspaceIds([mutation.workspaceId], { + skipReconcile: true, + ...(mutation.expectsInitialAgent ? { optimisticStatus: "running" } : {}), + }); + } catch (error) { + this.sessionLogger.warn( + { err: error, workspaceId: mutation.workspaceId, mutationKind: mutation.kind }, + "Failed to apply workspace mutation to session", + ); + } + } + + private async syncWorkspaceMutationObserver(mutation: WorkspaceMutation): Promise { + const subscription = this.workspaceUpdatesSubscription; + if (!mutation.workspace || !subscription) { + return; + } + const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({ + workspaceIds: [mutation.workspaceId], + includeGitData: false, + }); + const descriptor = descriptorsByWorkspaceId.get(mutation.workspaceId); + if ( + !descriptor || + !this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }) + ) { + this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); + return; + } + const currentWorkspace = await this.workspaceRegistry.get(mutation.workspaceId); + if (!currentWorkspace || currentWorkspace.archivedAt) { + this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); + return; + } + await this.workspaceGitObserver.syncObserverForWorkspace(currentWorkspace); + if (this.isCleanedUp) { + this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); + } + } + + private async handleProjectMutation(mutation: ProjectMutation): Promise { + try { + const subscription = this.workspaceUpdatesSubscription; + if (this.isCleanedUp || !subscription) { + return; + } + const projectWorkspaceIds = (await this.workspaceRegistry.list()) + .filter((workspace) => workspace.projectId === mutation.projectId) + .map((workspace) => workspace.workspaceId); + + if (mutation.kind === "remove") { + const visibleWorkspaceIds = projectWorkspaceIds.filter((workspaceId) => { + const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); + return ( + lastEmitted?.kind === "upsert" || + (lastEmitted?.kind === "remove" && + lastEmitted.emptyProject?.projectId === mutation.projectId) + ); + }); + let updateIds = visibleWorkspaceIds; + if ( + updateIds.length === 0 && + subscription.visibleEmptyProjectIds?.has(mutation.projectId) + ) { + updateIds = [mutation.projectId]; + } + if (updateIds.length === 0) { + return; + } + for (const workspaceId of projectWorkspaceIds) { + this.workspaceGitObserver.removeForWorkspaceId(workspaceId); + } + await this.emitWorkspaceUpdatesForWorkspaceIds(updateIds, { + skipReconcile: true, + removedProjectId: mutation.projectId, + }); + return; + } + + if (mutation.kind === "archive" || mutation.project?.archivedAt) { + for (const workspaceId of projectWorkspaceIds) { + this.workspaceGitObserver.removeForWorkspaceId(workspaceId); + } + } + await this.emitWorkspaceUpdatesForWorkspaceIds(projectWorkspaceIds, { + skipReconcile: true, + }); + } catch (error) { + this.sessionLogger.warn( + { err: error, projectId: mutation.projectId, mutationKind: mutation.kind }, + "Failed to apply project mutation to session", + ); + } + } + private subscribeToAgentEvents(): void { if (this.unsubscribeAgentEvents) { this.unsubscribeAgentEvents(); @@ -4222,6 +4369,16 @@ export class Session { subscription: WorkspaceUpdatesSubscriptionState, payload: WorkspaceUpdatePayload, ): void { + if (payload.kind === "upsert") { + subscription.visibleEmptyProjectIds?.delete(payload.workspace.projectId); + } else { + if (payload.emptyProject) { + subscription.visibleEmptyProjectIds?.add(payload.emptyProject.projectId); + } + if (payload.removedProjectId) { + subscription.visibleEmptyProjectIds?.delete(payload.removedProjectId); + } + } if (subscription.isBootstrapping) { const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; subscription.pendingUpdatesByWorkspaceId.set(workspaceId, payload); @@ -4468,7 +4625,12 @@ export class Session { private async emitWorkspaceUpdatesForWorkspaceIds( workspaceIds: Iterable, - options?: { skipReconcile?: boolean; dedupeGitState?: boolean; removedProjectId?: string }, + options?: { + skipReconcile?: boolean; + dedupeGitState?: boolean; + removedProjectId?: string; + optimisticStatus?: WorkspaceDescriptorPayload["status"]; + }, ): Promise { const subscription = this.workspaceUpdatesSubscription; if (!subscription) { @@ -4487,10 +4649,15 @@ export class Session { for (const workspaceId of uniqueWorkspaceIds) { const workspace = descriptorsByWorkspaceId.get(workspaceId); - const nextWorkspace = + const filteredWorkspace = workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter }) ? workspace : null; + const nextWorkspace = this.applyOptimisticWorkspaceStatus( + filteredWorkspace, + options?.optimisticStatus, + ); + const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); if ( options?.dedupeGitState && this.workspaceGitObserver.shouldSkipUpdate(workspaceId, nextWorkspace) @@ -4500,7 +4667,7 @@ export class Session { this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace); if (!nextWorkspace) { - if (workspace && !subscription.lastEmittedByWorkspaceId.has(workspaceId)) { + if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) { continue; } subscription.lastEmittedByWorkspaceId.delete(workspaceId); @@ -4516,7 +4683,6 @@ export class Session { workspace: nextWorkspace, }; - const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); if ( lastEmitted && lastEmitted.kind === "upsert" && @@ -4533,6 +4699,26 @@ export class Session { } } + private applyOptimisticWorkspaceStatus( + workspace: WorkspaceDescriptorPayload | null, + optimisticStatus: WorkspaceDescriptorPayload["status"] | undefined, + ): WorkspaceDescriptorPayload | null { + if (!workspace || !optimisticStatus) { + return workspace; + } + return { ...workspace, status: optimisticStatus }; + } + + private shouldSkipWorkspaceRemoval( + lastEmitted: WorkspaceUpdatePayload | undefined, + removedProjectId: string | undefined, + ): boolean { + if (lastEmitted?.kind === "remove") { + return !removedProjectId || lastEmitted.removedProjectId === removedProjectId; + } + return !lastEmitted && !removedProjectId; + } + private async buildWorkspaceRemoveUpdatePayload( workspaceId: string, removedProjectId?: string, @@ -4744,6 +4930,7 @@ export class Session { isBootstrapping: true, pendingUpdatesByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(), + visibleEmptyProjectIds: new Set(), }; } @@ -4759,7 +4946,12 @@ export class Session { "fetch_workspaces_response_ready", ); const snapshot = this.buildBootstrapSnapshot(payload.entries); - this.seedWorkspaceSubscriptionSnapshot(subscriptionId, request.filter, payload.entries); + this.seedWorkspaceSubscriptionSnapshot( + subscriptionId, + request.filter, + payload.entries, + payload.emptyProjects, + ); this.emit({ type: "fetch_workspaces_response", @@ -4824,6 +5016,7 @@ export class Session { subscriptionId: string | null, filter: FetchWorkspacesRequestFilter | undefined, entries: FetchWorkspacesResponseEntry[], + emptyProjects: WorkspaceProjectDescriptorPayload[], ): void { const subscription = this.workspaceUpdatesSubscription; if (!subscription) return; @@ -4835,6 +5028,9 @@ export class Session { workspace: entry, }); } + for (const project of emptyProjects) { + subscription.visibleEmptyProjectIds?.add(project.projectId); + } } private async registerWorkspaceForImportedAgent( @@ -4910,6 +5106,7 @@ export class Session { cwd, explicitTitle ?? promptTitle, request.source.projectId, + { expectsInitialAgent: Boolean(request.firstAgentContext) }, ); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); @@ -4922,7 +5119,10 @@ export class Session { error: null, }, }); - await this.emitCreatedWorkspaceUpdate(descriptor); + await this.emitCreatedWorkspaceUpdate( + descriptor, + request.firstAgentContext ? "running" : undefined, + ); void this.workspaceGitService .getSnapshot(workspace.cwd, { force: true, includeForge: true, reason: "open_project" }) .catch((error) => { @@ -4997,7 +5197,10 @@ export class Session { error: null, }, }); - await this.emitCreatedWorkspaceUpdate(descriptor); + await this.emitCreatedWorkspaceUpdate( + descriptor, + request.firstAgentContext ? "running" : undefined, + ); } private async handleOpenProjectRequest( @@ -6310,11 +6513,16 @@ export class Session { */ public async cleanup(): Promise { this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup"); + this.isCleanedUp = true; if (this.unsubscribeAgentEvents) { this.unsubscribeAgentEvents(); this.unsubscribeAgentEvents = null; } + this.unsubscribeProjectMutations?.(); + this.unsubscribeProjectMutations = null; + this.unsubscribeWorkspaceMutations?.(); + this.unsubscribeWorkspaceMutations = null; this.agentUpdates.dispose(); await this.hubExecutionController?.cleanup(); if (this.unsubscribeTerminalWorkspaceContributionEvents) { diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 06daa4726ec..00f3f780fdc 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -198,6 +198,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { get: async () => null, }), projectRegistry: createStub({ + subscribeToMutations: () => () => {}, initialize: async () => {}, existsOnDisk: async () => true, list: async () => Array.from(projects.values()), @@ -215,6 +216,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { }, }), workspaceRegistry: createStub({ + subscribeToMutations: () => () => {}, initialize: async () => {}, existsOnDisk: async () => true, list: async () => Array.from(workspaces.values()), diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 277320e5498..fdc3a8aa42a 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -109,6 +109,7 @@ function createHarness(input: { get: async () => null, }), projectRegistry: createStub({ + subscribeToMutations: () => () => {}, initialize: async () => {}, existsOnDisk: async () => true, list: async () => Array.from(projects.values()), @@ -141,6 +142,7 @@ function createHarness(input: { }, }), workspaceRegistry: createStub({ + subscribeToMutations: () => () => {}, initialize: async () => {}, existsOnDisk: async () => true, list: async () => Array.from(workspaces.values()), diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 2b38acba4cc..c7c0ed247c0 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -70,6 +70,7 @@ import { createPersistedWorkspaceRecord, type PersistedProjectRecord, type PersistedWorkspaceRecord, + type WorkspaceMutation, } from "./workspace-registry.js"; const REPO_CWD = path.resolve("/tmp/repo"); @@ -3409,7 +3410,27 @@ test("archiving the last workspace emits a remove carrying the now-empty project filter: undefined, isBootstrapping: false, pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map([ + [ + archivedWorkspace.workspaceId, + { + kind: "upsert", + workspace: { + id: archivedWorkspace.workspaceId, + projectId: project.projectId, + projectDisplayName: project.displayName, + projectRootPath: project.rootPath, + workspaceDirectory: archivedWorkspace.cwd, + projectKind: project.kind, + workspaceKind: archivedWorkspace.kind, + name: archivedWorkspace.displayName, + status: "done", + activityAt: null, + diffStat: null, + }, + }, + ], + ]), }; session.reconcileActiveWorkspaceRecords = async () => new Set(); // The archived workspace no longer resolves to an active descriptor. @@ -3578,7 +3599,9 @@ test("project.remove.request removes an already-empty project", async () => { filter: undefined, isBootstrapping: false, pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map([ + [archivedWorkspace.workspaceId, { kind: "remove", id: archivedWorkspace.workspaceId }], + ]), }; session.reconcileActiveWorkspaceRecords = async () => new Set(); session.listAgentPayloads = async () => []; @@ -6927,6 +6950,456 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho ); }); +test("workspace mutation handling does not let a delayed upsert recreate an archived observer", async () => { + let mutationListener: ((mutation: WorkspaceMutation) => void | Promise) | null = null; + const registerCalls: string[] = []; + const unsubscribeCalls: string[] = []; + const project = createPersistedProjectRecord({ + projectId: "proj-race", + rootPath: REPO_CWD, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-race", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const descriptor = { + id: workspace.workspaceId, + projectId: project.projectId, + projectDisplayName: project.displayName, + projectRootPath: project.rootPath, + workspaceDirectory: workspace.cwd, + projectKind: project.kind, + workspaceKind: workspace.kind, + name: workspace.displayName, + status: "done", + activityAt: null, + diffStat: null, + } as WorkspaceDescriptorPayload; + let listedWorkspaces: PersistedWorkspaceRecord[] = []; + const workspaceRegistry: SessionOptions["workspaceRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => listedWorkspaces, + get: async (workspaceId: string) => (workspaceId === workspace.workspaceId ? workspace : null), + update: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + subscribeToMutations: (listener) => { + mutationListener = listener; + return () => { + mutationListener = null; + }; + }, + }; + const session = createSessionForWorkspaceTests({ + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [project], + get: async (projectId: string) => (projectId === project.projectId ? project : null), + getOrCreateActiveByRoot: async () => project, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }, + workspaceRegistry, + workspaceGitService: createNoopWorkspaceGitService({ + registerWorkspace: ({ cwd }) => { + registerCalls.push(path.resolve(cwd)); + return { + unsubscribe: () => { + unsubscribeCalls.push(path.resolve(cwd)); + }, + }; + }, + }), + }); + session.emitWorkspaceUpdatesForWorkspaceIds = async () => {}; + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-fetch-workspaces-subscribe", + subscribe: {}, + }); + listedWorkspaces = [workspace]; + + let resumeDescribe!: () => void; + const describeStarted = new Promise((resolveStarted) => { + session.describeWorkspaceRecordWithGitData = async () => { + resolveStarted(); + await new Promise((resolveResume) => { + resumeDescribe = resolveResume; + }); + return descriptor; + }; + }); + + const upsertMutation = mutationListener?.({ + kind: "upsert", + workspaceId: workspace.workspaceId, + workspace, + }); + expect(upsertMutation).toBeDefined(); + const upsertPromise = Promise.resolve(upsertMutation); + await describeStarted; + + const archivedWorkspace = { ...workspace, archivedAt: "2026-03-02T12:00:00.000Z" }; + const archivePromise = Promise.resolve( + mutationListener?.({ + kind: "archive", + workspaceId: workspace.workspaceId, + workspace: archivedWorkspace, + }), + ); + + await Promise.resolve(); + expect(unsubscribeCalls).toEqual([]); + + resumeDescribe(); + await upsertPromise; + await archivePromise; + + expect(registerCalls).toEqual([REPO_CWD]); + expect(unsubscribeCalls).toEqual([REPO_CWD]); +}); + +test("workspace mutations outside a filtered subscription neither watch nor emit", async () => { + const emitted: SessionOutboundMessage[] = []; + let mutationListener: ((mutation: WorkspaceMutation) => void | Promise) | null = null; + const registerCalls: string[] = []; + const project = createPersistedProjectRecord({ + projectId: "proj-filtered-mutation", + rootPath: REPO_CWD, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-filtered-mutation", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const descriptor = { + id: workspace.workspaceId, + projectId: project.projectId, + projectDisplayName: project.displayName, + projectRootPath: project.rootPath, + workspaceDirectory: workspace.cwd, + projectKind: project.kind, + workspaceKind: workspace.kind, + name: workspace.displayName, + status: "done", + activityAt: null, + diffStat: null, + } as WorkspaceDescriptorPayload; + const workspaceRegistry: SessionOptions["workspaceRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [workspace], + get: async (workspaceId: string) => (workspaceId === workspace.workspaceId ? workspace : null), + update: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + subscribeToMutations: (listener) => { + mutationListener = listener; + return () => { + mutationListener = null; + }; + }, + }; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [project], + get: async (projectId: string) => (projectId === project.projectId ? project : null), + getOrCreateActiveByRoot: async () => project, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }, + workspaceRegistry, + workspaceGitService: createNoopWorkspaceGitService({ + registerWorkspace: ({ cwd }) => { + registerCalls.push(path.resolve(cwd)); + return { unsubscribe: () => {} }; + }, + }), + }); + session.listFetchWorkspacesEntries = async () => ({ + entries: [], + emptyProjects: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }); + session.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-filtered-mutation", + filter: { projectId: "some-other-project" }, + subscribe: {}, + }); + emitted.length = 0; + + await mutationListener?.({ + kind: "upsert", + workspaceId: workspace.workspaceId, + workspace, + }); + session.buildWorkspaceDescriptorMap = async () => new Map(); + await mutationListener?.({ + kind: "archive", + workspaceId: workspace.workspaceId, + workspace: { ...workspace, archivedAt: "2026-03-02T12:00:00.000Z" }, + }); + + expect(registerCalls).toEqual([]); + expect(filterByType(emitted, "workspace_update")).toEqual([]); +}); + +test("project removal mutation broadcasts the final delta to another subscribed session", async () => { + const emitted: SessionOutboundMessage[] = []; + const project = createPersistedProjectRecord({ + projectId: "proj-global-remove", + rootPath: REPO_CWD, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-global-remove", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-02T12:00:00.000Z", + archivedAt: "2026-03-02T12:00:00.000Z", + }); + let projectMutationListener: + | Parameters>[0] + | null = null; + const projectRegistry: SessionOptions["projectRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + getOrCreateActiveByRoot: async () => project, + upsert: async () => {}, + archive: async () => {}, + remove: async (projectId) => { + await projectMutationListener?.({ kind: "remove", projectId, project: null }); + }, + subscribeToMutations: (listener) => { + projectMutationListener = listener; + return () => { + projectMutationListener = null; + }; + }, + }; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + projectRegistry, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [workspace], + get: async (workspaceId) => (workspaceId === workspace.workspaceId ? workspace : null), + update: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }, + }); + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-global-remove", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map([ + [ + workspace.workspaceId, + { + kind: "remove", + id: workspace.workspaceId, + emptyProject: { + projectId: project.projectId, + projectDisplayName: project.displayName, + projectCustomName: null, + projectRootPath: project.rootPath, + projectKind: project.kind, + }, + }, + ], + ]), + }; + + await projectRegistry.remove(project.projectId); + + expect(filterByType(emitted, "workspace_update")).toEqual([ + { + type: "workspace_update", + payload: { + kind: "remove", + id: workspace.workspaceId, + removedProjectId: project.projectId, + }, + }, + ]); +}); + +test("workspace mutation handling drops queued observer sync after session cleanup", async () => { + let mutationListener: ((mutation: WorkspaceMutation) => void | Promise) | null = null; + const registerCalls: string[] = []; + const unsubscribeCalls: string[] = []; + const project = createPersistedProjectRecord({ + projectId: "proj-cleanup-race", + rootPath: REPO_CWD, + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const firstWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-cleanup-first", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const queuedWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-cleanup-queued", + projectId: project.projectId, + cwd: "/tmp/repo/queued", + kind: "local_checkout", + displayName: "queued", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspaces = new Map([ + [firstWorkspace.workspaceId, firstWorkspace], + [queuedWorkspace.workspaceId, queuedWorkspace], + ]); + let listedWorkspaces: PersistedWorkspaceRecord[] = []; + const workspaceRegistry: SessionOptions["workspaceRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => listedWorkspaces, + get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, + update: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + subscribeToMutations: (listener) => { + mutationListener = listener; + return () => { + mutationListener = null; + }; + }, + }; + const session = createSessionForWorkspaceTests({ + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [project], + get: async (projectId: string) => (projectId === project.projectId ? project : null), + getOrCreateActiveByRoot: async () => project, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }, + workspaceRegistry, + workspaceGitService: createNoopWorkspaceGitService({ + registerWorkspace: ({ cwd }) => { + registerCalls.push(path.resolve(cwd)); + return { + unsubscribe: () => { + unsubscribeCalls.push(path.resolve(cwd)); + }, + }; + }, + }), + }); + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-fetch-workspaces-cleanup-subscribe", + subscribe: {}, + }); + listedWorkspaces = Array.from(workspaces.values()); + + session.describeWorkspaceRecordWithGitData = async (workspace) => + ({ + id: workspace.workspaceId, + projectId: workspace.projectId, + projectDisplayName: project.displayName, + projectRootPath: project.rootPath, + workspaceDirectory: workspace.cwd, + projectKind: project.kind, + workspaceKind: workspace.kind, + name: workspace.displayName, + status: "done", + activityAt: null, + diffStat: null, + }) as WorkspaceDescriptorPayload; + + let resumeFirstEmit!: () => void; + const firstEmitStarted = new Promise((resolveStarted) => { + session.emitWorkspaceUpdatesForWorkspaceIds = async () => { + resolveStarted(); + await new Promise((resolveResume) => { + resumeFirstEmit = resolveResume; + }); + }; + }); + + const firstMutationPromise = Promise.resolve( + mutationListener?.({ + kind: "upsert", + workspaceId: firstWorkspace.workspaceId, + workspace: firstWorkspace, + }), + ); + expect(firstMutationPromise).toBeDefined(); + await firstEmitStarted; + + const queuedMutationPromise = Promise.resolve( + mutationListener?.({ + kind: "upsert", + workspaceId: queuedWorkspace.workspaceId, + workspace: queuedWorkspace, + }), + ); + + await session.cleanup(); + resumeFirstEmit(); + await firstMutationPromise; + await queuedMutationPromise; + + expect(registerCalls).toEqual([REPO_CWD]); + expect(unsubscribeCalls).toEqual([REPO_CWD]); +}); + test("a workspace leaving a filtered subscription after bootstrap emits a removal", async () => { const emitted: SessionOutboundMessage[] = []; const session = asTestSession( @@ -8302,6 +8775,7 @@ test("workspace.create.response persists the first prompt as the initial title", test("workspace create emits through a matching workspace subscription", async () => { const emitted: SessionOutboundMessage[] = []; const workspaces = new Map>(); + let mutationListener: ((mutation: WorkspaceMutation) => void | Promise) | null = null; const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message), workspaceRegistry: { @@ -8309,11 +8783,23 @@ test("workspace create emits through a matching workspace subscription", async ( existsOnDisk: async () => true, list: async () => Array.from(workspaces.values()), get: async (workspaceId) => workspaces.get(workspaceId) ?? null, - upsert: async (workspace) => { + upsert: async (workspace, context) => { workspaces.set(workspace.workspaceId, workspace); + await mutationListener?.({ + kind: "upsert", + workspaceId: workspace.workspaceId, + workspace, + expectsInitialAgent: context?.expectsInitialAgent, + }); }, archive: async () => {}, remove: async () => {}, + subscribeToMutations: (listener) => { + mutationListener = listener; + return () => { + mutationListener = null; + }; + }, }, }); session.listAgentPayloads = async () => []; @@ -8321,7 +8807,7 @@ test("workspace create emits through a matching workspace subscription", async ( await session.handleMessage({ type: "fetch_workspaces_request", requestId: "req-subscribe-create-match", - filter: { query: "repo" }, + filter: { query: "Implement" }, subscribe: { subscriptionId: "sub-create-match" }, }); emitted.length = 0; @@ -8329,9 +8815,14 @@ test("workspace create emits through a matching workspace subscription", async ( type: "workspace.create.request", requestId: "req-create-match", source: { kind: "directory", path: REPO_CWD }, + firstAgentContext: { prompt: "Implement the requested change" }, }); - expect(filterByType(emitted, "workspace_update")).toHaveLength(1); + const statuses = filterByType(emitted, "workspace_update").flatMap((message) => + message.payload.kind === "upsert" ? [message.payload.workspace.status] : [], + ); + expect(statuses).toContain("running"); + expect(statuses).not.toContain("done"); }); test("workspace create stays out of a non-matching workspace subscription", async () => { diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 10303380341..98d076ecf8c 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -42,6 +42,7 @@ export interface CreateWorktreeWorkspaceInput { branch: string | null; baseBranch: string | null; title: string | null; + expectsInitialAgent?: boolean; } export interface WorkspaceProvisioningService { @@ -55,6 +56,7 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, + context?: { expectsInitialAgent?: boolean }, ): Promise; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, @@ -175,6 +177,7 @@ export function createWorkspaceProvisioningService(deps: { cwd: string, title?: string | null, projectId?: string, + context?: { expectsInitialAgent?: boolean }, ): Promise { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -191,7 +194,7 @@ export function createWorkspaceProvisioningService(deps: { createdAt: timestamp, updatedAt: timestamp, }); - await workspaceRegistry.upsert(workspace); + await workspaceRegistry.upsert(workspace, context); return workspace; } @@ -223,7 +226,9 @@ export function createWorkspaceProvisioningService(deps: { createdAt: timestamp, updatedAt: timestamp, }); - await workspaceRegistry.upsert(workspace); + await workspaceRegistry.upsert(workspace, { + expectsInitialAgent: input.expectsInitialAgent, + }); return workspace; } @@ -294,7 +299,11 @@ export function createWorkspaceProvisioningService(deps: { ): Promise { if (input.createdWorktree) return input.createdWorktree.workspace.workspaceId; if (input.requestedWorkspaceId) return input.requestedWorkspaceId; - return (await createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId; + return ( + await createWorkspaceForDirectory(input.cwd, input.initialTitle, undefined, { + expectsInitialAgent: true, + }) + ).workspaceId; } async function ensureWorkspaceRecordUnarchived( diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 87308a3de34..37336434845 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -119,6 +119,7 @@ describe("snapshot mutation ownership boundary", () => { upsert: directStorageWrite, }), projectRegistry: createStub({ + subscribeToMutations: () => () => {}, initialize: async () => {}, existsOnDisk: async () => true, list: async () => [], @@ -128,6 +129,7 @@ describe("snapshot mutation ownership boundary", () => { remove: async () => {}, }), workspaceRegistry: createStub({ + subscribeToMutations: () => () => {}, initialize: async () => {}, existsOnDisk: async () => true, list: async () => [], diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 50cd9d20eda..5414a8cce78 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -5,7 +5,11 @@ import path from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; -import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; +import { + createDaemonTestContext, + DaemonClient, + type DaemonTestContext, +} from "./test-utils/index.js"; // Model B archive is scoped to a single workspace RECORD (by workspaceId), not // to a directory on disk. A directory can back multiple workspaces, so archiving @@ -85,6 +89,33 @@ async function terminalIdsForWorkspace(cwd: string, workspaceId: string): Promis return new Set(listed.terminals.map((terminal) => terminal.id)); } +function collectWorkspaceRemovals(client: DaemonClient): { + workspaceIds: string[]; + stop: () => void; +} { + const workspaceIds: string[] = []; + const stop = client.on("workspace_update", (message) => { + if (message.payload.kind === "remove") { + workspaceIds.push(message.payload.id); + } + }); + return { workspaceIds, stop }; +} + +function collectWorkspaceTitles(client: DaemonClient): { + workspaces: Array<{ id: string; name: string; title: string | null }>; + stop: () => void; +} { + const workspaces: Array<{ id: string; name: string; title: string | null }> = []; + const stop = client.on("workspace_update", (message) => { + if (message.payload.kind === "upsert") { + const { id, name, title } = message.payload.workspace; + workspaces.push({ id, name, title }); + } + }); + return { workspaces, stop }; +} + test("archiving one of two workspaces sharing a cwd spares the sibling and the directory", async () => { const cwd = makeTempDir("workspace-archive-shared-cwd-"); @@ -161,6 +192,62 @@ test("archiving one of two workspaces sharing a cwd spares the sibling and the d await ctx.client.killTerminal(terminalBId); }, 60000); +test("archiving a workspace removes it from every subscribed client", async () => { + const cwd = makeTempDir("workspace-archive-global-"); + const workspaceId = await createLocalWorkspace(cwd, "shared workspace"); + const observer = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + reconnect: { enabled: false }, + }); + + await observer.connect(); + const initiatingClientRemovals = collectWorkspaceRemovals(ctx.client); + const removals = collectWorkspaceRemovals(observer); + + try { + await ctx.client.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-initiator" } }); + await observer.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-observer" } }); + + const archive = await ctx.client.archiveWorkspace(workspaceId); + await observer.ping({ requestId: "archive-observer-barrier" }); + + expect(archive.error).toBe(null); + expect(initiatingClientRemovals.workspaceIds).toEqual([workspaceId]); + expect(removals.workspaceIds).toEqual([workspaceId]); + } finally { + initiatingClientRemovals.stop(); + removals.stop(); + await observer.close(); + } +}); + +test("renaming a workspace updates every subscribed client", async () => { + const cwd = makeTempDir("workspace-rename-global-"); + const workspaceId = await createLocalWorkspace(cwd, "shared workspace"); + const observer = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + reconnect: { enabled: false }, + }); + + await observer.connect(); + const titles = collectWorkspaceTitles(observer); + + try { + await observer.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-observer" } }); + + const renamed = await ctx.client.setWorkspaceTitle(workspaceId, "Renamed workspace"); + await observer.ping({ requestId: "rename-observer-barrier" }); + + expect(renamed).toEqual({ title: "Renamed workspace" }); + expect(titles.workspaces).toEqual([ + { id: workspaceId, name: "Renamed workspace", title: "Renamed workspace" }, + ]); + } finally { + titles.stop(); + await observer.close(); + } +}); + test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => { const repoDir = createGitRepo(); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index f219d91581f..14359f80c13 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -78,6 +78,23 @@ const PersistedWorkspaceRecordSchema = z.object({ export type PersistedProjectRecord = z.infer; export type PersistedWorkspaceRecord = z.infer; +export interface WorkspaceMutation { + kind: "upsert" | "archive" | "remove"; + workspaceId: string; + workspace: PersistedWorkspaceRecord | null; + expectsInitialAgent?: boolean; +} + +export interface WorkspaceMutationContext { + expectsInitialAgent?: boolean; +} + +export interface ProjectMutation { + kind: "upsert" | "archive" | "remove"; + projectId: string; + project: PersistedProjectRecord | null; +} + export interface ProjectRegistry { initialize(): Promise; existsOnDisk(): Promise; @@ -93,13 +110,7 @@ export interface ProjectRegistry { archive(projectId: string, archivedAt: string): Promise; remove(projectId: string): Promise; /** Central lifecycle seam for daemon-global project observers. */ - subscribeToMutations?( - listener: (mutation: { - kind: "upsert" | "archive" | "remove"; - projectId: string; - project: PersistedProjectRecord | null; - }) => void | Promise, - ): () => void; + subscribeToMutations?(listener: (mutation: ProjectMutation) => void | Promise): () => void; } export interface WorkspaceRegistry { @@ -111,9 +122,13 @@ export interface WorkspaceRegistry { workspaceId: string, updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord, ): Promise; - upsert(record: PersistedWorkspaceRecord): Promise; + upsert(record: PersistedWorkspaceRecord, context?: WorkspaceMutationContext): Promise; archive(workspaceId: string, archivedAt: string): Promise; remove(workspaceId: string): Promise; + /** Central lifecycle seam for daemon-global workspace observers. */ + subscribeToMutations?( + listener: (mutation: WorkspaceMutation) => void | Promise, + ): () => void; } type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord; @@ -186,10 +201,14 @@ class FileBackedRegistry { } async archive(id: string, archivedAt: string): Promise { + await this.archiveIfPresent(id, archivedAt); + } + + protected async archiveIfPresent(id: string, archivedAt: string): Promise { await this.load(); const existing = this.cache.get(id); - if (!existing) return; - await this.persistArchive(existing, archivedAt); + if (!existing) return null; + return this.persistArchive(existing, archivedAt); } protected async archiveIfActive(id: string, archivedAt: string): Promise { @@ -372,6 +391,10 @@ export class FileBackedWorkspaceRegistry extends FileBackedRegistry implements WorkspaceRegistry { + private readonly mutationListeners = new Set< + (mutation: WorkspaceMutation) => void | Promise + >(); + constructor(filePath: string, logger: Logger) { super({ filePath, @@ -381,6 +404,53 @@ export class FileBackedWorkspaceRegistry component: "workspaces", }); } + + subscribeToMutations( + listener: (mutation: WorkspaceMutation) => void | Promise, + ): () => void { + this.mutationListeners.add(listener); + return () => this.mutationListeners.delete(listener); + } + + override async update( + workspaceId: string, + updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord, + ): Promise { + const workspace = await super.update(workspaceId, updater); + if (workspace) { + await this.notifyMutation({ kind: "upsert", workspaceId, workspace }); + } + return workspace; + } + + override async upsert( + record: PersistedWorkspaceRecord, + context?: WorkspaceMutationContext, + ): Promise { + await super.upsert(record); + await this.notifyMutation({ + kind: "upsert", + workspaceId: record.workspaceId, + workspace: record, + ...(context?.expectsInitialAgent ? { expectsInitialAgent: true } : {}), + }); + } + + override async archive(workspaceId: string, archivedAt: string): Promise { + const workspace = await this.archiveIfPresent(workspaceId, archivedAt); + if (!workspace) return; + await this.notifyMutation({ kind: "archive", workspaceId, workspace }); + } + + override async remove(workspaceId: string): Promise { + const workspace = await this.removeIfPresent(workspaceId); + if (!workspace) return; + await this.notifyMutation({ kind: "remove", workspaceId, workspace: null }); + } + + private async notifyMutation(mutation: WorkspaceMutation): Promise { + await Promise.all([...this.mutationListeners].map((listener) => listener(mutation))); + } } export function createPersistedProjectRecord(input: { From 2bffd6e71ee74b182b150e3e2aabe23070b35290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=B4=E5=A5=B9=E5=91=BD=40?= <908280968@qq.com> Date: Fri, 24 Jul 2026 01:38:33 +0800 Subject: [PATCH 034/420] fix(server): clean up failed provider session initialization Closes #2347 --- docs/agent-lifecycle.md | 13 +- .../src/server/agent/agent-loading.test.ts | 82 ++++++++++++ .../server/src/server/agent/agent-loading.ts | 1 + .../server/src/server/agent/agent-manager.ts | 12 +- .../src/server/agent/agent-sdk-types.ts | 7 + .../src/server/agent/provider-registry.ts | 3 +- .../server/agent/providers/acp-agent.test.ts | 75 +++++++++++ .../src/server/agent/providers/acp-agent.ts | 124 +++++++++++------- .../providers/codex-app-server-agent.test.ts | 98 +++++++++++++- .../agent/providers/codex-app-server-agent.ts | 58 ++++++-- 10 files changed, 401 insertions(+), 72 deletions(-) create mode 100644 packages/server/src/server/agent/agent-loading.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 3d50231facb..bf8e2694893 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -83,9 +83,16 @@ only the route's explicit Unarchive or Restore action changes the archived works History navigation preserves the selected agent as an explicit recovery target. If both that agent and its workspace are archived, the workspace recovery action restores the workspace and unarchives the selected agent as one user action. Other archived agents in the restored workspace remain -recoverable from History. Opening one pins its tab and renders the archived-agent callout before any -provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex -`thread/unarchive`) before the normal agent resume and timeline hydration flow. +recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative +timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must +leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains +the only transition back to an interactive runtime: it runs the provider's native unarchive hook +(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. + +Provider session connection owns every process it spawns until the session is registered with +`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails, +`connect()` must dispose that process before rethrowing; the manager cannot clean up a session it never +received. ## Tabs vs archive diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts new file mode 100644 index 00000000000..e4943a079d1 --- /dev/null +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { AgentManager } from "./agent-manager.js"; +import { ensureAgentLoaded } from "./agent-loading.js"; +import { AgentStorage } from "./agent-storage.js"; +import type { + AgentClient, + AgentLaunchContext, + AgentPersistenceHandle, + AgentResumeSessionOptions, + AgentSession, + AgentSessionConfig, +} from "./agent-sdk-types.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; + +test("loads archived records for history and active records with the interactive default", async () => { + const root = await mkdtemp(path.join(tmpdir(), "agent-loading-purpose-")); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(root, "agents"), logger); + const baseClient = createTestAgentClients().codex; + if (!baseClient) { + throw new Error("expected Codex test client"); + } + + const resumeOptions: Array = []; + const client: AgentClient = { + provider: baseClient.provider, + capabilities: baseClient.capabilities, + createSession: async ( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise => await baseClient.createSession(config, launchContext), + resumeSession: async ( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, + ): Promise => { + resumeOptions.push(options); + return await baseClient.resumeSession(handle, overrides, launchContext); + }, + fetchCatalog: async (options) => await baseClient.fetchCatalog(options), + isAvailable: async () => await baseClient.isAvailable(), + }; + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + }); + + const archivedId = "00000000-0000-4000-8000-000000000301"; + const activeId = "00000000-0000-4000-8000-000000000302"; + + try { + const archived = await manager.createAgent({ provider: "codex", cwd: root }, archivedId, { + workspaceId: "workspace-archived", + }); + await manager.archiveAgent(archived.id); + + const active = await manager.createAgent({ provider: "codex", cwd: root }, activeId, { + workspaceId: "workspace-active", + }); + await manager.closeAgent(active.id); + + await ensureAgentLoaded(archived.id, { agentManager: manager, agentStorage: storage, logger }); + await ensureAgentLoaded(active.id, { agentManager: manager, agentStorage: storage, logger }); + + expect(resumeOptions).toEqual([{ purpose: "history" }, undefined]); + } finally { + await Promise.all([ + manager.closeAgent(archivedId).catch(() => undefined), + manager.closeAgent(activeId).catch(() => undefined), + ]); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index 6f54864b301..c4a32e5f5a1 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -111,6 +111,7 @@ export async function ensureAgentLoaded( buildConfigOverrides(record), agentId, extractTimestamps(record), + record.archivedAt ? { purpose: "history" } : undefined, ); deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence"); } else { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index cfda95c1bc9..3337ceca6c1 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -19,6 +19,7 @@ import { type AgentCapabilityFlags, type AgentClient, type AgentCreateSessionOptions, + type AgentResumeSessionOptions, type AgentFeature, type AgentLaunchContext, type AgentSlashCommand, @@ -1078,9 +1079,10 @@ export class AgentManager { workspaceId?: string; owner?: AgentOwner; }, + resumeOptions?: AgentResumeSessionOptions, ): Promise { return this.trackAgentRegistrationOperation( - this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options), + this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options, resumeOptions), ); } @@ -1096,6 +1098,7 @@ export class AgentManager { workspaceId?: string; owner?: AgentOwner; }, + resumeOptions?: AgentResumeSessionOptions, ): Promise { this.assertAcceptingAgentRegistrations(); const resolvedAgentId = validateAgentId( @@ -1122,7 +1125,12 @@ export class AgentManager { } const launchContext = await this.buildLaunchContext(resolvedAgentId, client); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); - const session = await client.resumeSession(handle, providerLaunchConfig, launchContext); + const session = await client.resumeSession( + handle, + providerLaunchConfig, + launchContext, + resumeOptions, + ); return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, persistence: handle, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6836cd8bc49..e473f75ec39 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -602,6 +602,12 @@ export interface AgentCreateSessionOptions { persistSession?: boolean; } +/** Runtime-only intent for a persisted-session resume. Never persist this option. */ +export interface AgentResumeSessionOptions { + /** Defaults to interactive. History loading may be read-only for archived native sessions. */ + purpose?: "interactive" | "history"; +} + /** * Returned by respondToPermission when the permission resolution requires * a follow-up turn (e.g. Codex plan approval → implementation). @@ -688,6 +694,7 @@ export interface AgentClient { handle: AgentPersistenceHandle, overrides?: Partial, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise; /** * Discover models and modes together. Implementations may use one upstream diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index dbdb5c0761c..96be83d160d 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -408,7 +408,7 @@ function wrapClientProvider( launchContext, ), ), - resumeSession: async (handle, overrides, launchContext) => + resumeSession: async (handle, overrides, launchContext, options) => wrapSessionProvider( provider, await inner.resumeSession( @@ -423,6 +423,7 @@ function wrapClientProvider( } : undefined, launchContext, + options, ), ), fetchCatalog: async (options) => { 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 201d4e0e437..07e3927935e 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -2742,6 +2742,81 @@ describe("ACPAgentSession close() tree-kill", () => { }); }); +describe("ACPAgentSession initialization cleanup", () => { + test("terminates the ACP process when session/new fails", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + + class FailingNewSession extends ACPAgentSession { + protected override async spawnProcess(): Promise { + return { + child, + connection: { + newSession: vi.fn().mockRejectedValue(new Error("session/new failed")), + } as unknown as ClientSideConnection, + initialize: { agentCapabilities: {} }, + }; + } + } + + const session = new FailingNewSession( + { provider: "copilot", cwd: "/tmp/paseo-acp-test" }, + { + provider: "copilot", + logger: createTestLogger(), + defaultCommand: ["copilot", "--acp"], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + }, + terminateProcess: terminator.terminate, + }, + ); + + await expect(session.initializeNewSession()).rejects.toThrow("session/new failed"); + + expect(terminator.terminated).toContain(child); + }); + + test("terminates the ACP process when session/load fails", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + + class FailingLoadSession extends ACPAgentSession { + protected override async spawnProcess(): Promise { + return { + child, + connection: { + loadSession: vi.fn().mockRejectedValue(new Error("session/load failed")), + } as unknown as ClientSideConnection, + initialize: { agentCapabilities: { loadSession: true } }, + }; + } + } + + const session = new FailingLoadSession( + { provider: "cursor", cwd: "/tmp/paseo-acp-test" }, + { + provider: "cursor", + logger: createTestLogger(), + defaultCommand: ["cursor-agent", "acp"], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + }, + handle: { provider: "cursor", sessionId: "session-1" }, + terminateProcess: terminator.terminate, + }, + ); + + await expect(session.initializeResumedSession()).rejects.toThrow("session/load failed"); + + expect(terminator.terminated).toContain(child); + }); +}); + describe("ACPAgentClient probe cleanup", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index ddb28b01da7..033e212382d 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1376,21 +1376,25 @@ export class ACPAgentSession implements AgentSession, ACPClient { } async initializeNewSession(): Promise { - const spawned = await this.spawnProcess(); - this.child = spawned.child; - this.connection = spawned.connection; - this.agentCapabilities = spawned.initialize.agentCapabilities ?? null; - - const response = await this.runACPRequest(() => - this.connection!.newSession({ - cwd: this.config.cwd, - mcpServers: this.acpMcpServers(), - }), - ); - this.sessionId = response.sessionId; - this.bootstrapThreadEventPending = true; - this.applySessionState(response); - await this.applyConfiguredOverrides(); + try { + const spawned = await this.spawnProcess(); + this.child = spawned.child; + this.connection = spawned.connection; + this.agentCapabilities = spawned.initialize.agentCapabilities ?? null; + + const response = await this.runACPRequest(() => + this.connection!.newSession({ + cwd: this.config.cwd, + mcpServers: this.acpMcpServers(), + }), + ); + this.sessionId = response.sessionId; + this.bootstrapThreadEventPending = true; + this.applySessionState(response); + await this.applyConfiguredOverrides(); + } catch (error) { + await this.closeAfterInitializationFailure(error); + } } /** @@ -1401,45 +1405,61 @@ export class ACPAgentSession implements AgentSession, ACPClient { * from these calls regardless of capabilities. */ async initializeResumedSession(): Promise { - const handle = this.initialHandle; - if (!handle) { - throw new Error("Resume requested without persistence handle"); - } + try { + const handle = this.initialHandle; + if (!handle) { + throw new Error("Resume requested without persistence handle"); + } - const spawned = await this.spawnProcess(); - this.child = spawned.child; - this.connection = spawned.connection; - this.agentCapabilities = spawned.initialize.agentCapabilities ?? null; - this.sessionId = handle.sessionId; - this.bootstrapThreadEventPending = true; + const spawned = await this.spawnProcess(); + this.child = spawned.child; + this.connection = spawned.connection; + this.agentCapabilities = spawned.initialize.agentCapabilities ?? null; + this.sessionId = handle.sessionId; + this.bootstrapThreadEventPending = true; - const sessionCapabilities = this.agentCapabilities?.sessionCapabilities; - if (this.agentCapabilities?.loadSession) { - this.replayingHistory = true; - const response = await this.runACPRequest(() => - this.connection!.loadSession({ - sessionId: handle.sessionId, - cwd: this.config.cwd, - mcpServers: this.acpMcpServers(), - }), - ); - this.replayingHistory = false; - this.historyPending = this.persistedHistory.length > 0; - this.applySessionState(response); - } else if (sessionCapabilities?.resume) { - const response = await this.runACPRequest(() => - this.connection!.unstable_resumeSession({ - sessionId: handle.sessionId, - cwd: this.config.cwd, - mcpServers: this.acpMcpServers(), - }), - ); - this.applySessionState(response); - } else { - throw new Error(`${this.provider} does not support ACP session resume`); + const sessionCapabilities = this.agentCapabilities?.sessionCapabilities; + if (this.agentCapabilities?.loadSession) { + this.replayingHistory = true; + const response = await this.runACPRequest(() => + this.connection!.loadSession({ + sessionId: handle.sessionId, + cwd: this.config.cwd, + mcpServers: this.acpMcpServers(), + }), + ); + this.replayingHistory = false; + this.historyPending = this.persistedHistory.length > 0; + this.applySessionState(response); + } else if (sessionCapabilities?.resume) { + const response = await this.runACPRequest(() => + this.connection!.unstable_resumeSession({ + sessionId: handle.sessionId, + cwd: this.config.cwd, + mcpServers: this.acpMcpServers(), + }), + ); + this.applySessionState(response); + } else { + throw new Error(`${this.provider} does not support ACP session resume`); + } + + await this.applyConfiguredOverrides(); + } catch (error) { + await this.closeAfterInitializationFailure(error); } + } - await this.applyConfiguredOverrides(); + private async closeAfterInitializationFailure(error: unknown): Promise { + try { + await this.close(); + } catch (closeError) { + this.logger.warn( + { err: closeError, initializationError: error }, + "Failed to close ACP process after session initialization failure", + ); + } + throw error; } async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { @@ -2335,6 +2355,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { { logger: this.logger, provider: this.provider }, ); const connection = new ClientSideConnection(() => this, stream); + // Take ownership before initialize so the outer initialization guard can + // close the process even when the ACP handshake itself rejects. + this.child = child; + this.connection = connection; const initialize = await this.runACPRequest(() => connection.initialize({ protocolVersion: PROTOCOL_VERSION, 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 a9df432f464..50c3baaae23 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 @@ -106,6 +106,37 @@ function createSession( return session; } +function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAppServerAgentClient { + const provider = new CodexAppServerAgentClient(createTestLogger()); + const internals = castInternals<{ + goalsEnabledPromise: Promise | null; + autoReviewEnabledPromise: Promise | null; + spawnAppServer: () => Promise; + }>(provider); + internals.goalsEnabledPromise = Promise.resolve(false); + internals.autoReviewEnabledPromise = Promise.resolve(false); + internals.spawnAppServer = async () => appServer.child; + return provider; +} + +function archivedThreadHandle() { + return { + sessionId: "archived-thread-id", + metadata: { + cwd: "/tmp/codex-question-test", + modeId: "auto", + model: "gpt-5.4", + }, + }; +} + +function archivedThreadErrorMessage(threadId: string): string { + return ( + `session ${threadId} is archived. ` + + `Run \`codex unarchive ${threadId}\` to unarchive it first.` + ); +} + function asInternals(session: CodexTestSession): CodexSessionTestAccess { return castInternals(session); } @@ -871,6 +902,66 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("loads archived Codex history without resuming the native thread", async () => { + const threadRequests: string[] = []; + const appServer = createFakeCodexAppServer({ + "thread/loaded/list": () => { + threadRequests.push("thread/loaded/list"); + return { data: [] }; + }, + "thread/resume": () => { + threadRequests.push("thread/resume"); + return Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id"))); + }, + "thread/read": () => { + threadRequests.push("thread/read"); + return { thread: { turns: [] } }; + }, + }); + const provider = createProviderWithFakeAppServer(appServer); + + const session = await provider.resumeSession(archivedThreadHandle(), undefined, undefined, { + purpose: "history", + }); + + expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume", "thread/read"]); + await session.close(); + appServer.assertNoErrors(); + }); + + test("closes Codex app-server when an interactive resume fails", async () => { + const appServer = createFakeCodexAppServer({ + "thread/resume": () => + Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id"))), + }); + const killSpy = vi.spyOn(appServer.child, "kill"); + const provider = createProviderWithFakeAppServer(appServer); + + await expect(provider.resumeSession(archivedThreadHandle())).rejects.toThrow( + archivedThreadErrorMessage("archived-thread-id"), + ); + + expect(killSpy).toHaveBeenCalledWith("SIGTERM"); + appServer.assertNoErrors(); + }); + + test("closes Codex app-server when archived history hydration fails", async () => { + const appServer = createFakeCodexAppServer({ + "thread/resume": () => + Promise.reject(new Error(archivedThreadErrorMessage("archived-thread-id"))), + "thread/read": () => Promise.reject(new Error("thread history is unavailable")), + }); + const killSpy = vi.spyOn(appServer.child, "kill"); + const provider = createProviderWithFakeAppServer(appServer); + + await expect( + provider.resumeSession(archivedThreadHandle(), undefined, undefined, { purpose: "history" }), + ).rejects.toThrow("thread history is unavailable"); + + expect(killSpy).toHaveBeenCalledWith("SIGTERM"); + appServer.assertNoErrors(); + }); + test("unarchives a persisted Codex thread through app-server", async () => { const threadRequests: Array<{ method: string; params: unknown }> = []; const appServer = createFakeCodexAppServer({ @@ -1113,12 +1204,7 @@ describe("Codex app-server provider", () => { }; }, }); - const provider = new CodexAppServerAgentClient(createTestLogger()); - castInternals<{ goalsEnabledPromise: Promise | null }>(provider).goalsEnabledPromise = - Promise.resolve(false); - castInternals<{ spawnAppServer: () => Promise }>( - provider, - ).spawnAppServer = async () => appServer.child; + const provider = createProviderWithFakeAppServer(appServer); const outcome = await Promise.race([ provider 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 ac3017dac21..19f6911066b 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 @@ -6,6 +6,7 @@ import { type AgentCreateSessionOptions, type AgentFeature, type AgentLaunchContext, + type AgentResumeSessionOptions, type AgentMode, type AgentModelDefinition, type McpServerConfig, @@ -110,6 +111,14 @@ function isRecord(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value); } +function isArchivedCodexThreadResumeError(error: unknown, threadId: string): boolean { + if (!(error instanceof Error)) return false; + const expectedMessage = + `session ${threadId} is archived. ` + + `Run \`codex unarchive ${threadId}\` to unarchive it first.`; + return error.message === expectedMessage; +} + function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolean { const message = error instanceof Error ? error.message : String(error); return message.includes(`no archived rollout found for thread id ${threadId}`); @@ -3174,6 +3183,7 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly goalsEnabled: boolean = false, private readonly autoReviewEnabled: boolean = false, private readonly agentId?: string, + private readonly initialResumePurpose: "interactive" | "history" = "interactive", ) { this.logger = logger.child({ module: "agent", @@ -3220,18 +3230,32 @@ export class CodexAppServerAgentSession implements AgentSession { this.client.setNotificationHandler((method, params) => this.handleNotification(method, params)); this.registerRequestHandlers(); - await this.client.request("initialize", buildCodexAppServerInitializeParams()); - this.client.notify("initialized", {}); + try { + await this.client.request("initialize", buildCodexAppServerInitializeParams()); + this.client.notify("initialized", {}); - await this.loadCollaborationModes(); - await this.loadSkills(); + await this.loadCollaborationModes(); + await this.loadSkills(); - if (this.currentThreadId) { - await this.ensureThreadLoaded(); - await this.loadPersistedHistory(); - } + if (this.currentThreadId) { + await this.ensureThreadLoaded({ + allowArchivedHistory: this.initialResumePurpose === "history", + }); + await this.loadPersistedHistory(); + } - this.connected = true; + this.connected = true; + } catch (error) { + try { + await this.close(); + } catch (closeError) { + this.logger.warn( + { err: closeError, connectError: error }, + "Failed to close Codex app-server after connection failure", + ); + } + throw error; + } } private traceContext(): CodexAppServerTraceContext { @@ -3537,7 +3561,9 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private async ensureThreadLoaded(): Promise { + private async ensureThreadLoaded( + options: { allowArchivedHistory?: boolean } = {}, + ): Promise { if (!this.client || !this.currentThreadId) return; try { const loaded = toObjectRecord(await this.client.request("thread/loaded/list", {})); @@ -3561,6 +3587,16 @@ export class CodexAppServerAgentSession implements AgentSession { } catch (error) { const threadId = this.currentThreadId; const message = error instanceof Error ? error.message : String(error); + if ( + options.allowArchivedHistory === true && + isArchivedCodexThreadResumeError(error, threadId) + ) { + this.logger.info( + { threadId }, + "Loading archived Codex thread history without resuming the native session", + ); + return; + } this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread"); throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error }); } @@ -6303,6 +6339,7 @@ export class CodexAppServerAgentClient implements AgentClient { handle: { sessionId: string; metadata?: Record }, overrides?: Partial, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise { const storedConfig = (handle.metadata ?? {}) as Partial; const merged: AgentSessionConfig = { @@ -6324,6 +6361,7 @@ export class CodexAppServerAgentClient implements AgentClient { goalsEnabled, autoReviewEnabled, launchContext?.agentId, + options?.purpose ?? "interactive", ); await session.connect(); return session; From eb83e2bb45545f38371e5a35a03e61a8060cfb37 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 18:40:11 +0200 Subject: [PATCH 035/420] fix(app): polish workspace controls --- packages/app/src/composer/draft/import-pill.tsx | 10 +++++++--- .../app/src/screens/workspace/workspace-screen.tsx | 10 +++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/app/src/composer/draft/import-pill.tsx b/packages/app/src/composer/draft/import-pill.tsx index 743693f2cc1..37a16176bee 100644 --- a/packages/app/src/composer/draft/import-pill.tsx +++ b/packages/app/src/composer/draft/import-pill.tsx @@ -19,6 +19,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); const bodyStyle = useMemo(() => [styles.body, isHovered && styles.bodyHovered], [isHovered]); + const labelStyle = useMemo(() => [styles.label, isHovered && styles.labelHovered], [isHovered]); return ( - + {t("importSession.title")} @@ -50,7 +51,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.xl, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, backgroundColor: theme.colors.surface1, @@ -59,7 +60,10 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, label: { - color: theme.colors.foreground, + color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + labelHovered: { + color: theme.colors.foreground, + }, })); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index f64447d9ab0..d3e1e0e872f 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3536,7 +3536,7 @@ function WorkspaceScreenContent({ tooltipLabel={t("workspace.tabs.explorer.toggle")} tooltipKeys={EXPLORER_TOGGLE_KEYS} tooltipSide="left" - style={styles.compactHeaderActionButton} + style={[styles.compactHeaderActionButton, styles.explorerPanelButton]} accessible accessibilityRole="button" accessibilityLabel={explorerToggleLabel} @@ -3959,6 +3959,11 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, + explorerPanelButton: { + // The 32px trigger and 16px panel glyph leave 8px more trailing space than + // the 22px split-pane trigger and its 14px glyph in the row below. + marginRight: -theme.spacing[2], + }, compactHeaderMenuCluster: { flexDirection: "row", alignItems: "center", @@ -3977,6 +3982,9 @@ const styles = StyleSheet.create((theme) => ({ minHeight: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2, minWidth: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2, borderRadius: theme.borderRadius.md, + // Match the painted right edge of the trailing split-pane glyph below. The + // two header rows intentionally use different control sizes and padding. + marginRight: -7, }, sourceControlButtonWithStats: { paddingHorizontal: theme.spacing[3], From a3438f96f893d6a8d61c9e4bf7f585b7e38eba9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=B4=E5=A5=B9=E5=91=BD=40?= <908280968@qq.com> Date: Fri, 24 Jul 2026 01:43:09 +0800 Subject: [PATCH 036/420] fix(app): preserve file line endings and UTF-8 BOM (#2277) * fix(app): preserve file line endings and UTF-8 BOM * refactor(app): simplify line ending preservation Let the editor parse newline variants and serialize with the file's first separator. Mixed-ending files become uniform on edit without a separate normalization layer. --------- Co-authored-by: Mohamed Boudra --- packages/app/e2e/file-editing.spec.ts | 23 ++++ .../app/src/file-explorer/read-result.test.ts | 34 +++++ packages/app/src/file-explorer/read-result.ts | 5 + .../app/src/file-pane/editor/model.test.ts | 119 +++++++++++++++++- packages/app/src/file-pane/editor/model.ts | 20 ++- .../app/src/file-pane/editor/view.web.tsx | 11 +- packages/app/src/file-pane/pane.tsx | 9 +- packages/app/src/stores/session-store.ts | 2 + 8 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 packages/app/src/file-explorer/read-result.test.ts diff --git a/packages/app/e2e/file-editing.spec.ts b/packages/app/e2e/file-editing.spec.ts index 5dd0cd6028c..fe8a3d0f25b 100644 --- a/packages/app/e2e/file-editing.spec.ts +++ b/packages/app/e2e/file-editing.spec.ts @@ -239,6 +239,29 @@ test.describe("CodeMirror workspace file editing", () => { await expect(editor(page)).toContainText("const afterReconnect = 9;"); }); + test("preserves a UTF-8 BOM and uses the first line separator after saving", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "file-editing-encoding-" }); + const sourcePath = path.join(workspace.repoPath, "windows.ts"); + await writeFile( + sourcePath, + Buffer.from("\uFEFFconst initial = true;\r\nconst mixed = true;\n", "utf8"), + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "windows.ts"); + + await replaceEditorText(page, "const saved = true;\nconst normalized = true;\n"); + await editor(page).press("Control+s"); + + const expected = Buffer.from( + "\uFEFFconst saved = true;\r\nconst normalized = true;\r\n", + "utf8", + ).toString("hex"); + await expect.poll(async () => (await readFile(sourcePath)).toString("hex")).toBe(expected); + }); + test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => { const workspace = await withWorkspace({ prefix: "file-editing-draft-" }); const sourcePath = path.join(workspace.repoPath, "draft.ts"); diff --git a/packages/app/src/file-explorer/read-result.test.ts b/packages/app/src/file-explorer/read-result.test.ts new file mode 100644 index 00000000000..fa5d3bb882e --- /dev/null +++ b/packages/app/src/file-explorer/read-result.test.ts @@ -0,0 +1,34 @@ +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import { describe, expect, it } from "vitest"; +import { explorerFileFromReadResult } from "./read-result"; + +function textRead(bytes: Uint8Array): FileReadResult { + return { + bytes, + mime: "text/plain", + size: bytes.byteLength, + path: "notes.txt", + kind: "text", + modifiedAt: "2026-07-21T00:00:00.000Z", + }; +} + +describe("explorerFileFromReadResult", () => { + it("records and hides a leading UTF-8 BOM", () => { + const file = explorerFileFromReadResult( + textRead(new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69])), + ); + + expect(file).toMatchObject({ content: "hi", hasBom: true }); + }); + + it("does not mark BOM-free text or non-leading U+FEFF as BOM files", () => { + const plain = explorerFileFromReadResult(textRead(new TextEncoder().encode("hi"))); + const embedded = explorerFileFromReadResult( + textRead(new Uint8Array([0x68, 0x69, 0xef, 0xbb, 0xbf])), + ); + + expect(plain.hasBom).toBe(false); + expect(embedded.hasBom).toBe(false); + }); +}); diff --git a/packages/app/src/file-explorer/read-result.ts b/packages/app/src/file-explorer/read-result.ts index 693e0a4e144..51b8ee10afa 100644 --- a/packages/app/src/file-explorer/read-result.ts +++ b/packages/app/src/file-explorer/read-result.ts @@ -8,8 +8,13 @@ export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile { kind: file.kind, encoding: isText ? "utf-8" : "none", content: isText ? new TextDecoder().decode(file.bytes) : undefined, + hasBom: isText && hasUtf8Bom(file.bytes), mimeType: file.mime, size: file.size, modifiedAt: file.modifiedAt, }; } + +function hasUtf8Bom(bytes: Uint8Array): boolean { + return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; +} diff --git a/packages/app/src/file-pane/editor/model.test.ts b/packages/app/src/file-pane/editor/model.test.ts index 9f165527751..026e8bc445b 100644 --- a/packages/app/src/file-pane/editor/model.test.ts +++ b/packages/app/src/file-pane/editor/model.test.ts @@ -77,8 +77,17 @@ function ready( return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt }; } -function makeModel() { - const file = { content: "one", version: ready() as Extract }; +interface MakeModelInput { + content?: string; + hasBom?: boolean; +} + +function makeModel(input: MakeModelInput = {}) { + const file = { + content: input.content ?? "one", + hasBom: input.hasBom ?? false, + version: ready() as Extract, + }; const session = new FileSession(file); const clock = new TestClock(); return { model: new FileEditorModel({ file, session, clock }), session, clock }; @@ -142,10 +151,55 @@ describe("FileEditorModel", () => { expect(model.getSnapshot().status).toBe("clean"); }); + test("keeps CRLF content in file form", async () => { + const { model, session } = makeModel({ content: "one\r\ntwo\r\n" }); + + expect(model.getSnapshot()).toMatchObject({ + content: "one\r\ntwo\r\n", + lineSeparator: "\r\n", + }); + model.edit("one\r\ntwo\r\nthree\r\n"); + await model.save(); + + expect(session.writes).toEqual([ + { + content: "one\r\ntwo\r\nthree\r\n", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + }, + ]); + }); + + test("restores a UTF-8 BOM before writing a CRLF file", async () => { + const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); + + model.edit("saved\r\n"); + await model.save(); + model.edit("saved again\r\n"); + await model.save(); + + expect(session.writes).toEqual([ + { + content: "\uFEFFsaved\r\n", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + }, + { + content: "\uFEFFsaved again\r\n", + expectedModifiedAt: "2026-07-18T00:00:01.000Z", + }, + ]); + }); + + test("uses the first line separator when a file mixes styles", () => { + const { model } = makeModel({ content: "one\r\ntwo\nthree\r" }); + + expect(model.getSnapshot().lineSeparator).toBe("\r\n"); + }); + test("reloads a clean editor when the disk version changes", async () => { const { model, session } = makeModel(); session.file = { content: "external", + hasBom: false, version: ready("2026-07-18T00:00:02.000Z", 8) as Extract, }; @@ -155,6 +209,26 @@ describe("FileEditorModel", () => { expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" }); }); + test("adopts the format from a clean remote refresh", async () => { + const { model, session } = makeModel({ content: "local\r\n", hasBom: true }); + session.file = { + content: "remote\n", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 7) as Extract, + }; + + model.receiveFileVersion(session.file.version); + await Promise.resolve(); + expect(model.getSnapshot().lineSeparator).toBe("\n"); + model.edit("saved\n"); + await model.save(); + + expect(session.writes.at(-1)).toEqual({ + content: "saved\n", + expectedModifiedAt: "2026-07-18T00:00:02.000Z", + }); + }); + test("coalesces consecutive clean disk updates onto the latest reload", async () => { const { model, session } = makeModel(); const reads: Array<(file: FileEditorFile) => void> = []; @@ -164,9 +238,9 @@ describe("FileEditorModel", () => { model.receiveFileVersion(firstVersion); model.receiveFileVersion(latestVersion); - reads[0]?.({ content: "first", version: firstVersion }); + reads[0]?.({ content: "first", hasBom: false, version: firstVersion }); await Promise.resolve(); - reads[1]?.({ content: "latest", version: latestVersion }); + reads[1]?.({ content: "latest", hasBom: false, version: latestVersion }); await Promise.resolve(); expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" }); @@ -186,6 +260,21 @@ describe("FileEditorModel", () => { expect(model.getSnapshot().status).toBe("clean"); }); + test("keeps the local CRLF and BOM when overwriting a conflict", async () => { + const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); + model.edit("local\r\n"); + model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4)); + + await model.overwrite(); + + expect(session.writes).toEqual([ + { + content: "\uFEFFlocal\r\n", + expectedModifiedAt: "2026-07-18T00:00:02.000Z", + }, + ]); + }); + test("reload discards a conflicted local buffer for the disk contents", async () => { const { model, session } = makeModel(); model.edit("local"); @@ -193,7 +282,7 @@ describe("FileEditorModel", () => { FileVersion, { status: "ready" } >; - session.file = { content: "disk", version: diskVersion }; + session.file = { content: "disk", hasBom: false, version: diskVersion }; model.receiveFileVersion(diskVersion); await model.reload(); @@ -201,6 +290,26 @@ describe("FileEditorModel", () => { expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" }); }); + test("adopts the remote format when reloading a conflict", async () => { + const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); + model.edit("local\r\n"); + const diskVersion = ready("2026-07-18T00:00:02.000Z", 5) as Extract< + FileVersion, + { status: "ready" } + >; + session.file = { content: "disk\n", hasBom: false, version: diskVersion }; + model.receiveFileVersion(diskVersion); + + await model.reload(); + model.edit("saved\n"); + await model.save(); + + expect(session.writes.at(-1)).toEqual({ + content: "saved\n", + expectedModifiedAt: "2026-07-18T00:00:02.000Z", + }); + }); + test("reports failed saves without losing the local buffer", async () => { const { model, session } = makeModel(); session.nextWrite = new Error("disk full"); diff --git a/packages/app/src/file-pane/editor/model.ts b/packages/app/src/file-pane/editor/model.ts index a2b081d7bc2..48cc77e9542 100644 --- a/packages/app/src/file-pane/editor/model.ts +++ b/packages/app/src/file-pane/editor/model.ts @@ -1,10 +1,12 @@ import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error"; +export type FileLineSeparator = "\n" | "\r\n" | "\r"; export interface FileEditorSnapshot { status: FileEditorStatus; content: string; + lineSeparator: FileLineSeparator; modified: boolean; version: FileVersion; observedVersion: FileVersion; @@ -13,6 +15,7 @@ export interface FileEditorSnapshot { export interface FileEditorFile { content: string; + hasBom: boolean; version: Extract; } @@ -49,6 +52,7 @@ export class FileEditorModel { private disposed = false; private observedWhileSaving: FileVersion | null = null; private persistedContent: string; + private hasBom: boolean; constructor(input: { file: FileEditorFile; @@ -58,9 +62,11 @@ export class FileEditorModel { this.session = input.session; this.clock = input.clock ?? systemClock; this.persistedContent = input.file.content; + this.hasBom = input.file.hasBom; this.snapshot = { status: "clean", content: input.file.content, + lineSeparator: detectLineSeparator(input.file.content), modified: false, version: input.file.version, observedVersion: input.file.version, @@ -167,10 +173,11 @@ export class FileEditorModel { const content = this.snapshot.content; this.observedWhileSaving = null; this.setSnapshot({ ...this.snapshot, status: "saving", error: null }); + const serializedContent = this.hasBom ? `\uFEFF${content}` : content; let result: FileWriteResult; try { result = await this.session.write({ - content, + content: serializedContent, expectedModifiedAt: expectedVersion.modifiedAt, expectedRevision: expectedVersion.revision, }); @@ -241,9 +248,11 @@ export class FileEditorModel { return; } this.persistedContent = file.content; + this.hasBom = file.hasBom; this.setSnapshot({ status: "clean", content: file.content, + lineSeparator: detectLineSeparator(file.content), modified: false, version: file.version, observedVersion: file.version, @@ -290,6 +299,15 @@ export class FileEditorModel { } } +function detectLineSeparator(content: string): FileLineSeparator { + for (let index = 0; index < content.length; index += 1) { + const character = content.charCodeAt(index); + if (character === 10) return "\n"; + if (character === 13) return content.charCodeAt(index + 1) === 10 ? "\r\n" : "\r"; + } + return "\n"; +} + function sameVersion(left: FileVersion, right: FileVersion): boolean { if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path) return false; diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index cc211c7a542..abf79f4ed76 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -56,7 +56,8 @@ export function FileEditorView({ update.docChanged && !update.transactions.some((tr) => tr.annotation(remoteUpdate)) ) { - values.model.edit(update.state.doc.toString()); + const { lineSeparator } = values.model.getSnapshot(); + values.model.edit(update.state.doc.sliceString(0, undefined, lineSeparator)); } if (update.selectionSet || update.docChanged) { const head = update.state.selection.main.head; @@ -77,10 +78,12 @@ export function FileEditorView({ useEffect(() => { const view = viewRef.current; - if (!view || view.state.doc.toString() === snapshot.content) return; - const head = Math.min(view.state.selection.main.head, snapshot.content.length); + if (!view) return; + const document = view.state.toText(snapshot.content); + if (view.state.doc.eq(document)) return; + const head = Math.min(view.state.selection.main.head, document.length); view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: snapshot.content }, + changes: { from: 0, to: view.state.doc.length, insert: document }, selection: { anchor: head }, annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)], }); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index ef8a74987ed..db7a4a5aef9 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -637,9 +637,13 @@ function EditableFilePane({ () => ({ async read(): Promise { const file = await client.readFile(cwd, path); - if (file.kind !== "text") throw new Error("File is no longer text."); + const decodedFile = explorerFileFromReadResult(file); + if (decodedFile.kind !== "text" || decodedFile.content === undefined) { + throw new Error("File is no longer text."); + } return { - content: new TextDecoder().decode(file.bytes), + content: decodedFile.content, + hasBom: decodedFile.hasBom, version: { status: "ready", cwd, @@ -661,6 +665,7 @@ function EditableFilePane({ new FileEditorModel({ file: { content: preview.content ?? "", + hasBom: preview.hasBom, version: { status: "ready", cwd, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index d6e37a1714d..1d61867f267 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -278,6 +278,8 @@ export interface ExplorerFile { kind: ExplorerFileKind; encoding: ExplorerEncoding; content?: string; + // TextDecoder removes a leading UTF-8 BOM; retain this bit so file writes can restore it. + hasBom: boolean; mimeType?: string; size: number; modifiedAt: string; From 8a8f2baf8012be672c2af43cced12d01d57c70a0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 20:43:57 +0200 Subject: [PATCH 037/420] Prevent duplicate ACP image prompts (#2363) * fix(acp): prevent duplicate image prompts Some ACP agents echo submitted prompts without preserving client message IDs. Attribute live user chunks to the active submitted turn, while coalescing provider-owned chunks outside it. * fix(acp): flush user messages on failed turns * fix(acp): flush user messages on session close --- .../server/agent/providers/acp-agent.test.ts | 236 +++++++++++++++++- .../src/server/agent/providers/acp-agent.ts | 170 +++++++------ 2 files changed, 327 insertions(+), 79 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 07e3927935e..49adf40a2c1 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -2150,6 +2150,14 @@ describe("ACPAgentSession", () => { content: { type: "text", text: "lo" }, } as SessionUpdate, }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "assistant-2", + content: { type: "text", text: "" }, + } as SessionUpdate, + }); const timeline = events .filter((event) => event.type === "timeline") @@ -2161,7 +2169,6 @@ describe("ACPAgentSession", () => { { type: "assistant_message", text: " How are you?", messageId: "assistant-1" }, { type: "reasoning", text: "Thinking" }, { type: "reasoning", text: " more" }, - { type: "user_message", text: "hel", messageId: "user-1" }, { type: "user_message", text: "hello", messageId: "user-1" }, ]); }); @@ -2528,6 +2535,102 @@ describe("ACPAgentSession", () => { ]); }); + test("startTurn dedupes a provider-owned user echo streamed as text and image chunks", async () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + const prompt = vi.fn(() => new Promise(() => {})); + + asInternals(session).sessionId = "session-1"; + asInternals(session).connection = { prompt }; + + session.subscribe((event) => { + events.push(event); + }); + + await session.startTurn( + [ + { type: "text", text: "hey" }, + { type: "image", data: "AA==", mimeType: "image/png" }, + ], + { clientMessageId: "msg-client-1" }, + ); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + messageId: "msg-provider-1", + content: { type: "text", text: "hey" }, + } as SessionUpdate, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + messageId: "msg-provider-1", + content: { type: "image", data: "AA==", mimeType: "image/png" }, + } as SessionUpdate, + }); + + expect( + events.filter((event) => event.type === "timeline" && event.item.type === "user_message"), + ).toEqual([ + { + type: "timeline", + provider: session.provider, + item: { + type: "user_message", + text: "hey", + messageId: "msg-client-1", + clientMessageId: "msg-client-1", + }, + turnId: expect.any(String), + }, + ]); + }); + + test("startTurn keeps an image-only provider echo when no canonical user message was emitted", async () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + const prompt = vi.fn(() => new Promise(() => {})); + + asInternals(session).sessionId = "session-1"; + asInternals(session).connection = { prompt }; + + session.subscribe((event) => { + events.push(event); + }); + + await session.startTurn([{ type: "image", data: "AA==", mimeType: "image/png" }], { + clientMessageId: "msg-client-1", + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "image", data: "AA==", mimeType: "image/png" }, + } as SessionUpdate, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "assistant-1", + content: { type: "text", text: "I see it" }, + } as SessionUpdate, + }); + + expect( + events.filter((event) => event.type === "timeline" && event.item.type === "user_message"), + ).toEqual([ + { + type: "timeline", + provider: session.provider, + item: { type: "user_message", text: "[image]" }, + turnId: expect.any(String), + }, + ]); + }); + test("startTurn converts background prompt rejections into turn_failed events", async () => { const session = createSession(); const events: Array<{ type: string; turnId?: string; error?: string }> = []; @@ -2561,6 +2664,49 @@ describe("ACPAgentSession", () => { expect(asInternals(session).activeForegroundTurnId).toBeNull(); }); + test("flushes an image-only provider echo before a rejected turn finishes", async () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + let rejectPrompt!: (error: Error) => void; + const prompt = vi.fn( + () => + new Promise((_, reject) => { + rejectPrompt = reject; + }), + ); + + asInternals(session).sessionId = "session-1"; + asInternals(session).connection = { prompt }; + session.subscribe((event) => events.push(event)); + + const { turnId } = await session.startTurn([ + { type: "image", data: "AA==", mimeType: "image/png" }, + ]); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "image", data: "AA==", mimeType: "image/png" }, + } as SessionUpdate, + }); + + rejectPrompt(new Error("prompt failed")); + await Promise.resolve(); + await Promise.resolve(); + + expect( + events.filter((event) => event.type === "timeline" || event.type === "turn_failed"), + ).toEqual([ + { + type: "timeline", + provider: session.provider, + item: { type: "user_message", text: "[image]" }, + turnId, + }, + expect.objectContaining({ type: "turn_failed", turnId, error: "prompt failed" }), + ]); + }); + test("startTurn preserves JSON-RPC error details from a real ACP prompt response", async () => { const session = createSession(); const clientToAgent = new TransformStream(); @@ -2663,6 +2809,32 @@ describe("ACPAgentSession close() tree-kill", () => { vi.restoreAllMocks(); }); + test("close() flushes a buffered provider-owned user message before unsubscribing", async () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + asInternals(session).sessionId = "session-1"; + + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "image", data: "AA==", mimeType: "image/png" }, + } as SessionUpdate, + }); + expect(events).toEqual([]); + + await session.close(); + + expect(events).toEqual([ + { + type: "timeline", + provider: session.provider, + item: { type: "user_message", text: "[image]" }, + }, + ]); + }); + test("close() terminates the main child process via the process tree", async () => { const terminator = new FakeTerminator(); const session = createSession(terminator.terminate); @@ -2984,6 +3156,68 @@ describe("ACP session/load invariant — cwd and mcpServers always passed", () = ]); }); + test("coalesces an ID-less text and image user message during loadSession replay", async () => { + let session!: ACPAgentSession; + const loadSession = async () => { + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "hey" }, + } as SessionUpdate, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "image", data: "AA==", mimeType: "image/png" }, + } as SessionUpdate, + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "assistant-replay-1", + content: { type: "text", text: "Hello" }, + } as SessionUpdate, + }); + return { + sessionId: "session-1", + modes: null, + models: null, + configOptions: [], + }; + }; + ({ session } = makeTestSession({ + capabilities: { loadSession: true }, + handle: { sessionId: "session-1", provider: "test-acp" }, + loadSession, + })); + + await session.initializeResumedSession(); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + expect(history).toEqual([ + { + type: "timeline", + provider: session.provider, + item: { type: "user_message", text: "hey[image]" }, + }, + { + type: "timeline", + provider: session.provider, + item: { + type: "assistant_message", + text: "Hello", + messageId: "assistant-replay-1", + }, + }, + ]); + }); + test("assigns stable fallback IDs to ID-less assistant messages during loadSession replay", async () => { let session!: ACPAgentSession; const loadSession = async () => { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 033e212382d..399ed89427f 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -467,14 +467,9 @@ interface PendingPermission { turnId: string | null; } -interface MessageAssemblyState { +interface PendingUserMessage { text: string; -} - -interface SubmittedUserMessageEcho { - messageId: string; - text: string; - turnId: string; + messageId?: string; } export type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse; @@ -1302,9 +1297,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly launchEnv?: Record; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly pendingPermissions = new Map(); - private readonly messageAssemblies = new Map(); - private readonly submittedUserMessageIds = new Set(); - private activeSubmittedUserMessage: SubmittedUserMessageEcho | null = null; + private pendingUserMessage: PendingUserMessage | null = null; + private submittedUserMessageTurnId: string | null = null; private readonly toolCalls = new Map(); private readonly terminalEntries = new Map(); private readonly persistedHistory: AgentTimelineItem[] = []; @@ -1428,6 +1422,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { mcpServers: this.acpMcpServers(), }), ); + this.deliverTranslatedEvents(this.flushPendingUserMessage()); this.replayingHistory = false; this.historyPending = this.persistedHistory.length > 0; this.applySessionState(response); @@ -1493,11 +1488,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { throw new Error("A foreground turn is already active"); } + this.deliverTranslatedEvents(this.flushPendingUserMessage()); const turnId = randomUUID(); const messageId = options?.clientMessageId ?? randomUUID(); this.activeForegroundTurnId = turnId; this.fallbackAssistantMessageId = null; - this.activeSubmittedUserMessage = null; + this.submittedUserMessageTurnId = null; this.emitBootstrapThreadEvent(); this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); this.emitSubmittedUserMessage(prompt, messageId, turnId, options?.clientMessageId); @@ -2043,6 +2039,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { } this.closed = true; + this.deliverTranslatedEvents(this.flushPendingUserMessage()); this.settleCommandsReady(); for (const pending of this.pendingPermissions.values()) { @@ -2141,6 +2138,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { }, "provider.acp.parsed_event", ); + this.deliverTranslatedEvents(events); + } + + private deliverTranslatedEvents(events: AgentStreamEvent[]): void { if (this.replayingHistory) { for (const event of events) { if (event.type === "timeline") { @@ -2467,45 +2468,43 @@ export class ACPAgentSession implements AgentSession, ACPClient { } private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] { + if (update.sessionUpdate === "user_message_chunk") { + return this.handleUserMessageChunk(update); + } + + const pendingUserEvents = this.flushPendingUserMessage(); switch (update.sessionUpdate) { - case "user_message_chunk": { - this.fallbackAssistantMessageId = null; - const item = this.createMessageTimelineItem("user_message", update); - if (!item) { - return []; - } - if (item.type !== "user_message") { - return [this.wrapTimeline(item)]; - } - if (this.isSubmittedUserMessageEcho(item)) { - return []; - } - return [this.wrapTimeline(item)]; - } case "agent_message_chunk": { const item = this.createMessageTimelineItem("assistant_message", update); - return item ? [this.wrapTimeline(item)] : []; + return item ? [...pendingUserEvents, this.wrapTimeline(item)] : pendingUserEvents; } case "agent_thought_chunk": { this.fallbackAssistantMessageId = null; const item = this.createMessageTimelineItem("reasoning", update); - return item ? [this.wrapTimeline(item)] : []; + return item ? [...pendingUserEvents, this.wrapTimeline(item)] : pendingUserEvents; } case "tool_call": this.fallbackAssistantMessageId = null; - return this.handleToolCallUpdate(update.toolCallId, update, undefined); + return [ + ...pendingUserEvents, + ...this.handleToolCallUpdate(update.toolCallId, update, undefined), + ]; case "tool_call_update": - return this.handleToolCallUpdate( - update.toolCallId, - update, - this.toolCalls.get(update.toolCallId), - ); + return [ + ...pendingUserEvents, + ...this.handleToolCallUpdate( + update.toolCallId, + update, + this.toolCalls.get(update.toolCallId), + ), + ]; case "plan": this.fallbackAssistantMessageId = null; - return [this.wrapTimeline(mapPlanToTimeline(update))]; + return [...pendingUserEvents, this.wrapTimeline(mapPlanToTimeline(update))]; case "current_mode_update": this.handleCurrentModeUpdate(update); return [ + ...pendingUserEvents, { type: "mode_changed", provider: this.provider, @@ -2514,13 +2513,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { }, ]; case "config_option_update": - return this.handleConfigOptionUpdate(update); + return [...pendingUserEvents, ...this.handleConfigOptionUpdate(update)]; case "session_info_update": this.handleSessionInfoUpdate(update); - return []; + return pendingUserEvents; case "usage_update": this.handleUsageUpdate(update); - return []; + return pendingUserEvents; case "available_commands_update": this.cachedCommands = update.availableCommands.map((command) => ({ name: command.name, @@ -2529,12 +2528,60 @@ export class ACPAgentSession implements AgentSession, ACPClient { kind: "command", })); this.settleCommandsReady(); - return []; + return pendingUserEvents; default: - return []; + return pendingUserEvents; } } + private handleUserMessageChunk( + update: Extract, + ): AgentStreamEvent[] { + this.fallbackAssistantMessageId = null; + if ( + this.activeForegroundTurnId && + this.submittedUserMessageTurnId === this.activeForegroundTurnId + ) { + return []; + } + + const chunkText = contentBlockToText(update.content); + if (!chunkText) { + return []; + } + + const messageId = update.messageId ?? undefined; + const pending = this.pendingUserMessage; + const startsNewMessage = Boolean( + pending?.messageId && messageId && pending.messageId !== messageId, + ); + const events = startsNewMessage ? this.flushPendingUserMessage() : []; + this.pendingUserMessage ??= { + text: "", + ...(messageId ? { messageId } : {}), + }; + if (!this.pendingUserMessage.messageId && messageId) { + this.pendingUserMessage.messageId = messageId; + } + this.pendingUserMessage.text += chunkText; + return events; + } + + private flushPendingUserMessage(): AgentStreamEvent[] { + const pending = this.pendingUserMessage; + if (!pending) { + return []; + } + this.pendingUserMessage = null; + return [ + this.wrapTimeline({ + type: "user_message", + text: pending.text, + ...(pending.messageId ? { messageId: pending.messageId } : {}), + }), + ]; + } + private handleToolCallUpdate( toolCallId: string, update: ToolCall | ToolCallUpdate, @@ -2549,13 +2596,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { } private createMessageTimelineItem( - type: "user_message" | "assistant_message" | "reasoning", + type: "assistant_message" | "reasoning", update: Extract< SessionUpdate, - { sessionUpdate: "user_message_chunk" | "agent_message_chunk" | "agent_thought_chunk" } + { sessionUpdate: "agent_message_chunk" | "agent_thought_chunk" } >, ): - | { type: "user_message"; text: string; messageId?: string } | { type: "assistant_message"; text: string; messageId: string } | { type: "reasoning"; text: string } | null { @@ -2563,14 +2609,6 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (!chunkText) { return null; } - const key = this.messageAssemblyKey(type, update.messageId); - const state = this.messageAssemblies.get(key) ?? { text: "" }; - state.text += chunkText; - this.messageAssemblies.set(key, state); - - if (type === "user_message") { - return { type: "user_message", text: state.text, messageId: update.messageId ?? undefined }; - } if (type === "assistant_message") { return { type: "assistant_message", @@ -2590,15 +2628,6 @@ export class ACPAgentSession implements AgentSession, ACPClient { return this.fallbackAssistantMessageId; } - private messageAssemblyKey( - type: "user_message" | "assistant_message" | "reasoning", - messageId: string | null | undefined, - ): string { - const fallbackId = - type === "user_message" ? (this.activeForegroundTurnId ?? "default") : "default"; - return `${type}:${messageId ?? fallbackId}`; - } - private handleCurrentModeUpdate(update: CurrentModeUpdate): void { this.currentMode = this.transformModeId(update.currentModeId); } @@ -2717,8 +2746,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (text.trim().length === 0) { return; } - this.submittedUserMessageIds.add(messageId); - this.activeSubmittedUserMessage = { messageId, text, turnId }; + this.submittedUserMessageTurnId = turnId; this.pushEvent({ type: "timeline", provider: this.provider, @@ -2749,29 +2777,15 @@ export class ACPAgentSession implements AgentSession, ACPClient { private finishTurn( event: Extract, ): void { + this.deliverTranslatedEvents(this.flushPendingUserMessage()); this.activeForegroundTurnId = null; this.fallbackAssistantMessageId = null; - if (this.activeSubmittedUserMessage?.turnId === event.turnId) { - this.activeSubmittedUserMessage = null; + if (this.submittedUserMessageTurnId === event.turnId) { + this.submittedUserMessageTurnId = null; } this.pushEvent(event); } - private isSubmittedUserMessageEcho( - item: Extract, - ): boolean { - const active = this.activeSubmittedUserMessage; - if (!active || active.turnId !== this.activeForegroundTurnId) { - return false; - } - if (item.messageId) { - if (this.submittedUserMessageIds.has(item.messageId)) { - return true; - } - } - return active.text.startsWith(item.text); - } - private emitBootstrapThreadEvent(): void { if (!this.bootstrapThreadEventPending || !this.sessionId) { return; From b73592ccac74420750f7de691af4abad12b9f179 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 21:08:12 +0200 Subject: [PATCH 038/420] Fix web chat stickiness at non-default zoom (#2368) Treat subpixel browser scroll rounding as the visual bottom while preserving material overscroll protection. --- .../src/agent-stream/strategy-web.test.tsx | 83 +++++++++++++++++++ .../app/src/agent-stream/strategy-web.tsx | 4 +- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 7bd0c5f9599..f407fefbbf3 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -199,6 +199,89 @@ describe("createWebStreamStrategy", () => { expect(renderLiveHeadRow).toHaveBeenCalledTimes(2); }); + it("keeps bottom anchoring through subpixel browser rounding", () => { + const scrollTo = vi.fn(); + HTMLElement.prototype.scrollTo = scrollTo; + const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); + const viewportRef = React.createRef(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + strategy.render({ + agentId: "agent", + segments: { + historyVirtualized: [], + historyMounted: [userMessage(1)], + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + renderers: createRenderers(vi.fn()), + 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, + }), + ); + }); + + const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]'); + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected agent chat scroll container"); + } + Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 766 }); + Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 5725 }); + Object.defineProperty(scrollContainer, "scrollTop", { + configurable: true, + value: 4959.1708984375, + }); + scrollTo.mockClear(); + + act(() => { + viewportRef.current?.scrollToBottom("message-sent"); + }); + + expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" }); + + scrollTo.mockClear(); + Object.defineProperty(scrollContainer, "scrollTop", { + configurable: true, + value: 4960.5, + }); + + act(() => { + viewportRef.current?.scrollToBottom("message-sent"); + }); + + expect(scrollTo).toHaveBeenCalledWith({ top: 5725, behavior: "auto" }); + + scrollTo.mockClear(); + Object.defineProperty(scrollContainer, "scrollTop", { + configurable: true, + value: 4967, + }); + + act(() => { + viewportRef.current?.scrollToBottom("message-sent"); + }); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + 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 bc5c11604fb..abac0fac174 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -22,6 +22,7 @@ type ScrollBehaviorLike = "auto" | "smooth"; const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200; const USER_SCROLL_DELTA_EPSILON = 1; +const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; const HISTORY_START_THRESHOLD_PX = 96; @@ -88,7 +89,8 @@ function getScrollContainerDistanceFromBottom( function isScrollContainerOverscrolledPastBottom( scrollContainer: Pick, ): boolean { - return getScrollContainerDistanceFromBottom(scrollContainer) < 0; + // Browser zoom can leave scrollTop fractional while the height metrics remain integer-valued. + return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX; } function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { From a290f74705bd544e1b1186d4b61e740a9be2e38d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 21:34:07 +0200 Subject: [PATCH 039/420] fix(app): keep grouped tool-call shimmers full speed (#2369) Retained group headings could enter loading after their initial layout, leaving React Native Web without a registered layout observer and the shimmer with a zero-length endpoint. Measure web badge labels from mount so later loading states reuse real dimensions. --- packages/app/e2e/tool-call-shimmer.spec.ts | 238 +++++++++++++++++++++ packages/app/src/components/message.tsx | 4 +- 2 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/tool-call-shimmer.spec.ts diff --git a/packages/app/e2e/tool-call-shimmer.spec.ts b/packages/app/e2e/tool-call-shimmer.spec.ts new file mode 100644 index 00000000000..c0d1ad51446 --- /dev/null +++ b/packages/app/e2e/tool-call-shimmer.spec.ts @@ -0,0 +1,238 @@ +import type { Locator, Page } from "@playwright/test"; +import { test, expect } from "./fixtures"; +import { daemonWsRoutePattern } from "./helpers/daemon-port"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; + +type WebSocketMessage = string | Buffer; + +interface ShimmerEvidence { + animationDuration: string; + endPx: number; + label: string; + renderedWidth: number; + startPx: number; +} + +function parseSessionMessage(message: WebSocketMessage): Record | null { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown }; + return envelope.type === "session" && envelope.message && typeof envelope.message === "object" + ? (envelope.message as Record) + : null; + } catch { + return null; + } +} + +function getToolCallStatus( + message: WebSocketMessage, + agentId: string, +): { callId: string; status: string } | null { + const sessionMessage = parseSessionMessage(message); + if (sessionMessage?.type !== "agent_stream") { + return null; + } + const payload = sessionMessage.payload as Record | undefined; + if (payload?.agentId !== agentId) { + return null; + } + const event = payload.event as Record | undefined; + const item = event?.item as Record | undefined; + return event?.type === "timeline" && + item?.type === "tool_call" && + typeof item.callId === "string" && + typeof item.status === "string" + ? { callId: item.callId, status: item.status } + : null; +} + +function replaceToolCallStatus(message: WebSocketMessage, status: string): string { + const raw = typeof message === "string" ? message : message.toString("utf8"); + const envelope = JSON.parse(raw) as { + message?: { payload?: { event?: { item?: { status?: string } } } }; + }; + const item = envelope.message?.payload?.event?.item; + if (!item) { + throw new Error("Expected a tool-call session message"); + } + item.status = status; + return JSON.stringify(envelope); +} + +async function gateSecondToolCall(page: Page, agentId: string) { + let firstCallId: string | null = null; + let secondCallId: string | null = null; + let secondRunningMessage: WebSocketMessage | null = null; + let releaseSecondRequested = false; + let pauseServerMessages = false; + let secondRunningForwarded = false; + let forwardSecondRunning: (() => void) | null = null; + let resolveFirstCompleted!: () => void; + let resolveSecondRunning!: () => void; + const firstCompleted = new Promise((resolve) => { + resolveFirstCompleted = resolve; + }); + const secondRunning = new Promise((resolve) => { + resolveSecondRunning = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + forwardSecondRunning = () => { + if (!secondRunningMessage || secondRunningForwarded) { + return; + } + ws.send(secondRunningMessage); + secondRunningForwarded = true; + }; + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + if (pauseServerMessages) { + return; + } + + const toolCall = getToolCallStatus(message, agentId); + if (!toolCall) { + ws.send(message); + return; + } + if (!firstCallId) { + firstCallId = toolCall.callId; + } + if (toolCall.callId === firstCallId) { + if (toolCall.status === "running" || toolCall.status === "executing") { + return; + } + if (toolCall.status === "completed") { + // The mock tool completes inside the daemon's coalescing window, so the + // browser naturally receives its authoritative completed state first. + ws.send(message); + resolveFirstCompleted(); + return; + } + } + + secondCallId ??= toolCall.callId; + if ( + toolCall.callId === secondCallId && + (toolCall.status === "running" || toolCall.status === "executing") + ) { + secondRunningMessage = message; + pauseServerMessages = true; + resolveSecondRunning(); + if (releaseSecondRequested) { + forwardSecondRunning?.(); + } + return; + } + + if (toolCall.callId === secondCallId && toolCall.status === "completed") { + // Keep the second real tool call inspectably active. Production providers + // send this same status shape while their work remains in flight. + secondRunningMessage = replaceToolCallStatus(message, "running"); + pauseServerMessages = true; + resolveSecondRunning(); + if (releaseSecondRequested) { + forwardSecondRunning?.(); + } + return; + } + + ws.send(message); + }); + }); + + return { + waitForFirstCompleted: () => firstCompleted, + waitForSecondRunning: () => secondRunning, + releaseSecondRunning() { + releaseSecondRequested = true; + if (secondRunningMessage) { + forwardSecondRunning?.(); + } + }, + }; +} + +async function readShimmerEvidence(locator: Locator): Promise { + return locator.evaluate((root) => { + const shimmer = Array.from(root.querySelectorAll("*")).find((element) => + getComputedStyle(element).animationName.includes("paseo-toolcall-shimmer"), + ); + if (!shimmer) { + throw new Error("Expected a running shimmer inside the badge"); + } + const style = getComputedStyle(shimmer); + return { + animationDuration: style.animationDuration, + endPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-end")), + label: shimmer.textContent ?? "", + renderedWidth: shimmer.getBoundingClientRect().width, + startPx: Number.parseFloat(style.getPropertyValue("--paseo-shimmer-start")), + }; + }); +} + +test("measures an overview heading that becomes loading after its idle mount", async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ toolCallDetailLevel: "overview" }), + ); + }); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "tool-call-shimmer-", + title: "Tool-call shimmer", + model: "ten-second-stream", + }); + + try { + const gate = await gateSecondToolCall(page, agent.agentId); + await openAgentRoute(page, { + workspaceId: agent.workspaceId, + agentId: agent.agentId, + }); + await agent.client.sendAgentMessage(agent.agentId, "Prove the overview shimmer lifecycle."); + + await gate.waitForFirstCompleted(); + const group = page.getByTestId("tool-call-group"); + await expect(group).toBeVisible(); + const idleGroupHandle = await group.elementHandle(); + if (!idleGroupHandle) { + throw new Error("Expected the idle tool-call group to be mounted"); + } + await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).toHaveCount(0); + + await gate.waitForSecondRunning(); + gate.releaseSecondRunning(); + await expect(group.locator('[style*="paseo-toolcall-shimmer"]')).not.toHaveCount(0); + const sameGroupNode = await group.evaluate( + (node, previous) => node === previous, + idleGroupHandle, + ); + + await group.click(); + const runningChild = page.getByTestId("tool-call-badge").last(); + await expect(runningChild).toBeVisible(); + const header = await readShimmerEvidence(group); + const child = await readShimmerEvidence(runningChild); + const evidence = { sameGroupNode, header, child }; + await testInfo.attach("tool-call-shimmer-evidence", { + body: JSON.stringify(evidence, null, 2), + contentType: "application/json", + }); + + expect(sameGroupNode).toBe(true); + expect(child.endPx).toBeGreaterThan(0); + expect( + header.endPx, + `The retained header rendered ${header.renderedWidth}px wide but its shimmer still ends at ${header.endPx}px`, + ).toBeGreaterThan(0); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index b17982a6e97..87fd88d4ac3 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -2657,7 +2657,9 @@ function computeShimmerMetrics(input: { Math.min(120, input.labelRowWidth > 0 ? input.labelRowWidth * 0.28 : 0), ); const isWebShimmer = input.isLoading && isWeb; - const shouldMeasureWebShimmer = isWebShimmer; + // React Native Web only observes a node when onLayout exists at mount. Keep + // measuring while idle so a retained badge has dimensions when it starts loading. + const shouldMeasureWebShimmer = isWeb; const shouldMeasureNativeShimmer = input.isLoading && isNative; const isNativeShimmer = shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0; From 12612f66464f1c39ada3f7bf7763fd9a8765148d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 21:57:08 +0200 Subject: [PATCH 040/420] Make Git slowdowns visible in daemon metrics (#2366) * feat(server): expose daemon Git pressure metrics Separate limiter queue wait from Git execution time and report subscription ownership so accumulating work is visible in the existing runtime log. * fix(server): collect subscriptions in agent metrics Reuse the existing agent snapshot during runtime flushes so WebSocket shutdown does not require an additional AgentManager method call. * test(server): retry transient hub cleanup --- docs/terminal-performance.md | 5 + .../server/src/server/agent/agent-manager.ts | 2 + .../hub/test-utils/relationship-harness.ts | 11 +- packages/server/src/server/session.ts | 7 + .../workspace-git-observer-service.test.ts | 29 ++++ .../workspace-git-observer-service.ts | 15 ++ .../test-utils/workspace-git-service-stub.ts | 14 ++ .../server/src/server/websocket-server.ts | 43 ++++- .../src/server/websocket/runtime-metrics.ts | 2 + .../src/server/workspace-git-service.ts | 63 ++++++++ .../utils/git-command-runtime-metrics.test.ts | 74 +++++++++ .../src/utils/git-command-runtime-metrics.ts | 147 ++++++++++++++++++ packages/server/src/utils/run-git-command.ts | 46 ++++-- 13 files changed, 440 insertions(+), 18 deletions(-) create mode 100644 packages/server/src/utils/git-command-runtime-metrics.test.ts create mode 100644 packages/server/src/utils/git-command-runtime-metrics.ts diff --git a/docs/terminal-performance.md b/docs/terminal-performance.md index 287855aa6bc..15108e951e8 100644 --- a/docs/terminal-performance.md +++ b/docs/terminal-performance.md @@ -32,6 +32,11 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev - **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1` — `packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst. - **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`. +- **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age, + queue wait, execution time, failures, timeouts, and top operations), + `git.workspaceService` (daemon-global Git observer ownership), and per-session workspace Git + subscription totals under `runtime`. Queue wait and execution time are separate because the Git + command timeout begins only after a command acquires a limiter slot. ## Known remaining contention (follow-up candidates) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 3337ceca6c1..8ad7b77a5b2 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -385,6 +385,7 @@ export type ManagedAgent = export interface AgentMetricsSnapshot { total: number; + subscriptionCount: number; byLifecycle: Record; withActiveForegroundTurn: number; timelineStats: { @@ -726,6 +727,7 @@ export class AgentManager { return { total: this.agents.size, + subscriptionCount: this.subscribers.size, byLifecycle, withActiveForegroundTurn, timelineStats: { diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 6603bff8749..819fb8d859e 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -1322,16 +1322,17 @@ export class HubRelationshipHarness { } private async removeRoot(): Promise { - let retryableCode: string | null = null; - if (platform() === "win32") retryableCode = "EBUSY"; - if (platform() === "darwin") retryableCode = "ENOTEMPTY"; - const attempts = retryableCode ? 10 : 1; + const retryableCodes = new Set(["EBUSY", "ENOTEMPTY"]); + const attempts = 10; for (let attempt = 1; attempt <= attempts; attempt++) { try { await rm(this.root, { recursive: true, force: true }); return; } catch (error) { - if ((error as NodeJS.ErrnoException).code !== retryableCode || attempt === attempts) { + if ( + !retryableCodes.has((error as NodeJS.ErrnoException).code ?? "") || + attempt === attempts + ) { throw error; } await new Promise((resolve) => setTimeout(resolve, 100 * attempt)); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1c8de87a009..9f21c4833e2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -313,6 +313,9 @@ export function resolveWaitForFinishError(options: { export interface SessionRuntimeMetrics { terminalDirectorySubscriptionCount: number; terminalSubscriptionCount: number; + workspaceGitWatchedDirectoryCount: number; + workspaceGitWorkspaceRecordCount: number; + workspaceGitSubscriptionCount: number; inflightRequests: number; peakInflightRequests: number; } @@ -1260,9 +1263,13 @@ export class Session { public getRuntimeMetrics(): SessionRuntimeMetrics { const terminalMetrics = this.terminalController.getMetrics(); + const workspaceGitMetrics = this.workspaceGitObserver.getMetrics(); return { terminalDirectorySubscriptionCount: terminalMetrics.directorySubscriptionCount, terminalSubscriptionCount: terminalMetrics.streamSubscriptionCount, + workspaceGitWatchedDirectoryCount: workspaceGitMetrics.watchedDirectoryCount, + workspaceGitWorkspaceRecordCount: workspaceGitMetrics.workspaceRecordCount, + workspaceGitSubscriptionCount: workspaceGitMetrics.subscriptionCount, inflightRequests: this.inflightRequests, peakInflightRequests: this.peakInflightRequests, }; diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts index d82d7121006..1eb08a70227 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts @@ -207,6 +207,35 @@ describe("syncObservers", () => { h.emitSnapshot(WS1, "feature"); expect(h.branchChanges).toEqual([["ws2", null, "feature"]]); }); + + test("reports observer ownership as workspaces are added and removed", () => { + const h = buildHarness(); + h.service.syncObservers([ + makeDescriptor({ id: "ws1", workspaceDirectory: WS1 }), + makeDescriptor({ id: "ws2", workspaceDirectory: WS1 }), + makeDescriptor({ id: "ws3", workspaceDirectory: WS2 }), + ]); + + expect(h.service.getMetrics()).toEqual({ + watchedDirectoryCount: 2, + workspaceRecordCount: 3, + subscriptionCount: 2, + }); + + h.service.removeForWorkspaceId("ws1"); + expect(h.service.getMetrics()).toEqual({ + watchedDirectoryCount: 2, + workspaceRecordCount: 2, + subscriptionCount: 2, + }); + + h.service.dispose(); + expect(h.service.getMetrics()).toEqual({ + watchedDirectoryCount: 0, + workspaceRecordCount: 0, + subscriptionCount: 0, + }); + }); }); describe("git snapshot listener", () => { diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts index a5729f2db3b..dfc87b312b4 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts @@ -19,6 +19,12 @@ interface WorkspaceGitWatchState { lastBranchName: string | null; } +export interface WorkspaceGitObserverMetrics { + watchedDirectoryCount: number; + workspaceRecordCount: number; + subscriptionCount: number; +} + /** * Observes a workspace's git state on disk (via WorkspaceGitService) and drives the * live update fan-out: branch-change notifications, workspace-card refreshes, and @@ -40,6 +46,7 @@ export interface WorkspaceGitObserverService { shouldSkipUpdate(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): boolean; recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void; handleBranchSnapshot(cwd: string, branchName: string | null): void; + getMetrics(): WorkspaceGitObserverMetrics; removeForWorkspaceId(workspaceId: string): void; dispose(): void; } @@ -239,6 +246,14 @@ export function createWorkspaceGitObserverService(deps: { handleBranchSnapshot, + getMetrics() { + return { + watchedDirectoryCount: watchTargets.size, + workspaceRecordCount: workspaceStates.size, + subscriptionCount: subscriptions.size, + }; + }, + removeForWorkspaceId, dispose() { diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index 20f5d2ebb0f..5a28a5f4963 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -71,6 +71,20 @@ export function createNoopWorkspaceGitService( scheduleRefreshForCwd: () => {}, onWorkspaceStateMayHaveChanged: () => {}, invalidateForge: () => {}, + getMetrics: () => ({ + workspaceTargetCount: 0, + workspaceListenerCount: 0, + repositoryTargetCount: 0, + repositoryWorkspaceLinkCount: 0, + workingTreeWatchTargetCount: 0, + workingTreeWatchListenerCount: 0, + workspaceObservationSetupInFlightCount: 0, + workingTreeWatchSetupInFlightCount: 0, + workspaceRefreshInFlightCount: 0, + workspaceRefreshQueuedCount: 0, + fetchInFlightCount: 0, + snapshotUpdatedListenerCount: 0, + }), dispose: () => {}, ...overrides, }; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 35929bb848e..b7bc4b881d3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -37,7 +37,13 @@ import type { HubRelationshipManagement } from "./hub/relationship-controller.js import type { HubExecutionAgents } from "./hub/daemon-executions.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; -import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; +import type { + WorkspaceGitRuntimeSnapshot, + WorkspaceGitService, + WorkspaceGitServiceMetrics, +} from "./workspace-git-service.js"; +import type { GitCommandRuntimeMetricsSnapshot } from "../utils/git-command-runtime-metrics.js"; +import { snapshotGitCommandRuntimeMetrics } from "../utils/run-git-command.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { PushTokenStore } from "./push/token-store.js"; @@ -119,9 +125,14 @@ interface WebSocketServerConfig { } type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; +interface GitRuntimeMetrics { + commands: GitCommandRuntimeMetricsSnapshot; + workspaceService: WorkspaceGitServiceMetrics; +} type WebSocketRuntimeDiagnosticPayload = WebSocketRuntimeDiagnosticSnapshot< WebSocketRuntimeMetrics, - AgentMetricsSnapshot + AgentMetricsSnapshot, + GitRuntimeMetrics >; type WebSocketRuntimeMetricsLogPayload = Omit; @@ -211,6 +222,20 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { scheduleRefreshForCwd: () => {}, onWorkspaceStateMayHaveChanged: () => {}, invalidateForge: () => {}, + getMetrics: () => ({ + workspaceTargetCount: 0, + workspaceListenerCount: 0, + repositoryTargetCount: 0, + repositoryWorkspaceLinkCount: 0, + workingTreeWatchTargetCount: 0, + workingTreeWatchListenerCount: 0, + workspaceObservationSetupInFlightCount: 0, + workingTreeWatchSetupInFlightCount: 0, + workspaceRefreshInFlightCount: 0, + workspaceRefreshQueuedCount: 0, + fetchInFlightCount: 0, + snapshotUpdatedListenerCount: 0, + }), dispose: () => {}, }; } @@ -2044,6 +2069,9 @@ export class VoiceAssistantWebSocketServer { ); let terminalDirectorySubscriptionCount = 0; let terminalSubscriptionCount = 0; + let workspaceGitWatchedDirectoryCount = 0; + let workspaceGitWorkspaceRecordCount = 0; + let workspaceGitSubscriptionCount = 0; let inflightRequests = 0; let peakInflightRequests = 0; @@ -2051,6 +2079,9 @@ export class VoiceAssistantWebSocketServer { const sessionMetrics = connection.session.getRuntimeMetrics(); terminalDirectorySubscriptionCount += sessionMetrics.terminalDirectorySubscriptionCount; terminalSubscriptionCount += sessionMetrics.terminalSubscriptionCount; + workspaceGitWatchedDirectoryCount += sessionMetrics.workspaceGitWatchedDirectoryCount; + workspaceGitWorkspaceRecordCount += sessionMetrics.workspaceGitWorkspaceRecordCount; + workspaceGitSubscriptionCount += sessionMetrics.workspaceGitSubscriptionCount; inflightRequests += sessionMetrics.inflightRequests; peakInflightRequests = Math.max(peakInflightRequests, sessionMetrics.peakInflightRequests); connection.session.resetPeakInflight(); @@ -2060,6 +2091,9 @@ export class VoiceAssistantWebSocketServer { ...this.checkoutDiffManager.getMetrics(), terminalDirectorySubscriptionCount, terminalSubscriptionCount, + workspaceGitWatchedDirectoryCount, + workspaceGitWorkspaceRecordCount, + workspaceGitSubscriptionCount, inflightRequests, peakInflightRequests, }; @@ -2076,6 +2110,7 @@ export class VoiceAssistantWebSocketServer { ).length; const sessionMetrics = this.collectSessionRuntimeMetrics(); const agentSnapshot = this.agentManager.getMetricsSnapshot(); + const gitCommandMetrics = snapshotGitCommandRuntimeMetrics(); const loggedMetrics = { windowMs: runtimeMetrics.windowMs, final: Boolean(options?.final), @@ -2103,6 +2138,10 @@ export class VoiceAssistantWebSocketServer { runtime: sessionMetrics, latency: runtimeMetrics.latency, agents: agentSnapshot, + git: { + commands: gitCommandMetrics, + workspaceService: this.workspaceGitService.getMetrics(), + }, } satisfies WebSocketRuntimeMetricsLogPayload; this.lastRuntimeMetricsSnapshot = { diff --git a/packages/server/src/server/websocket/runtime-metrics.ts b/packages/server/src/server/websocket/runtime-metrics.ts index fbb31210f4f..8e7c5c6ffd3 100644 --- a/packages/server/src/server/websocket/runtime-metrics.ts +++ b/packages/server/src/server/websocket/runtime-metrics.ts @@ -46,6 +46,7 @@ export interface WebSocketRuntimeMetricsSnapshot { export interface WebSocketRuntimeDiagnosticSnapshot< TRuntime = unknown, TAgents = unknown, + TGit = unknown, > extends WebSocketRuntimeMetricsSnapshot { collectedAt: string; final: boolean; @@ -67,6 +68,7 @@ export interface WebSocketRuntimeDiagnosticSnapshot< memory: ProcessMemoryDiagnostics; runtime: TRuntime; agents: TAgents; + git: TGit; } type Clock = () => number; diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 71209da3478..7a2a108bb6d 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -173,9 +173,25 @@ export interface WorkspaceGitService { scheduleRefreshForCwd(cwd: string): void; onWorkspaceStateMayHaveChanged(cwd: string): void; invalidateForge(cwd: string): void; + getMetrics(): WorkspaceGitServiceMetrics; dispose(): void; } +export interface WorkspaceGitServiceMetrics { + workspaceTargetCount: number; + workspaceListenerCount: number; + repositoryTargetCount: number; + repositoryWorkspaceLinkCount: number; + workingTreeWatchTargetCount: number; + workingTreeWatchListenerCount: number; + workspaceObservationSetupInFlightCount: number; + workingTreeWatchSetupInFlightCount: number; + workspaceRefreshInFlightCount: number; + workspaceRefreshQueuedCount: number; + fetchInFlightCount: number; + snapshotUpdatedListenerCount: number; +} + export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; export type WorkspaceGitSnapshotUpdatedListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void; @@ -454,6 +470,53 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; } + getMetrics(): WorkspaceGitServiceMetrics { + let workspaceListenerCount = 0; + let repositoryWorkspaceLinkCount = 0; + let workingTreeWatchListenerCount = 0; + let workspaceRefreshInFlightCount = 0; + let workspaceRefreshQueuedCount = 0; + let workspaceObservationSetupInFlightCount = 0; + let fetchInFlightCount = 0; + + for (const target of this.workspaceTargets.values()) { + workspaceListenerCount += target.listeners.size; + if (target.observationSetupPromise) { + workspaceObservationSetupInFlightCount += 1; + } + if (target.refreshState.status === "in-flight") { + workspaceRefreshInFlightCount += 1; + if (target.refreshState.queued) { + workspaceRefreshQueuedCount += 1; + } + } + } + for (const target of this.repoTargets.values()) { + repositoryWorkspaceLinkCount += target.workspaceKeys.size; + if (target.fetchInFlight) { + fetchInFlightCount += 1; + } + } + for (const target of this.workingTreeWatchTargets.values()) { + workingTreeWatchListenerCount += target.listeners.size; + } + + return { + workspaceTargetCount: this.workspaceTargets.size, + workspaceListenerCount, + repositoryTargetCount: this.repoTargets.size, + repositoryWorkspaceLinkCount, + workingTreeWatchTargetCount: this.workingTreeWatchTargets.size, + workingTreeWatchListenerCount, + workspaceObservationSetupInFlightCount, + workingTreeWatchSetupInFlightCount: this.workingTreeWatchSetups.size, + workspaceRefreshInFlightCount, + workspaceRefreshQueuedCount, + fetchInFlightCount, + snapshotUpdatedListenerCount: this.snapshotUpdatedListeners.size, + }; + } + async getSnapshot( cwd: string, options?: WorkspaceGitSnapshotOptions, diff --git a/packages/server/src/utils/git-command-runtime-metrics.test.ts b/packages/server/src/utils/git-command-runtime-metrics.test.ts new file mode 100644 index 00000000000..e5bcd75d296 --- /dev/null +++ b/packages/server/src/utils/git-command-runtime-metrics.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; +import { GitCommandRuntimeMetricsWindow } from "./git-command-runtime-metrics.js"; + +function createMetricsWindow(concurrencyLimit = 2) { + let now = 1_000; + return { + metrics: new GitCommandRuntimeMetricsWindow(concurrencyLimit, () => now), + advance(ms: number) { + now += ms; + }, + }; +} + +describe("GitCommandRuntimeMetricsWindow", () => { + test("separates queue wait from execution time", () => { + const { metrics, advance } = createMetricsWindow(); + const command = metrics.submit("status"); + advance(40); + metrics.start(command); + advance(15); + metrics.finish(command, { success: true, timedOut: false }); + + expect(metrics.snapshotAndReset()).toMatchObject({ + submitted: 1, + started: 1, + completed: 1, + failed: 0, + timedOut: 0, + queueWaitMs: { count: 1, p50Ms: 40, p95Ms: 40, maxMs: 40 }, + executionMs: { count: 1, p50Ms: 15, p95Ms: 15, maxMs: 15 }, + operationsTop: [["status", 1]], + }); + }); + + test("reports live queue pressure across window resets", () => { + const { metrics, advance } = createMetricsWindow(1); + const active = metrics.submit("fetch"); + metrics.start(active); + const pending = metrics.submit("rev-parse"); + metrics.observeLimiter(1, 1); + advance(25); + + expect(metrics.snapshotAndReset()).toMatchObject({ + concurrencyLimit: 1, + active: 1, + pending: 1, + peakActive: 1, + peakPending: 1, + oldestPendingMs: 25, + submitted: 2, + started: 1, + }); + + advance(10); + metrics.finish(active, { success: true, timedOut: false }); + metrics.start(pending); + metrics.observeLimiter(1, 0); + advance(5); + metrics.finish(pending, { success: false, timedOut: true }); + + expect(metrics.snapshotAndReset()).toMatchObject({ + active: 0, + pending: 0, + peakActive: 1, + peakPending: 1, + submitted: 0, + started: 1, + completed: 2, + failed: 1, + timedOut: 1, + queueWaitMs: { count: 1, p50Ms: 35, p95Ms: 35, maxMs: 35 }, + }); + }); +}); diff --git a/packages/server/src/utils/git-command-runtime-metrics.ts b/packages/server/src/utils/git-command-runtime-metrics.ts new file mode 100644 index 00000000000..69ff54e35c5 --- /dev/null +++ b/packages/server/src/utils/git-command-runtime-metrics.ts @@ -0,0 +1,147 @@ +export interface GitCommandDurationStats { + count: number; + p50Ms: number; + p95Ms: number; + maxMs: number; +} + +export interface GitCommandRuntimeMetricsSnapshot { + concurrencyLimit: number; + active: number; + pending: number; + peakActive: number; + peakPending: number; + oldestPendingMs: number; + submitted: number; + started: number; + completed: number; + failed: number; + timedOut: number; + queueWaitMs: GitCommandDurationStats; + executionMs: GitCommandDurationStats; + operationsTop: Array<[string, number]>; +} + +interface GitCommandRuntimeMetric { + queuedAtMs: number; + startedAtMs: number | null; +} + +type Clock = () => number; + +export class GitCommandRuntimeMetricsWindow { + private readonly pendingCommands = new Set(); + private active = 0; + private peakActive = 0; + private peakPending = 0; + private submittedCount = 0; + private startedCount = 0; + private completedCount = 0; + private failedCount = 0; + private timedOutCount = 0; + private readonly queueWaitSamples: number[] = []; + private readonly executionSamples: number[] = []; + private readonly operationCounts = new Map(); + + constructor( + private readonly concurrencyLimit: number, + private readonly clock: Clock = Date.now, + ) {} + + submit(operation: string): GitCommandRuntimeMetric { + const metric = { queuedAtMs: this.clock(), startedAtMs: null }; + this.pendingCommands.add(metric); + this.submittedCount += 1; + this.operationCounts.set(operation, (this.operationCounts.get(operation) ?? 0) + 1); + return metric; + } + + observeLimiter(active: number, pending: number): void { + this.peakActive = Math.max(this.peakActive, active); + this.peakPending = Math.max(this.peakPending, pending); + } + + start(metric: GitCommandRuntimeMetric): void { + if (!this.pendingCommands.delete(metric)) { + return; + } + const now = this.clock(); + metric.startedAtMs = now; + this.active += 1; + this.startedCount += 1; + this.peakActive = Math.max(this.peakActive, this.active); + this.queueWaitSamples.push(Math.max(0, now - metric.queuedAtMs)); + } + + finish(metric: GitCommandRuntimeMetric, outcome: { success: boolean; timedOut: boolean }): void { + if (metric.startedAtMs === null) { + return; + } + const startedAtMs = metric.startedAtMs; + metric.startedAtMs = null; + this.active = Math.max(0, this.active - 1); + this.completedCount += 1; + if (!outcome.success) { + this.failedCount += 1; + } + if (outcome.timedOut) { + this.timedOutCount += 1; + } + this.executionSamples.push(Math.max(0, this.clock() - startedAtMs)); + } + + snapshotAndReset( + limiter = { active: this.active, pending: this.pendingCommands.size }, + ): GitCommandRuntimeMetricsSnapshot { + const now = this.clock(); + const oldestPendingAtMs = Math.min( + ...Array.from(this.pendingCommands, (metric) => metric.queuedAtMs), + ); + const snapshot: GitCommandRuntimeMetricsSnapshot = { + concurrencyLimit: this.concurrencyLimit, + active: limiter.active, + pending: limiter.pending, + peakActive: this.peakActive, + peakPending: this.peakPending, + oldestPendingMs: + limiter.pending > 0 && Number.isFinite(oldestPendingAtMs) + ? Math.max(0, now - oldestPendingAtMs) + : 0, + submitted: this.submittedCount, + started: this.startedCount, + completed: this.completedCount, + failed: this.failedCount, + timedOut: this.timedOutCount, + queueWaitMs: summarizeDurations(this.queueWaitSamples), + executionMs: summarizeDurations(this.executionSamples), + operationsTop: [...this.operationCounts.entries()] + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, 12), + }; + + this.peakActive = limiter.active; + this.peakPending = limiter.pending; + this.submittedCount = 0; + this.startedCount = 0; + this.completedCount = 0; + this.failedCount = 0; + this.timedOutCount = 0; + this.queueWaitSamples.length = 0; + this.executionSamples.length = 0; + this.operationCounts.clear(); + return snapshot; + } +} + +function summarizeDurations(samples: number[]): GitCommandDurationStats { + if (samples.length === 0) { + return { count: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 }; + } + const sorted = [...samples].sort((left, right) => left - right); + return { + count: sorted.length, + p50Ms: Math.round(sorted[Math.floor(sorted.length / 2)] ?? 0), + p95Ms: Math.round(sorted[Math.ceil(sorted.length * 0.95) - 1] ?? 0), + maxMs: Math.round(sorted[sorted.length - 1] ?? 0), + }; +} diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index d2b1fb9f55d..254c980462e 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -2,6 +2,10 @@ import { existsSync } from "node:fs"; import pLimit from "p-limit"; import type { Logger } from "pino"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; +import { + GitCommandRuntimeMetricsWindow, + type GitCommandRuntimeMetricsSnapshot, +} from "./git-command-runtime-metrics.js"; import { spawnProcess } from "./spawn.js"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -10,6 +14,7 @@ const DEFAULT_STDERR_LIMIT = 2048; const gitConcurrency = parseInt(process.env.PASEO_GIT_CONCURRENCY ?? "8", 10) || 8; const gitLimit = pLimit(gitConcurrency); +const gitRuntimeMetrics = new GitCommandRuntimeMetricsWindow(gitConcurrency); export interface GitCommandOptions { cwd: string; @@ -81,6 +86,13 @@ export function stopGitCommandMetrics(): GitCommandMetricsSnapshot { }; } +export function snapshotGitCommandRuntimeMetrics(): GitCommandRuntimeMetricsSnapshot { + return gitRuntimeMetrics.snapshotAndReset({ + active: gitLimit.activeCount, + pending: gitLimit.pendingCount, + }); +} + function beginGitCommandMetric(): GitCommandMetricsState | null { const state = gitCommandMetricsState; if (!state) { @@ -123,9 +135,11 @@ export function runGitCommand( args: string[], options: GitCommandOptions, ): Promise { - return gitLimit( + const runtimeMetric = gitRuntimeMetrics.submit(getGitOperation(args)); + const promise = gitLimit( () => new Promise((resolve, reject) => { + gitRuntimeMetrics.start(runtimeMetric); const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS; const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const acceptExitCodes = options.acceptExitCodes ?? [0]; @@ -175,24 +189,28 @@ export function runGitCommand( callback(); }; - const finishMetricOnce = (metric: GitCommandMetric) => { + const finishMetricOnce = (metric: GitCommandMetric, timedOut = false) => { if (metricFinished) return; metricFinished = true; finishGitCommandMetric(metricsState, metric); + gitRuntimeMetrics.finish(runtimeMetric, { success: metric.success, timedOut }); }; const timer = setTimeout(() => { const error = new Error(`Git command timed out after ${timeout}ms: ${command}`); child.kill("SIGKILL"); - finishMetricOnce({ - args, - cwd: options.cwd, - startedAtMs: startedAt, - durationMs: Date.now() - startedAt, - exitCode: null, - signal: "SIGKILL", - success: false, - }); + finishMetricOnce( + { + args, + cwd: options.cwd, + startedAtMs: startedAt, + durationMs: Date.now() - startedAt, + exitCode: null, + signal: "SIGKILL", + success: false, + }, + true, + ); settle(() => reject(error)); }, timeout); @@ -318,8 +336,14 @@ export function runGitCommand( }); }), ); + gitRuntimeMetrics.observeLimiter(gitLimit.activeCount, gitLimit.pendingCount); + return promise; } function formatGitCommand(args: string[]): string { return ["git", ...args].join(" "); } + +function getGitOperation(args: string[]): string { + return args[0] === "-c" ? (args[2] ?? "unknown") : (args[0] ?? "unknown"); +} From 1c95f8c37e014e01ac91b7b9c93999b842f24c6a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 22:36:42 +0200 Subject: [PATCH 041/420] fix(server): stop workspace updates triggering full scans (#2379) Workspace update fanout was constructing one-off reconciliation services, so bursts could launch overlapping all-workspace scans. Keep reconciliation in the daemon service and preserve runtime cleanup for missing workspaces. --- packages/server/src/server/bootstrap.ts | 17 +- packages/server/src/server/session.ts | 119 ++----------- .../src/server/session.workspaces.test.ts | 162 +++++------------- .../workspace-reconciliation-service.test.ts | 18 +- .../workspace-reconciliation-service.ts | 4 + 5 files changed, 86 insertions(+), 234 deletions(-) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index b22cb0e1a50..725a6773369 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -92,10 +92,7 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null { export async function fanOutReconciledWorkspaceUpdates(input: { sessions: Iterable<{ syncWorkspaceGitObserversForExternalWorkspaceIds(workspaceIds: Iterable): Promise; - emitWorkspaceUpdatesForExternalWorkspaceIds( - workspaceIds: Iterable, - options: { skipReconcile: boolean }, - ): Promise; + emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable): Promise; }>; workspaceIds: readonly string[]; logger: Pick; @@ -111,9 +108,7 @@ export async function fanOutReconciledWorkspaceUpdates(input: { ); } try { - await session.emitWorkspaceUpdatesForExternalWorkspaceIds(input.workspaceIds, { - skipReconcile: true, - }); + await session.emitWorkspaceUpdatesForExternalWorkspaceIds(input.workspaceIds); } catch (error) { input.logger.warn({ err: error }, "Failed to emit workspace updates after reconciliation"); } @@ -842,12 +837,17 @@ export async function createPaseoDaemon( logger, }); logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped"); + const teardownArchivedWorkspaceRuntime = (workspaceId: string): void => { + scriptRuntimeStore.removeForWorkspace(workspaceId); + releaseWorkspaceServicePortPlan(workspaceId); + }; const workspaceReconciliation = new WorkspaceReconciliationService({ projectRegistry, workspaceRegistry, logger, workspaceGitService, onProjectUpdate: (update) => wsServer?.publishProjectUpdate(update), + onWorkspaceArchived: teardownArchivedWorkspaceRuntime, onWorkspacesChanged: async (workspaceIds) => { await fanOutReconciledWorkspaceUpdates({ sessions: wsServer?.listTrustedSessions() ?? [], @@ -873,8 +873,7 @@ export async function createPaseoDaemon( workspaceRegistry, }); if (!existingWorkspace || existingWorkspace.archivedAt) return; - scriptRuntimeStore.removeForWorkspace(workspaceId); - releaseWorkspaceServicePortPlan(workspaceId); + teardownArchivedWorkspaceRuntime(workspaceId); }; // external path→workspace adapter, not ownership: archive-by-path requests that // arrive with a worktree path and no workspaceId (old clients / CLI). diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9f21c4833e2..c060450c98c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -162,7 +162,6 @@ import { archiveWorkspaceContents, requireActiveWorkspaceForArchive, } from "./workspace-archive-service.js"; -import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import type { ServiceProxySubsystem } from "./service-proxy.js"; import { renameCurrentBranch as renameCurrentBranchDefault } from "../utils/checkout-git.js"; import { @@ -206,7 +205,6 @@ import type { ForgeService } from "../services/forge-service.js"; import type { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { summarizeFetchWorkspacesEntries, - workspaceIdsForProjects, workspaceIdsOnCheckout, WorkspaceDirectory, type WorkspaceUpdatesFilter, @@ -1135,7 +1133,7 @@ export class Session { } async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise { - await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true }); + await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]); } private async emitCreatedWorkspaceUpdate( @@ -1143,10 +1141,10 @@ export class Session { optimisticStatus?: WorkspaceDescriptorPayload["status"], ): Promise { if (this.workspaceUpdatesSubscription) { - await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.id], { - skipReconcile: true, - ...(optimisticStatus ? { optimisticStatus } : {}), - }); + await this.emitWorkspaceUpdatesForWorkspaceIds( + [workspace.id], + optimisticStatus ? { optimisticStatus } : undefined, + ); return; } // COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12. @@ -1171,11 +1169,8 @@ export class Session { this.clearWorkspaceArchiving(workspaceIds); } - async emitWorkspaceUpdatesForExternalWorkspaceIds( - workspaceIds: Iterable, - options?: { skipReconcile?: boolean }, - ): Promise { - await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, options); + async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable): Promise { + await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds); } async syncWorkspaceGitObserversForExternalWorkspaceIds( @@ -1407,10 +1402,10 @@ export class Session { if (this.isCleanedUp) { return; } - await this.emitWorkspaceUpdatesForWorkspaceIds([mutation.workspaceId], { - skipReconcile: true, - ...(mutation.expectsInitialAgent ? { optimisticStatus: "running" } : {}), - }); + await this.emitWorkspaceUpdatesForWorkspaceIds( + [mutation.workspaceId], + mutation.expectsInitialAgent ? { optimisticStatus: "running" } : undefined, + ); } catch (error) { this.sessionLogger.warn( { err: error, workspaceId: mutation.workspaceId, mutationKind: mutation.kind }, @@ -1480,7 +1475,6 @@ export class Session { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); } await this.emitWorkspaceUpdatesForWorkspaceIds(updateIds, { - skipReconcile: true, removedProjectId: mutation.projectId, }); return; @@ -1491,9 +1485,7 @@ export class Session { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); } } - await this.emitWorkspaceUpdatesForWorkspaceIds(projectWorkspaceIds, { - skipReconcile: true, - }); + await this.emitWorkspaceUpdatesForWorkspaceIds(projectWorkspaceIds); } catch (error) { this.sessionLogger.warn( { err: error, projectId: mutation.projectId, mutationKind: mutation.kind }, @@ -2412,9 +2404,7 @@ export class Session { } } - await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds, { - skipReconcile: true, - }); + await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds); this.emit({ type: "agent.detach.response", @@ -2619,9 +2609,7 @@ export class Session { .filter((workspace) => workspace.projectId === projectId) .map((workspace) => workspace.workspaceId); if (affectedWorkspaceIds.length > 0) { - await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds, { - skipReconcile: true, - }); + await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds); } } catch (error) { this.sessionLogger.error( @@ -2666,9 +2654,7 @@ export class Session { if (activeWorkspaceIds.length > 0) { this.markWorkspaceArchiving(activeWorkspaceIds, new Date().toISOString()); - await this.emitWorkspaceUpdatesForWorkspaceIds(activeWorkspaceIds, { - skipReconcile: true, - }); + await this.emitWorkspaceUpdatesForWorkspaceIds(activeWorkspaceIds); } const removedWorkspaceIds: string[] = []; @@ -2700,7 +2686,6 @@ export class Session { ? removedWorkspaceIds : [projectWorkspaces[0]?.workspaceId ?? projectId]; await this.emitWorkspaceUpdatesForWorkspaceIds(updateIds, { - skipReconcile: true, removedProjectId: projectId, }); @@ -2785,9 +2770,7 @@ export class Session { }, }); - await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { - skipReconcile: true, - }); + await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]); } catch (error) { this.sessionLogger.error( { err: error, workspaceId, requestId }, @@ -2842,7 +2825,7 @@ export class Session { return; } emitResponse(true, nextPinnedAt, null); - await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true }); + await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]); } catch (error) { this.sessionLogger.error( { ...logContext, err: error }, @@ -4573,67 +4556,9 @@ export class Session { releaseWorkspaceServicePortPlan(workspaceId); } - private async reconcileAndEmitWorkspaceUpdates(): Promise { - if (!this.workspaceUpdatesSubscription) { - return; - } - try { - const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); - if (changedWorkspaceIds.size === 0) { - return; - } - await this.emitWorkspaceUpdatesForWorkspaceIds(changedWorkspaceIds, { - skipReconcile: true, - }); - } catch (error) { - this.sessionLogger.error({ err: error }, "Background workspace reconciliation failed"); - } - } - - private async reconcileActiveWorkspaceRecords(): Promise> { - const service = new WorkspaceReconciliationService({ - projectRegistry: this.projectRegistry, - workspaceRegistry: this.workspaceRegistry, - logger: this.sessionLogger, - workspaceGitService: this.workspaceGitService, - }); - const result = await service.runOnce(); - const changedWorkspaceIds = new Set(); - const changedProjectIds = new Set(); - - await Promise.all( - result.changesApplied.map(async (change) => { - switch (change.kind) { - case "workspace_archived": - await this.teardownArchivedWorkspace(change.workspaceId); - changedWorkspaceIds.add(change.workspaceId); - break; - case "workspace_updated": - changedWorkspaceIds.add(change.workspaceId); - break; - case "project_updated": - changedProjectIds.add(change.projectId); - break; - } - }), - ); - - if (changedProjectIds.size > 0) { - for (const workspaceId of workspaceIdsForProjects( - await this.workspaceRegistry.list(), - changedProjectIds, - )) { - changedWorkspaceIds.add(workspaceId); - } - } - - return changedWorkspaceIds; - } - private async emitWorkspaceUpdatesForWorkspaceIds( workspaceIds: Iterable, options?: { - skipReconcile?: boolean; dedupeGitState?: boolean; removedProjectId?: string; optimisticStatus?: WorkspaceDescriptorPayload["status"]; @@ -4700,10 +4625,6 @@ export class Session { this.bufferOrEmitWorkspaceUpdate(subscription, nextPayload); } - - if (!options?.skipReconcile) { - void this.reconcileAndEmitWorkspaceUpdates(); - } } private applyOptimisticWorkspaceStatus( @@ -4764,9 +4685,7 @@ export class Session { if (!event.workspaceId) { return; } - await this.emitWorkspaceUpdatesForWorkspaceIds([event.workspaceId], { - skipReconcile: true, - }); + await this.emitWorkspaceUpdatesForWorkspaceIds([event.workspaceId]); } // A git fact (branch, diff, dirty, PR) changed at `cwd`. Every workspace whose @@ -4777,7 +4696,6 @@ export class Session { private async emitWorkspaceUpdateForCwd( cwd: string, options?: { - skipReconcile?: boolean; dedupeGitState?: boolean; }, ): Promise { @@ -4971,7 +4889,6 @@ export class Session { if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { this.flushBootstrappedWorkspaceUpdates(snapshot); - void this.reconcileAndEmitWorkspaceUpdates(); } } catch (error) { if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index c7c0ed247c0..8a87e5064da 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -10,6 +10,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { setImmediate as waitForImmediate } from "node:timers/promises"; import { afterEach, expect, test, vi } from "vitest"; import { z } from "zod"; @@ -78,6 +79,10 @@ const UNREGISTERED_CWD = path.resolve("/tmp/unregistered"); const terminalManagers: TerminalManager[] = []; +async function flushWorkspaceUpdateBackgroundWork(): Promise { + await waitForImmediate(); +} + afterEach(async () => { while (terminalManagers.length > 0) { const manager = terminalManagers.pop(); @@ -130,14 +135,12 @@ interface SessionTestAccess { agentUpdates: AgentUpdatesService; workspaceUpdatesSubscription: unknown; interruptAgentIfRunning(agentId: string): unknown; - reconcileActiveWorkspaceRecords(...args: unknown[]): Promise>; reconcileWorkspaceRecord(workspaceId: string): Promise<{ changed: boolean; workspace?: Record | null; removedWorkspaceId?: string | null; [key: string]: unknown; }>; - reconcileAndEmitWorkspaceUpdates(...args: unknown[]): Promise; handleArchiveAgentRequest(agentId: string, requestId: string): Promise; handleMessage(message: unknown): Promise; handleCreatePaseoWorktreeRequest(params: unknown): Promise; @@ -155,10 +158,7 @@ interface SessionTestAccess { clearWorkspaceArchiving(workspaceIds: Iterable): void; emitWorkspaceUpdateForCwd(...args: unknown[]): Promise; emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; - emitWorkspaceUpdatesForExternalWorkspaceIds( - workspaceIds: Iterable, - options?: { skipReconcile?: boolean }, - ): Promise; + emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable): Promise; updateClientCapabilities(capabilities: Record | null): void; emit(message: unknown): void; onMessage(message: unknown): void; @@ -1234,59 +1234,6 @@ test("unsupported persisted agents are excluded from active lists but preserved ); }); -test("workspace reconciliation reports archived workspaces to subscribed clients", async () => { - const missingCwd = path.join(tmpdir(), `paseo-missing-workspace-${Date.now()}`); - rmSync(missingCwd, { recursive: true, force: true }); - const projects = new Map([ - [ - "proj-missing", - createPersistedProjectRecord({ - projectId: "proj-missing", - rootPath: missingCwd, - kind: "non_git", - displayName: "missing", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ], - ]); - const workspaces = new Map([ - [ - "ws-missing", - createPersistedWorkspaceRecord({ - workspaceId: "ws-missing", - projectId: "proj-missing", - cwd: missingCwd, - kind: "directory", - displayName: "missing", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ], - ]); - const session = createSessionForWorkspaceTests(); - session.projectRegistry.list = async () => Array.from(projects.values()); - session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { - const project = projects.get(projectId); - if (project) { - projects.set(projectId, { ...project, archivedAt }); - } - }; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { - const workspace = workspaces.get(workspaceId); - if (workspace) { - workspaces.set(workspaceId, { ...workspace, archivedAt }); - } - }; - - const changedWorkspaceIds = await session.reconcileActiveWorkspaceRecords(); - - expect(changedWorkspaceIds).toEqual(new Set(["ws-missing"])); - expect(workspaces.get("ws-missing")?.archivedAt).toBeTruthy(); - expect(projects.get("proj-missing")?.archivedAt).toBeFalsy(); -}); - test("agent_update placement does not refresh git snapshots", async () => { const emitted: SessionOutboundMessage[] = []; const getSnapshot = vi.fn(async () => { @@ -3316,8 +3263,6 @@ test("workspace update stream keeps persisted workspace visible after agents sto pendingUpdatesByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(), }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); - session.buildWorkspaceDescriptorMap = async () => new Map([ [ @@ -3432,13 +3377,10 @@ test("archiving the last workspace emits a remove carrying the now-empty project ], ]), }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); // The archived workspace no longer resolves to an active descriptor. session.buildWorkspaceDescriptorMap = async () => new Map(); - await session.emitWorkspaceUpdatesForWorkspaceIds([archivedWorkspace.workspaceId], { - skipReconcile: true, - }); + await session.emitWorkspaceUpdatesForWorkspaceIds([archivedWorkspace.workspaceId]); const removeUpdate = filterByType(emitted, "workspace_update").find( (message) => message.payload.kind === "remove", @@ -3503,7 +3445,6 @@ test("project.remove.request archives active workspaces and removes the project pendingUpdatesByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(), }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); session.listAgentPayloads = async () => []; session.buildWorkspaceDescriptorMap = async (options: { workspaceIds?: Iterable }) => { const workspaceIds = Array.from(options.workspaceIds ?? workspaces.keys()); @@ -3603,7 +3544,6 @@ test("project.remove.request removes an already-empty project", async () => { [archivedWorkspace.workspaceId, { kind: "remove", id: archivedWorkspace.workspaceId }], ]), }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); session.listAgentPayloads = async () => []; session.buildWorkspaceDescriptorMap = async () => new Map(); @@ -3773,14 +3713,19 @@ test("create paseo worktree response preserves an explicit non-Git project", asy expect(projects.get(explicitProject.projectId)).toEqual(explicitProject); }); -test("workspace update fanout for multiple cwd values is deduplicated", async () => { +test("workspace updates stay scoped to the matching cwd", async () => { const emitted: SessionOutboundMessage[] = []; + const archivedWorkspaceIds: string[] = []; + const missingRoot = path.join(tmpdir(), `paseo-scoped-workspace-${Date.now()}`); + rmSync(missingRoot, { recursive: true, force: true }); + const mainCwd = path.join(missingRoot, "main"); + const featureCwd = path.join(missingRoot, "feature"); const session = createSessionForWorkspaceTests(); session.workspaceRegistry.list = async () => [ createPersistedWorkspaceRecord({ workspaceId: "ws-repo-main", projectId: "proj-repo-main", - cwd: REPO_CWD, + cwd: mainCwd, kind: "local_checkout", displayName: "main", createdAt: "2026-03-01T12:00:00.000Z", @@ -3789,13 +3734,16 @@ test("workspace update fanout for multiple cwd values is deduplicated", async () createPersistedWorkspaceRecord({ workspaceId: "ws-repo-feature", projectId: "proj-repo-main", - cwd: "/tmp/repo/worktree", + cwd: featureCwd, kind: "worktree", displayName: "feature", createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }), ]; + session.workspaceRegistry.archive = async (workspaceId) => { + archivedWorkspaceIds.push(workspaceId); + }; session.workspaceUpdatesSubscription = { subscriptionId: "sub-dedup", filter: undefined, @@ -3803,31 +3751,15 @@ test("workspace update fanout for multiple cwd values is deduplicated", async () pendingUpdatesByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(), }; - session.reconcileActiveWorkspaceRecords = async () => - new Set(["ws-repo-main", "ws-repo-feature"]); session.buildWorkspaceDescriptorMap = async () => new Map([ - [ - "ws-repo-main", - { - id: "ws-repo-main", - projectId: "proj-repo-main", - projectDisplayName: "repo", - projectRootPath: REPO_CWD, - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "done", - activityAt: null, - }, - ], [ "ws-repo-feature", { id: "ws-repo-feature", projectId: "proj-repo-main", projectDisplayName: "repo", - projectRootPath: REPO_CWD, + projectRootPath: mainCwd, projectKind: "git", workspaceKind: "worktree", name: "feature", @@ -3840,17 +3772,20 @@ test("workspace update fanout for multiple cwd values is deduplicated", async () if (isSessionOutboundMessage(message)) emitted.push(message); }; - await session.emitWorkspaceUpdateForCwd("/tmp/repo/worktree"); - await new Promise((resolve) => setTimeout(resolve, 0)); + await session.emitWorkspaceUpdateForCwd(featureCwd); + await flushWorkspaceUpdateBackgroundWork(); const workspaceUpdates = filterByType(emitted, "workspace_update"); - expect(workspaceUpdates).toHaveLength(2); - expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]); - expect( - workspaceUpdates - .map((entry) => (entry.payload.kind === "upsert" ? entry.payload.workspace.id : null)) - .sort((a, b) => String(a).localeCompare(String(b))), - ).toEqual(["ws-repo-feature", "ws-repo-main"]); + expect(workspaceUpdates).toEqual([ + { + type: "workspace_update", + payload: { + kind: "upsert", + workspace: expect.objectContaining({ id: "ws-repo-feature" }), + }, + }, + ]); + expect(archivedWorkspaceIds).toEqual([]); }); test("open_project_request registers a workspace before any agent exists", async () => { @@ -4243,8 +4178,6 @@ test("open_project_request emits a workspace_update with githubRuntime once the pendingUpdatesByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(), }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); - await session.handleMessage({ type: "open_project_request", cwd, @@ -4892,7 +4825,6 @@ test("workspace recovery stays accepted when git observer warming fails", async pendingUpdatesByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(), }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); session.listAgentPayloads = async () => []; await session.handleMessage({ @@ -6458,19 +6390,14 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u pendingUpdatesByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(), }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; session.markWorkspaceArchiving([workspace.workspaceId], archivingAt); - await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId], { - skipReconcile: true, - }); - await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId], { - skipReconcile: true, - }); + await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); + await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); expect(emitted).toEqual([ { @@ -6486,7 +6413,7 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u ]); }); -test("external workspace updates emit one deduplicated batch without reconciling", async () => { +test("external workspace updates emit one deduplicated batch", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); const project = createPersistedProjectRecord({ @@ -6536,10 +6463,11 @@ test("external workspace updates emit one deduplicated batch without reconciling if (isSessionOutboundMessage(message)) emitted.push(message); }; - await session.emitWorkspaceUpdatesForExternalWorkspaceIds( - [main.workspaceId, feature.workspaceId, main.workspaceId], - { skipReconcile: true }, - ); + await session.emitWorkspaceUpdatesForExternalWorkspaceIds([ + main.workspaceId, + feature.workspaceId, + main.workspaceId, + ]); expect(filterByType(emitted, "workspace_update")).toEqual([ { @@ -6795,7 +6723,6 @@ test("workspace_update includes updated runtime fields", async () => { pendingUpdatesByWorkspaceId: new Map(), lastEmittedByWorkspaceId: new Map(), }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; @@ -6813,9 +6740,7 @@ test("workspace_update includes updated runtime fields", async () => { }, }); - await session.emitWorkspaceUpdateForCwd(REPO_CWD, { - skipReconcile: true, - }); + await session.emitWorkspaceUpdateForCwd(REPO_CWD); expect(peekSnapshotRuntimeUpdate).toHaveBeenCalledWith(REPO_CWD); expect(emitted).toContainEqual({ @@ -6914,7 +6839,6 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [gitProject, directoryProject]; session.workspaceRegistry.list = async () => [gitWorkspace, directoryWorkspace]; - session.reconcileAndEmitWorkspaceUpdates = vi.fn(async () => {}); const describeWorkspaceRecordSubscribed = vi.fn( async (workspace: typeof gitWorkspace, project: unknown) => { if (workspace.workspaceId === gitWorkspace.workspaceId) { @@ -7426,15 +7350,13 @@ test("a workspace leaving a filtered subscription after bootstrap emits a remova session.listFetchWorkspacesEntries = async () => listing; session.buildWorkspaceDescriptorMap = async () => new Map(currentDescriptor ? [[currentDescriptor.id, currentDescriptor]] : []); - session.reconcileAndEmitWorkspaceUpdates = async () => undefined; - const bootstrap = session.handleMessage({ type: "fetch_workspaces_request", requestId: "req-buffered-filter", filter: { query: "repo" }, subscribe: { subscriptionId: "sub-buffered-filter" }, }); - await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true }); + await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id]); finishListing({ entries: [], emptyProjects: [], @@ -7448,7 +7370,7 @@ test("a workspace leaving a filtered subscription after bootstrap emits a remova emitted.length = 0; currentDescriptor = { ...descriptor, name: "other work" }; - await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true }); + await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id]); expect(filterByType(emitted, "workspace_update")).toEqual([ { diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 4cdebd624c4..26559458b76 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -474,6 +474,7 @@ describe("WorkspaceReconciliationService", () => { test("archives workspaces whose directories no longer exist", async () => { const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + const archivedWorkspaceIds: string[] = []; projects.set( "p1", @@ -503,14 +504,23 @@ describe("WorkspaceReconciliationService", () => { projectRegistry, workspaceRegistry, logger: createTestLogger(), + onWorkspaceArchived: (workspaceId) => { + archivedWorkspaceIds.push(workspaceId); + }, }); const result = await service.runOnce(); - expect(result.changesApplied.length).toBeGreaterThanOrEqual(1); - const wsChange = result.changesApplied.find((c) => c.kind === "workspace_archived"); - expect(wsChange).toBeDefined(); - expect(workspaces.get("w1")!.archivedAt).toBeTruthy(); + expect(result.changesApplied).toEqual([ + { + kind: "workspace_archived", + workspaceId: "w1", + directory: "/tmp/does-not-exist-reconcile-test", + reason: "directory_missing", + }, + ]); + expect(archivedWorkspaceIds).toEqual(["w1"]); + expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String)); }); test("keeps a project active after all its workspaces are archived", async () => { diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 28ca9e219ab..fa181035872 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -87,6 +87,7 @@ export interface WorkspaceReconciliationServiceOptions { onChanges?: (changes: ReconciliationChange[]) => void; workspaceGitService?: Pick; onProjectUpdate?: (update: ProjectUpdate) => void; + onWorkspaceArchived?: (workspaceId: string) => void | Promise; onWorkspacesChanged?: (workspaceIds: string[]) => Promise; watchProjectRoot?: ProjectRootWatch; clock?: ReconciliationClock; @@ -116,6 +117,7 @@ export class WorkspaceReconciliationService { private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null; private readonly workspaceGitService: Pick | null; private readonly onProjectUpdate: ((update: ProjectUpdate) => void) | null; + private readonly onWorkspaceArchived: ((workspaceId: string) => void | Promise) | null; private readonly onWorkspacesChanged: ((workspaceIds: string[]) => Promise) | null; private readonly watchProjectRoot: ProjectRootWatch; private readonly clock: ReconciliationClock; @@ -137,6 +139,7 @@ export class WorkspaceReconciliationService { this.onChanges = options.onChanges ?? null; this.workspaceGitService = options.workspaceGitService ?? null; this.onProjectUpdate = options.onProjectUpdate ?? null; + this.onWorkspaceArchived = options.onWorkspaceArchived ?? null; this.onWorkspacesChanged = options.onWorkspacesChanged ?? null; this.watchProjectRoot = options.watchProjectRoot ?? watchProjectRoot; this.clock = options.clock ?? systemClock; @@ -237,6 +240,7 @@ export class WorkspaceReconciliationService { missingWorkspaces.map(async (workspace) => { const timestamp = new Date().toISOString(); await this.workspaceRegistry.archive(workspace.workspaceId, timestamp); + await this.onWorkspaceArchived?.(workspace.workspaceId); changes.push({ kind: "workspace_archived", workspaceId: workspace.workspaceId, From 09cfdecbbf03f1ab378ad39adf8f316903b0be35 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 21:44:41 +0200 Subject: [PATCH 042/420] fix(app): release inactive query caches Ordinary queries were retained for the renderer lifetime, allowing large file previews to accumulate. Explicit replica and local-state queries keep their own lifetime policies. --- packages/app/src/data/query-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/src/data/query-client.ts b/packages/app/src/data/query-client.ts index 4be76c73cbb..748bf172e22 100644 --- a/packages/app/src/data/query-client.ts +++ b/packages/app/src/data/query-client.ts @@ -4,7 +4,6 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, - gcTime: Infinity, refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, From c469ac124acc637d8f7148cc97a07cfbe4577e2b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 23:29:58 +0200 Subject: [PATCH 043/420] fix(app): show only workspace commits in Changes Keep base history out of the commit list and explain the empty workspace state. --- packages/app/e2e/commit-diff-panel.spec.ts | 20 ++++++++ .../src/git/commits-section/commit-row.tsx | 5 +- .../git/commits-section/commits-section.tsx | 48 +++++++++++-------- 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 + 11 files changed, 58 insertions(+), 23 deletions(-) diff --git a/packages/app/e2e/commit-diff-panel.spec.ts b/packages/app/e2e/commit-diff-panel.spec.ts index 25d2ed7d2a6..f290b008b7a 100644 --- a/packages/app/e2e/commit-diff-panel.spec.ts +++ b/packages/app/e2e/commit-diff-panel.spec.ts @@ -5,6 +5,25 @@ import { test, expect } from "./fixtures"; const COMMIT_SUBJECT = "Show commit timestamps"; +test("commit history explains when the workspace has no commits ahead of its base", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace({ prefix: "commit-history-empty-workspace-" }); + execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" }); + await workspace.navigateTo(); + + await page.getByRole("button", { name: "Open explorer" }).click(); + const commitsSection = page.getByRole("button", { name: /Commits/i }); + await expect(commitsSection).toBeVisible({ timeout: 30_000 }); + await commitsSection.click(); + + await expect(page.getByTestId("commits-section-no-workspace-commits")).toHaveText( + "No commits ahead of main yet", + { timeout: 30_000 }, + ); +}); + test("commit history shows dates and shares diff layout preferences", async ({ page, withWorkspace, @@ -23,6 +42,7 @@ test("commit history shows dates and shares diff layout preferences", async ({ hasText: COMMIT_SUBJECT, }); await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 }); + await expect(page.locator('[data-testid^="commit-row-"]')).toHaveCount(1); await expect(commitRow).toContainText("Jan 15"); await commitRow.click(); diff --git a/packages/app/src/git/commits-section/commit-row.tsx b/packages/app/src/git/commits-section/commit-row.tsx index 4da725c9166..9c6126eefa0 100644 --- a/packages/app/src/git/commits-section/commit-row.tsx +++ b/packages/app/src/git/commits-section/commit-row.tsx @@ -3,6 +3,7 @@ import { Pressable, Text, View, type PressableStateCallbackType } from "react-na import { StyleSheet } from "react-native-unistyles"; import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron"; import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { formatTimeAgo } from "@/utils/time"; import { CommitGraphNode } from "./commit-graph-node"; @@ -41,7 +42,7 @@ export const CommitRow = memo(function CommitRow({ > - + {commit.shortSha} @@ -79,7 +80,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, fontFamily: theme.fontFamily.mono, color: theme.colors.foregroundMuted, - width: theme.spacing[16], + width: 70, flexShrink: 0, }, subject: { diff --git a/packages/app/src/git/commits-section/commits-section.tsx b/packages/app/src/git/commits-section/commits-section.tsx index 931d95e29ea..bd95a3db470 100644 --- a/packages/app/src/git/commits-section/commits-section.tsx +++ b/packages/app/src/git/commits-section/commits-section.tsx @@ -6,6 +6,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query"; import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron"; +import { normalizeBranchOptionName } from "@/utils/branch-suggestions"; import { CommitRow } from "./commit-row"; interface CommitsSectionProps { @@ -14,8 +15,6 @@ interface CommitsSectionProps { onCommitPress: (sha: string) => void; } -const SKELETON_ROW_KEYS = ["commit-skeleton-1", "commit-skeleton-2", "commit-skeleton-3"]; - function CommitsSectionSkeleton() { const { t } = useTranslation(); return ( @@ -25,15 +24,13 @@ function CommitsSectionSkeleton() { style={styles.skeleton} testID="commits-section-skeleton" > - {SKELETON_ROW_KEYS.map((key) => ( - - - - - - - - ))} + + + + + + + ); } @@ -58,21 +55,25 @@ function CommitsSectionContent({ if (query.status !== "loaded") { return ; } - if (query.data.commits.length === 0) { + const workspaceCommits = query.data.commits.filter((commit) => !commit.isOnBase); + const baseRef = normalizeBranchOptionName(query.data.baseRef) ?? t("workspace.git.diff.base"); + if (workspaceCommits.length === 0) { return ( - - {t("workspace.git.diff.commits.empty")} - + + + {t("workspace.git.diff.commits.noneAhead", { baseRef })} + + ); } return ( - {query.data.commits.map((commit, index) => ( + {workspaceCommits.map((commit, index) => ( @@ -193,12 +194,17 @@ const styles = StyleSheet.create((theme) => ({ list: { paddingBottom: theme.spacing[1], }, - emptyRow: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, + noWorkspaceCommitsRow: { + flexDirection: "row", + alignItems: "center", paddingLeft: theme.spacing[2], paddingRight: theme.spacing[3], - paddingVertical: theme.spacing[2], + paddingTop: theme.spacing[1], + paddingBottom: theme.spacing[2], + }, + noWorkspaceCommitsText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, }, errorRow: { fontSize: theme.fontSize.xs, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 44209253071..bfff7e1294a 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -796,6 +796,7 @@ export const ar: TranslationResources = { commits: { title: "الإيداعات", countLabel: "{{count}} من إيداعات مساحة العمل", + noneAhead: "لا توجد إيداعات متقدمة على {{baseRef}} بعد", fileDiffEmpty: "لا توجد تغييرات لعرضها", fileDiffError: "تعذّر تحميل فروق الملف", loading: "جارٍ تحميل الإيداعات…", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 36440715da3..b6fa7cd7a3a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -806,6 +806,7 @@ export const en = { commits: { title: "Commits", countLabel: "{{count}} workspace commits", + noneAhead: "No commits ahead of {{baseRef}} yet", fileDiffEmpty: "No changes to display", fileDiffError: "Failed to load file diff", loading: "Loading commits…", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 9558f1d655b..7372e60a316 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -827,6 +827,7 @@ export const es: TranslationResources = { commits: { title: "Commits", countLabel: "{{count}} commits del espacio de trabajo", + noneAhead: "Aún no hay commits por delante de {{baseRef}}", fileDiffEmpty: "No hay cambios para mostrar", fileDiffError: "Error al cargar el diff del archivo", loading: "Cargando commits…", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index e46900e01c3..00dfc82451f 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -826,6 +826,7 @@ export const fr: TranslationResources = { commits: { title: "Commits", countLabel: "{{count}} commits de l’espace de travail", + noneAhead: "Aucun commit en avance sur {{baseRef}} pour le moment", fileDiffEmpty: "Aucune modification à afficher", fileDiffError: "Échec du chargement du diff du fichier", loading: "Chargement des commits…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e0e1771f3b2..391994a474f 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -807,6 +807,7 @@ export const ja: TranslationResources = { commits: { title: "コミット", countLabel: "ワークスペースのコミット数: {{count}}", + noneAhead: "{{baseRef}} より先のコミットはまだありません", fileDiffEmpty: "表示する変更はありません", fileDiffError: "ファイル差分の読み込みに失敗しました", loading: "コミットを読み込み中…", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 7c1b2d9236c..dea623dcf5d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -818,6 +818,7 @@ export const ptBR: TranslationResources = { commits: { title: "Commits", countLabel: "{{count}} commits do espaço de trabalho", + noneAhead: "Ainda não há commits à frente de {{baseRef}}", fileDiffEmpty: "Nenhuma alteração para exibir", fileDiffError: "Falha ao carregar diff do arquivo", loading: "Carregando commits…", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index aa1e391f1fa..c799196facd 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -818,6 +818,7 @@ export const ru: TranslationResources = { commits: { title: "Коммиты", countLabel: "{{count}} коммитов рабочего пространства", + noneAhead: "Коммитов впереди {{baseRef}} пока нет", fileDiffEmpty: "Нет изменений для отображения", fileDiffError: "Не удалось загрузить различия файла", loading: "Загрузка коммитов…", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 27e4b046d36..5f805fc5c0f 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -788,6 +788,7 @@ export const zhCN: TranslationResources = { commits: { title: "提交", countLabel: "{{count}} 个工作区提交", + noneAhead: "尚无领先于 {{baseRef}} 的提交", fileDiffEmpty: "没有可显示的更改", fileDiffError: "加载文件差异失败", loading: "正在加载提交…", From 17c12e2e1a1ee20ac3991c4a2b5dca683af103d4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 23:33:31 +0200 Subject: [PATCH 044/420] chore(acp): update provider catalog versions --- packages/app/src/data/acp-provider-catalog.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index f97580c3d2f..2ec3fd3269d 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -18,10 +18,10 @@ const CATALOG_DATA = [ title: "Agoragentic", description: "Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.", - version: "1.3.3", + version: "1.3.6", iconId: "agoragentic-acp", installLink: "https://agoragentic.com", - command: ["npx", "-y", "agoragentic-mcp@1.3.3", "--acp"], + command: ["npx", "-y", "agoragentic-mcp@1.3.6", "--acp"], }, { id: "amp-acp", @@ -140,10 +140,10 @@ const CATALOG_DATA = [ id: "dimcode", title: "DimCode", description: "A coding agent that puts leading models at your command.", - version: "0.2.35", + version: "0.2.36", iconId: "dimcode", installLink: "https://dimcode.dev/docs/acp.html", - command: ["npx", "-y", "dimcode@0.2.35", "acp"], + command: ["npx", "-y", "dimcode@0.2.36", "acp"], }, { id: "dirac", @@ -159,10 +159,10 @@ const CATALOG_DATA = [ id: "factory-droid", title: "Factory Droid", description: "Factory Droid - AI coding agent powered by Factory AI", - version: "0.177.0", + version: "0.178.0", iconId: "factory-droid", installLink: "https://factory.ai/product/cli", - command: ["npx", "-y", "droid@0.177.0", "exec", "--output-format", "acp-daemon"], + command: ["npx", "-y", "droid@0.178.0", "exec", "--output-format", "acp-daemon"], env: { DROID_DISABLE_AUTO_UPDATE: "true", FACTORY_DROID_AUTO_UPDATE_ENABLED: "false", @@ -173,19 +173,19 @@ const CATALOG_DATA = [ id: "fast-agent", title: "fast-agent", description: "Code and build agents with comprehensive multi-provider support", - version: "0.9.21", + version: "0.9.22", iconId: "fast-agent", installLink: "https://fast-agent.ai/acp/", - command: ["uvx", "--from", "fast-agent-acp==0.9.21", "fast-agent-acp", "-x"], + command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"], }, { id: "gemini", title: "Gemini CLI", description: "Google's official CLI for Gemini", - version: "0.51.0", + version: "0.52.0", iconId: "gemini", installLink: "https://geminicli.com", - command: ["npx", "-y", "@google/gemini-cli@0.51.0", "--acp"], + command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"], }, { id: "glm-acp-agent", @@ -302,10 +302,10 @@ const CATALOG_DATA = [ id: "qoder", title: "Qoder CLI", description: "AI coding assistant with agentic capabilities", - version: "1.1.3", + version: "1.1.4", iconId: "qoder", installLink: "https://qoder.com", - command: ["npx", "-y", "@qoder-ai/qodercli@1.1.3", "--acp"], + command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"], }, { id: "qwen-code", From 13bce0563005c95fd80cea71f4ad6b897e3ff1dc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Jul 2026 23:33:37 +0200 Subject: [PATCH 045/420] docs: prepare 0.2.0-beta.4 changelog --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce069f4665a..11607895260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.2.0-beta.4 - 2026-07-23 + +### Improved + +- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361)) +- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim)) + +### Fixed + +- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379)) +- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang)) +- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger)) +- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363)) +- File edits preserve CRLF line endings and UTF-8 BOMs ([#2277](https://github.com/getpaseo/paseo/pull/2277) by [@dwyanewang](https://github.com/dwyanewang)) +- Oh My Pi custom messages marked hidden stay hidden in live and restored chats ([#2280](https://github.com/getpaseo/paseo/pull/2280) by [@isac322](https://github.com/isac322)) +- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368)) +- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369)) + ## 0.2.0-beta.3 - 2026-07-22 ### Added From b02acb882c8fab0653ed3c5a50b5f0b9fb23b7f8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 00:16:25 +0200 Subject: [PATCH 046/420] chore(release): cut 0.2.0-beta.4 --- package-lock.json | 42 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++--- packages/client/package.json | 6 ++-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 +++--- packages/website/package.json | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b8d7885428..6ae4a230dbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35211,7 +35211,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36236,12 +36236,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.0-beta.3", - "@getpaseo/protocol": "0.2.0-beta.3", - "@getpaseo/server": "0.2.0-beta.3", + "@getpaseo/client": "0.2.0-beta.4", + "@getpaseo/protocol": "0.2.0-beta.4", + "@getpaseo/server": "0.2.0-beta.4", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36487,10 +36487,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "dependencies": { - "@getpaseo/protocol": "0.2.0-beta.3", - "@getpaseo/relay": "0.2.0-beta.3", + "@getpaseo/protocol": "0.2.0-beta.4", + "@getpaseo/relay": "0.2.0-beta.4", "zod": "^4.4.3" }, "devDependencies": { @@ -36501,7 +36501,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36744,7 +36744,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37640,7 +37640,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -37872,7 +37872,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "dependencies": { "zod": "^4.4.3" }, @@ -37885,7 +37885,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38103,15 +38103,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.0-beta.3", - "@getpaseo/highlight": "0.2.0-beta.3", - "@getpaseo/protocol": "0.2.0-beta.3", - "@getpaseo/relay": "0.2.0-beta.3", + "@getpaseo/client": "0.2.0-beta.4", + "@getpaseo/highlight": "0.2.0-beta.4", + "@getpaseo/protocol": "0.2.0-beta.4", + "@getpaseo/relay": "0.2.0-beta.4", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38648,7 +38648,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 854951506fd..8c8ca4bf2bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index d360576fe8e..6abe2017d62 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 67dbf7177b9..8353e3b18f8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -27,9 +27,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.0-beta.3", - "@getpaseo/protocol": "0.2.0-beta.3", - "@getpaseo/server": "0.2.0-beta.3", + "@getpaseo/client": "0.2.0-beta.4", + "@getpaseo/protocol": "0.2.0-beta.4", + "@getpaseo/server": "0.2.0-beta.4", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 4dda8d619c0..13c7837745c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "description": "Paseo client SDK package", "files": [ "dist", @@ -35,8 +35,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.2.0-beta.3", - "@getpaseo/relay": "0.2.0-beta.3", + "@getpaseo/protocol": "0.2.0-beta.4", + "@getpaseo/relay": "0.2.0-beta.4", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index a8c7ab925d2..076334a168d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 6bf7a5327a6..127e9c95a0a 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 87576d2cdc8..b7f4b5db742 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 60d8eb2d2a5..fc0342a1bb1 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 5adfb4d6302..a2a80ee22e7 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 1b5ef131716..ffa1549fd89 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "description": "Paseo backend server", "files": [ "dist/server", @@ -67,10 +67,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.0-beta.3", - "@getpaseo/highlight": "0.2.0-beta.3", - "@getpaseo/protocol": "0.2.0-beta.3", - "@getpaseo/relay": "0.2.0-beta.3", + "@getpaseo/client": "0.2.0-beta.4", + "@getpaseo/highlight": "0.2.0-beta.4", + "@getpaseo/protocol": "0.2.0-beta.4", + "@getpaseo/relay": "0.2.0-beta.4", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 30a55513ce1..49338979c5e 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.2.0-beta.3", + "version": "0.2.0-beta.4", "private": true, "type": "module", "scripts": { From 4b5551d61cf958060d871a6c9621743e664cda6c Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:25:59 +0000 Subject: [PATCH 047/420] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 8315118765d..727c448a687 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-x6jiaw7zkCuBRlddLHD0tXQxolXwr7tzIQBfWu5xnUU= +sha256-t7FqvPj7U5YidGH9OZ1kFnD2dPTYUyjcxBlzAJU7Mak= From 08c522c986a4220868d933a4aabef8522761612b Mon Sep 17 00:00:00 2001 From: Ethan Greenfeld <39423472+ebg1223@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:02:52 -0700 Subject: [PATCH 048/420] Render live omp system-notices as synthetic tool calls (#2218) Route live OMP custom messages through the existing system-notice mapper so background notices render as task notifications while advisor, hidden, and ordinary custom-message behavior stays intact. --- .../server/agent/providers/omp/agent.test.ts | 29 +++++++++++++++++++ .../src/server/agent/providers/omp/agent.ts | 7 +++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 35020ca45c6..f0adadee74c 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -277,6 +277,35 @@ describe("OMP agent client and session", () => { ]); }); + test("renders a live system-notice custom message as a synthetic tool call", async () => { + const omp = new OmpHarness(); + await omp.start(); + + await omp.runPrompt("hello OMP", "done"); + omp + .runtime() + .acceptCustomMessage( + [ + "", + "Background job DocsSmokeTwo has completed.", + '', + "done", + "", + "", + ].join("\n"), + ); + omp.runtime().acceptCustomMessage("plain custom status text"); + + expect(omp.timeline().filter((item) => item.type === "tool_call")).toMatchObject([ + { callId: "omp-notice:DocsSmokeTwo", name: "task_notification", status: "completed" }, + ]); + // Non-notice custom messages still fall through as assistant messages. + expect(omp.timeline().filter((item) => item.type === "assistant_message")).toMatchObject([ + { text: "done" }, + { text: "plain custom status text" }, + ]); + }); + test("does not complete a queued model turn from OMP's local-only hint", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index c0654a6274d..8337c250a45 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -68,6 +68,7 @@ export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-c import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; +import { mapOmpSystemNoticeToToolCall } from "./system-notice.js"; import { materializeProviderImage } from "../provider-image-output.js"; import { OmpCliRuntime } from "./cli-runtime.js"; import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js"; @@ -2068,12 +2069,14 @@ export class OmpAgentSession implements AgentSession { if (shouldDisplayOmpCustomMessage(event.message)) { const text = getUserMessageText(event.message.content); if (text) { - const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text); + const item = + mapOmpAdvisorMessageToToolCall(event.message, text) ?? + mapOmpSystemNoticeToToolCall(text); this.emit({ type: "timeline", provider: this.provider, turnId, - item: advisorItem ?? { type: "assistant_message", text }, + item: item ?? { type: "assistant_message", text }, }); } } From fc10c79e2653e5e653fe8dc2d1364faaa0529e0e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 11:52:31 +0200 Subject: [PATCH 049/420] fix(app): synchronize chat submission and keyboard state Render optimistic turn feedback before host acknowledgement and roll it back on rejection. Reconcile iOS keyboard offsets from the native transition end event so JS contention cannot leave the composer displaced. --- docs/floating-panels.md | 7 +++ packages/app/src/agent-stream/view.tsx | 2 +- packages/app/src/composer/actions.test.ts | 20 +++++++ packages/app/src/composer/actions.ts | 52 +++++++++++-------- .../src/composer/attachments/workspace.tsx | 14 ++++- packages/app/src/composer/index.tsx | 5 ++ .../app/src/hooks/use-keyboard-shift-style.ts | 18 ++++++- packages/app/src/timeline/turn-time.test.ts | 30 +++++++++++ packages/app/src/timeline/turn-time.ts | 22 +++----- 9 files changed, 132 insertions(+), 38 deletions(-) diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 7b47b23aadc..d646dc609c2 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -129,6 +129,13 @@ lockstep, no re-measurement needed. Do not call can briefly report a stale nonzero height with closed progress, and the shared provider is where that is normalized. +The provider also reconciles iOS from the controller's native `onEnd` event. +The controller's stock iOS shared values update at move start and during an +interactive move, but not at the terminal event, so JS contention can otherwise +leave the last height/progress pair stuck in either the open or closed state. +Keep that terminal reconciliation on the UI thread; a later focus or blur must +not be required to repair the offset. + Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only to refresh the snapshot if the keyboard was mid-transition when the popover opened. diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 3ff24bcc897..421fed73c94 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -876,7 +876,7 @@ const AgentStreamViewComponent = forwardRef renderPendingPermissionsNode({ diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index facc6a909e3..8b577fcd7fa 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -333,6 +333,26 @@ describe("pickAndPersistImages", () => { }); describe("dispatchComposerAgentMessage", () => { + it("removes the optimistic prompt when the host rejects it", async () => { + const rejection = new Error("Host rejected prompt"); + const client = createFakeSendClient({ rejection }); + const stream = createFakeStream(); + + await expect( + dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "rejected prompt", + attachments: [], + encodeImages: passthroughEncodeImages, + stream, + }), + ).rejects.toBe(rejection); + + expect(stream.head.get("agent")).toBeUndefined(); + expect(stream.tail.get("agent") ?? []).toEqual([]); + }); + it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => { const client = createFakeSendClient(); const stream = createFakeStream(); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 382d9f77557..af3d123d780 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -186,40 +186,50 @@ export async function dispatchComposerAgentMessage( images: wirePayload.images, attachments: wirePayload.attachments, }); - appendUserMessageToStream(input.agentId, userMessage, input.stream); - const imagesData = await input.encodeImages(wirePayload.images); - await input.client.sendAgentMessage(input.agentId, input.text, { - messageId, - images: imagesData ?? [], - attachments: wirePayload.attachments, - }); + const rollbackOptimisticMessage = appendUserMessageToStream( + input.agentId, + userMessage, + input.stream, + ); + try { + const imagesData = await input.encodeImages(wirePayload.images); + await input.client.sendAgentMessage(input.agentId, input.text, { + messageId, + images: imagesData ?? [], + attachments: wirePayload.attachments, + }); + } catch (error) { + rollbackOptimisticMessage(); + throw error; + } } function appendUserMessageToStream( agentId: string, userMessage: UserMessageItem, stream: AgentStreamWriter, -): void { +): () => void { const result = appendOptimisticUserMessageToStream({ tail: stream.getTail(agentId) ?? [], head: stream.getHead(agentId) ?? [], message: userMessage, placement: "active-head", }); - if (result.changedHead) { - stream.setHead((prev) => { - const next = new Map(prev); - next.set(agentId, result.head); - return next; + const write = result.changedHead ? stream.setHead : stream.setTail; + const items = result.changedHead ? result.head : result.tail; + write((prev) => new Map(prev).set(agentId, items)); + + return () => { + write((prev) => { + const current = prev.get(agentId); + if (!current) return prev; + const nextItems = current.filter( + (item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic, + ); + if (nextItems.length === current.length) return prev; + return new Map(prev).set(agentId, nextItems); }); - } - if (result.changedTail) { - stream.setTail((prev) => { - const next = new Map(prev); - next.set(agentId, result.tail); - return next; - }); - } + }; } export interface QueueComposerMessageInput { diff --git a/packages/app/src/composer/attachments/workspace.tsx b/packages/app/src/composer/attachments/workspace.tsx index 0beff1fb589..2e9882a4906 100644 --- a/packages/app/src/composer/attachments/workspace.tsx +++ b/packages/app/src/composer/attachments/workspace.tsx @@ -45,6 +45,7 @@ interface ComposerWorkspaceAttachmentBinding { buildOutgoingAttachments: (normalAttachments: UserComposerAttachment[]) => ComposerAttachment[]; removeAttachment: (input: RemoveWorkspaceAttachmentInput) => boolean; openAttachment: (input: OpenWorkspaceAttachmentInput) => boolean; + beginSubmit: (attachments: readonly ComposerAttachment[]) => void; clearSentAttachments: (attachments: readonly ComposerAttachment[]) => void; completeSubmit: (input: CompleteSubmitInput) => void; resetSuppression: () => void; @@ -196,12 +197,22 @@ function useWorkspaceAttachmentBinding({ setSuppressedKeys([]); }, []); + const beginSubmit = useCallback((attachments: readonly ComposerAttachment[]) => { + const keys = attachments.filter(isWorkspaceAttachment).map(getAttachmentKey); + if (keys.length === 0) return; + setSuppressedKeys((current) => { + const next = new Set(current); + for (const key of keys) next.add(key); + return next.size === current.length ? current : Array.from(next); + }); + }, []); + const completeSubmit = useCallback( ({ result, outgoingAttachments }: CompleteSubmitInput) => { if (result === "submitted") { clearSentAttachments(outgoingAttachments); } - if (result === "queued" || result === "submitted") { + if (result === "queued" || result === "submitted" || result === "failed") { resetSuppression(); } }, @@ -213,6 +224,7 @@ function useWorkspaceAttachmentBinding({ buildOutgoingAttachments, removeAttachment, openAttachment, + beginSubmit, clearSentAttachments, completeSubmit, resetSuppression, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c7e9cc38a20..f753a04d41d 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1095,6 +1095,7 @@ export function Composer({ buildOutgoingAttachments, removeAttachment, openAttachment, + beginSubmit, clearSentAttachments, completeSubmit, resetSuppression, @@ -1372,6 +1373,9 @@ export function Composer({ queueMessage(queuedText, queuedAttachments); }, submitMessage: async ({ message: submitText, attachments: submitAttachments }) => { + if (submitBehavior !== "preserve-and-lock") { + beginSubmit(submitAttachments); + } await submitMessage(submitText, submitAttachments); }, clearDraft, @@ -1393,6 +1397,7 @@ export function Composer({ }, [ allowEmptySubmit, + beginSubmit, clearDraft, completeSubmit, hasExternalContent, diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index a07ce56c337..63799c6783b 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -9,7 +9,10 @@ import { import { Platform } from "react-native"; import type { ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; +import { + useGenericKeyboardHandler, + useReanimatedKeyboardAnimation, +} from "react-native-keyboard-controller"; import { useAnimatedStyle, useDerivedValue, @@ -40,6 +43,19 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { bottomInset.value = insets.bottom; }, [bottomInset, insets.bottom]); + useGenericKeyboardHandler( + { + onEnd: (event) => { + "worklet"; + if (isIos) { + keyboardHeight.value = -event.height; + keyboardProgress.value = event.progress; + } + }, + }, + [isIos, keyboardHeight, keyboardProgress], + ); + const shift = useDerivedValue(() => { "worklet"; return resolveKeyboardShift({ diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index 575817b5ef7..d63d2de7d53 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -22,6 +22,36 @@ function assistant(id: string, timestamp: Date): StreamItem { } describe("deriveStreamTurnTiming", () => { + it("reserves a running footer for an optimistic prompt before the host starts the turn", () => { + const optimisticPrompt = { + ...user("optimistic", new Date("2026-05-15T00:00:00.000Z")), + optimistic: true as const, + }; + + const timing = deriveStreamTurnTiming({ + agentStatus: "idle", + tail: [], + head: [optimisticPrompt], + }); + + assert.equal(timing.isActive, true); + }); + + it("does not start elapsed time from an optimistic prompt", () => { + const optimisticPrompt = { + ...user("optimistic", new Date("2026-05-15T00:00:00.000Z")), + optimistic: true as const, + }; + + const timing = deriveStreamTurnTiming({ + agentStatus: "running", + tail: [], + head: [optimisticPrompt], + }); + + assert.equal(timing.runningStartedAt, null); + }); + it("uses the last user message as the running turn start", () => { const firstUserAt = new Date("2026-05-15T00:00:00.000Z"); const secondUserAt = new Date("2026-05-15T00:01:00.000Z"); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index 575f52ef6d0..4356945243f 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -9,6 +9,7 @@ export interface TurnTiming { export interface StreamTurnTiming { byAssistantId: Map; runningStartedAt: Date | null; + isActive: boolean; } export function deriveStreamTurnTiming(params: { @@ -18,6 +19,8 @@ export function deriveStreamTurnTiming(params: { }): StreamTurnTiming { const byAssistantId = new Map(); let currentUserAt: Date | null = null; + let currentAuthoritativeUserAt: Date | null = null; + let currentUserIsOptimistic = false; let currentLastItemAt: Date | null = null; let currentAssistantIds: string[] = []; @@ -39,6 +42,8 @@ export function deriveStreamTurnTiming(params: { if (item.kind === "user_message") { flushCompletedTurn(); currentUserAt = item.timestamp; + currentAuthoritativeUserAt = item.optimistic ? null : item.timestamp; + currentUserIsOptimistic = item.optimistic === true; currentLastItemAt = null; currentAssistantIds = []; return; @@ -59,10 +64,8 @@ export function deriveStreamTurnTiming(params: { visitItem(item); } - const runningStartedAt = - params.agentStatus === "running" - ? (findLastUserMessageTimestamp(params.head) ?? currentUserAt) - : null; + const isRunning = params.agentStatus === "running"; + const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null; if (params.agentStatus !== "running") { flushCompletedTurn(); } @@ -70,15 +73,6 @@ export function deriveStreamTurnTiming(params: { return { byAssistantId, runningStartedAt, + isActive: isRunning || currentUserIsOptimistic, }; } - -function findLastUserMessageTimestamp(items: StreamItem[]): Date | null { - for (let i = items.length - 1; i >= 0; i -= 1) { - const item = items[i]; - if (item?.kind === "user_message") { - return item.timestamp; - } - } - return null; -} From 7bd4afe848fcae59b4fb7e69c461ca242ef8cddb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 11:57:20 +0200 Subject: [PATCH 050/420] docs(providers): add Codex setup guide (#2389) --- public-docs/codex.md | 84 ++++++++++++++++++++++++++++++ public-docs/supported-providers.md | 2 +- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 public-docs/codex.md diff --git a/public-docs/codex.md b/public-docs/codex.md new file mode 100644 index 00000000000..2def47a8f28 --- /dev/null +++ b/public-docs/codex.md @@ -0,0 +1,84 @@ +--- +title: Codex +description: Run Codex in Paseo using the official Codex CLI and your existing OpenAI account. +nav: Codex +order: 24 +category: Providers +--- + +# Codex + +Paseo runs Codex through the official `codex` CLI and its app-server interface. + +## Does Codex cost extra in Paseo? + +No. Paseo does not add a charge for Codex. Sign in to the Codex CLI with ChatGPT to use the access included with your ChatGPT plan, or sign in with an API key for usage billed through your OpenAI Platform account. + +Your plan's normal Codex limits and OpenAI's standard API pricing still apply. + +## Getting started + +Install the [Codex CLI](https://learn.chatgpt.com/docs/codex/cli) on the machine running Paseo: + +```bash +npm install -g @openai/codex +``` + +Sign in with ChatGPT for subscription access: + +```bash +codex login +``` + +Or sign in with an API key for usage billed through your OpenAI Platform account: + +```bash +# macOS or Linux +printenv OPENAI_API_KEY | codex login --with-api-key +``` + +```powershell +# Windows PowerShell +$env:OPENAI_API_KEY | codex login --with-api-key +``` + +Then confirm the CLI starts: + +```bash +codex +``` + +Paseo uses this installation and its existing authentication when you start a Codex agent. + +## Codex is missing in Paseo + +The ChatGPT desktop app and the Codex CLI are separate installs. Installing the desktop app does not make the `codex` command available to Paseo. + +Check whether the CLI is on your `PATH`: + +```bash +# macOS or Linux +which -a codex + +# Windows +where.exe codex +``` + +If the command is not found: + +1. Install the [Codex CLI](https://learn.chatgpt.com/docs/codex/cli). +2. Sign in with ChatGPT or an API key using the commands above. See [Codex authentication](https://learn.chatgpt.com/docs/auth). +3. Restart Paseo if its daemon was already running when you installed the CLI. +4. In Paseo, open **Settings → Providers → Codex** and select **Refresh**. + +The provider should become available once Paseo can find and start the `codex` command. + +## Use Codex in the Paseo terminal + +Codex also works inside the Paseo terminal. Open a terminal in your workspace and run `codex` for the standard CLI experience while keeping access to your workspace, git changes, and other Paseo tools. + +## See also + +- [Supported providers](/docs/supported-providers), for other agents you can run alongside Codex. +- [Custom providers](/docs/custom-providers), for custom binaries, third-party endpoints, or multiple Codex profiles. +- [Paseo vs Codex app](/alternatives/codex-app), for a feature comparison. diff --git a/public-docs/supported-providers.md b/public-docs/supported-providers.md index ff0bb0b5835..22190bb7584 100644 --- a/public-docs/supported-providers.md +++ b/public-docs/supported-providers.md @@ -15,7 +15,7 @@ For the concept and how Paseo manages providers, see [Providers](/docs/providers Work out of the box once the underlying CLI is installed and authenticated. - [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Anthropic's coding agent with MCP support, streaming, and deep reasoning. -- [Codex CLI](https://github.com/openai/codex). OpenAI's workspace agent with sandbox controls and optional network access. +- [Codex](/docs/codex). OpenAI's workspace agent with sandbox controls and optional network access. - [OpenCode](https://opencode.ai/). Open-source coding assistant with multi-provider model support. - [pi](https://github.com/svkozak/pi-acp). Minimal terminal-based coding agent with multi-provider LLM support. From 779a56ed36326ee9e9fb47c17c572e93aa41d82d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 12:14:08 +0200 Subject: [PATCH 051/420] fix(app): hide Markdown source toggle when editing is unavailable (#2382) --- packages/app/src/file-pane/pane.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index db7a4a5aef9..c34bf710873 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -459,6 +459,7 @@ export function FilePane({ preview, supportsEditing, }); + const canToggleMarkdownMode = isMarkdown && editable; const lineCount = preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined; const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad")); @@ -471,8 +472,8 @@ export function FilePane({ preview={preview} version={version} filename={getFileNameFromPath(location.path) ?? location.path} - markdownMode={isMarkdown ? markdownMode : undefined} - onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined} + markdownMode={canToggleMarkdownMode ? markdownMode : undefined} + onMarkdownModeChange={canToggleMarkdownMode ? setMarkdownMode : undefined} lineCount={lineCount} editable={editable} disconnectedMessage={t("workspace.terminal.hostDisconnected")} From 609f81bc11a6d34c859bf3820b853b41ab601eb5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 12:46:10 +0200 Subject: [PATCH 052/420] Keep development tool versions consistent across version managers (#2390) * chore(dev): centralize tool versions * docs(android): clarify SDK version source * fix(dev): keep mise setup installable --- .mise.toml | 14 +++++++------- docs/android.md | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.mise.toml b/.mise.toml index 7c1a0bad954..068718b615d 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,10 +1,10 @@ +[vars] +android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}' + [env] -ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0" +ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}" _.path = [ - "{{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", + "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin", + "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools", + "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator", ] - -[tools] -java = "21" diff --git a/docs/android.md b/docs/android.md index c8f1309960f..81a35c68488 100644 --- a/docs/android.md +++ b/docs/android.md @@ -27,13 +27,13 @@ The formula reserves three digits each for minor and patch. If either reaches `1 ## 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): +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 derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). 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`. +> **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 only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry. `mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon: From 48b14d27a5cc049345215663c6f73c193fb19a31 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 12:46:46 +0200 Subject: [PATCH 053/420] fix(app): align and simplify provider rows --- .../settings/providers-section.test.tsx | 3 +- .../screens/settings/providers-section.tsx | 60 +++++++++++-------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 6af35b675e4..c4f825dd9f5 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -44,6 +44,7 @@ const { theme, snapshotState, configState, patchConfigMock, openProviderSettings ); vi.mock("react-native", () => ({ + Platform: { OS: "web" }, View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => React.createElement("div", { "data-testid": testID }, children), Text: ({ children }: { children?: React.ReactNode }) => @@ -90,7 +91,7 @@ vi.mock("react-native-unistyles", () => ({ create: (factory: unknown) => typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, }, - useUnistyles: () => ({ theme }), + useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }), })); vi.mock("lucide-react-native", () => { diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 2d951c95483..716eb3b8a34 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -10,6 +10,7 @@ import { type PressableStateCallbackType, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostFeature } from "@/runtime/host-features"; @@ -178,6 +179,7 @@ function ProviderRow({ }: ProviderRowProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); + const isCompact = useIsCompactFormFactor(); const ProviderIcon = getProviderIcon(def.id); const providerError = enabled && @@ -229,10 +231,10 @@ function ProviderRow({ {def.label} - · - + {!isCompact ? · : null} + - {providerError ? ( + {providerError && !isCompact ? ( {providerError} @@ -246,18 +248,20 @@ function ProviderRow({ disabled={isToggling || isRemoving} accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })} /> - {canRemove ? ( - - ) : null} + + {canRemove ? ( + + ) : null} + )} @@ -278,7 +282,7 @@ function getDotColor(tone: StatusTone, theme: ReturnType["t } } -function StatusIndicator({ status }: { status: ProviderStatus }) { +function StatusIndicator({ status, compact }: { status: ProviderStatus; compact: boolean }) { const { t } = useTranslation(); const { theme } = useUnistyles(); const dotStyle = useMemo( @@ -293,15 +297,19 @@ function StatusIndicator({ status }: { status: ProviderStatus }) { ) : ( )} - {status.label} - {status.modelCount !== null ? ( + {!compact ? ( <> - · - - {status.modelCount === 1 - ? t("settings.providers.models.one") - : t("settings.providers.models.many", { count: status.modelCount })} - + {status.label} + {status.modelCount !== null ? ( + <> + · + + {status.modelCount === 1 + ? t("settings.providers.models.one") + : t("settings.providers.models.many", { count: status.modelCount })} + + + ) : null} ) : null} @@ -536,6 +544,10 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, + menuSlot: { + width: 32, + height: 32, + }, menuButtonHovered: { backgroundColor: theme.colors.surface2, }, From afcd972dd080ed5f76253edb7c02364f7ea24594 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 12:47:23 +0200 Subject: [PATCH 054/420] Keep terminal pairing QR codes scannable (#2381) * fix(pairing): keep terminal QR codes scannable Prompt framing could wrap dense QR codes and alter long pairing links. Print pairing instructions directly, add the standard quiet zone, and suppress codes that cannot fit without auto-wrapping. * fix(pairing): contain terminal QR background --- packages/cli/src/commands/daemon/pair.ts | 10 +++- packages/cli/src/commands/onboard.ts | 11 ++-- packages/cli/src/output/pairing.test.ts | 30 +++++++++++ packages/cli/src/output/pairing.ts | 37 ++++++++++++++ packages/server/src/server/pairing-qr.test.ts | 28 +++++++++++ packages/server/src/server/pairing-qr.ts | 50 ++++--------------- 6 files changed, 119 insertions(+), 47 deletions(-) create mode 100644 packages/cli/src/output/pairing.test.ts create mode 100644 packages/cli/src/output/pairing.ts create mode 100644 packages/server/src/server/pairing-qr.test.ts diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index a30f0e2cc0c..a2ca3aab2a9 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -4,6 +4,7 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpas import { tryConnectToDaemon } from "../../utils/client.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { addJsonOption } from "../../utils/command-options.js"; +import { formatPairingInstructions } from "../../output/pairing.js"; interface PairOptions { home?: string; @@ -97,6 +98,11 @@ function outputPairingResult( return; } - const qrBlock = pairing.qr ? `${pairing.qr}\n` : ""; - process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`); + process.stdout.write( + formatPairingInstructions({ + url: pairing.url, + qr: pairing.qr, + columns: process.stdout.columns, + }), + ); } diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 2600bdb2945..22e10cf4af7 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -18,6 +18,7 @@ import { type DaemonStartOptions, } from "./daemon/local-daemon.js"; import { tryConnectToDaemon } from "../utils/client.js"; +import { formatPairingInstructions } from "../output/pairing.js"; interface OnboardOptions extends DaemonStartOptions { timeout?: string; @@ -520,11 +521,13 @@ export async function runOnboard(options: OnboardOptions): Promise { return; } - renderNote( - pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.", - "Scan to pair", + process.stdout.write( + formatPairingInstructions({ + url: pairing.url, + qr: pairing.qr, + columns: process.stdout.columns, + }), ); - renderNote(pairing.url, "Pairing link"); printNextSteps(pairing.url, paseoHome, richUi); if (richUi) { outro("Paseo is ready!"); diff --git a/packages/cli/src/output/pairing.test.ts b/packages/cli/src/output/pairing.test.ts new file mode 100644 index 00000000000..00ed6b0a85e --- /dev/null +++ b/packages/cli/src/output/pairing.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { formatPairingInstructions } from "./pairing.js"; + +const QR = "\u001b[47m\u001b[30m \n ████ \n \u001b[0m"; +const URL = "https://app.paseo.sh/#offer=pairing-offer"; + +describe("formatPairingInstructions", () => { + it("prints the QR and an unmodified pairing-link line when the terminal is wide enough", () => { + const output = formatPairingInstructions({ qr: QR, url: URL, columns: 7 }); + + expect(output).toContain(QR); + expect(output.split("\n")).toContain(URL); + }); + + it("does not print a QR that would reach the terminal edge", () => { + const output = formatPairingInstructions({ qr: QR, url: URL, columns: 6 }); + + expect(output).not.toContain(QR); + expect(output).toContain("Resize the terminal to at least 7 columns"); + expect(output.split("\n")).toContain(URL); + }); + + it("does not risk printing a QR when terminal width is unknown", () => { + const output = formatPairingInstructions({ qr: QR, url: URL }); + + expect(output).not.toContain(QR); + expect(output).toContain("terminal width could not be detected"); + expect(output.split("\n")).toContain(URL); + }); +}); diff --git a/packages/cli/src/output/pairing.ts b/packages/cli/src/output/pairing.ts new file mode 100644 index 00000000000..6d0da5bf6f5 --- /dev/null +++ b/packages/cli/src/output/pairing.ts @@ -0,0 +1,37 @@ +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g"); + +interface PairingInstructions { + url: string; + qr: string | null; + columns?: number; +} + +function visibleWidth(value: string): number { + return Math.max( + ...value + .replace(ANSI_PATTERN, "") + .split("\n") + .map((line) => line.length), + ); +} + +function formatQr(qr: string | null, columns: number | undefined): string { + if (!qr) { + return "QR code is unavailable. Use the pairing link below."; + } + + if (columns === undefined) { + return "QR code not shown because terminal width could not be detected."; + } + + const width = visibleWidth(qr); + if (columns <= width) { + return `QR code not shown. Resize the terminal to at least ${width + 1} columns, then run this command again.`; + } + + return qr; +} + +export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string { + return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n`; +} diff --git a/packages/server/src/server/pairing-qr.test.ts b/packages/server/src/server/pairing-qr.test.ts new file mode 100644 index 00000000000..27f317c6e76 --- /dev/null +++ b/packages/server/src/server/pairing-qr.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import * as QRCode from "qrcode"; +import { renderPairingQr } from "./pairing-qr.js"; + +const ESCAPE = String.fromCharCode(0x1b); +const ANSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-9;]*m`, "g"); + +describe("renderPairingQr", () => { + it("renders a theme-independent QR code with a four-module quiet zone", async () => { + const url = "https://app.paseo.sh/#offer=test-pairing-offer"; + const qr = await renderPairingQr(url); + const styledLines = qr.split("\n"); + const visibleLines = qr.replace(ANSI_PATTERN, "").split("\n"); + const moduleCount = QRCode.create(url).modules.size; + + expect( + styledLines.every( + (line) => line.startsWith(`${ESCAPE}[47m${ESCAPE}[30m`) && line.endsWith(`${ESCAPE}[0m`), + ), + ).toBe(true); + expect(visibleLines.every((line) => line.length === moduleCount + 8)).toBe(true); + expect(visibleLines.slice(0, 2).every((line) => line.trim() === "")).toBe(true); + expect(visibleLines.slice(-2).every((line) => line.trim() === "")).toBe(true); + expect(visibleLines.every((line) => line.startsWith(" ") && line.endsWith(" "))).toBe( + true, + ); + }); +}); diff --git a/packages/server/src/server/pairing-qr.ts b/packages/server/src/server/pairing-qr.ts index 8b8f31f44bf..7d9d1316749 100644 --- a/packages/server/src/server/pairing-qr.ts +++ b/packages/server/src/server/pairing-qr.ts @@ -1,50 +1,18 @@ import * as QRCode from "qrcode"; -import type { QRCodeToStringOptionsTerminal, QRCodeToStringOptionsOther } from "qrcode"; -import type { Logger } from "pino"; +import type { QRCodeToStringOptionsOther } from "qrcode"; -function parseBooleanEnv(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - const normalized = value.trim().toLowerCase(); - if (["1", "true", "yes", "y", "on"].includes(normalized)) return true; - if (["0", "false", "no", "n", "off"].includes(normalized)) return false; - return undefined; -} - -function shouldPrintPairingQr(): boolean { - const env = parseBooleanEnv(process.env.PASEO_PAIRING_QR); - if (env !== undefined) return env; - return process.stdout.isTTY ?? false; -} +const BLACK_ON_WHITE = "\u001b[47m\u001b[30m"; +const RESET_COLORS = "\u001b[0m"; export async function renderPairingQr(url: string): Promise { - const terminalOptions: QRCodeToStringOptionsTerminal = { - type: "terminal", - small: true, - }; - const utf8Options: QRCodeToStringOptionsOther = { type: "utf8", + margin: 4, }; - try { - return await QRCode.toString(url, terminalOptions); - } catch { - return await QRCode.toString(url, utf8Options); - } -} - -export async function printPairingQrIfEnabled(args: { - url: string; - logger?: Logger; -}): Promise { - if (!shouldPrintPairingQr()) return; - - const qr = await renderPairingQr(args.url); - const out = `\nScan to pair:\n${qr}\n${args.url}\n`; - - try { - process.stdout.write(out); - } catch (error) { - args.logger?.debug({ error }, "Failed to print pairing QR"); - } + const qr = await QRCode.toString(url, utf8Options); + return qr + .split("\n") + .map((line) => `${BLACK_ON_WHITE}${line}${RESET_COLORS}`) + .join("\n"); } From 7e97ab4a9cd353b8fe4d1f443f19ed672bc0734a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 13:24:23 +0200 Subject: [PATCH 055/420] chore(app): refresh ACP provider catalog --- packages/app/src/data/acp-provider-catalog.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 2ec3fd3269d..eab70de7012 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -159,10 +159,10 @@ const CATALOG_DATA = [ id: "factory-droid", title: "Factory Droid", description: "Factory Droid - AI coding agent powered by Factory AI", - version: "0.178.0", + version: "0.179.0", iconId: "factory-droid", installLink: "https://factory.ai/product/cli", - command: ["npx", "-y", "droid@0.178.0", "exec", "--output-format", "acp-daemon"], + command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"], env: { DROID_DISABLE_AUTO_UPDATE: "true", FACTORY_DROID_AUTO_UPDATE_ENABLED: "false", From fbbdbdc571064de4fa0a51e307d896dc1df2faa5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 13:24:33 +0200 Subject: [PATCH 056/420] docs: prepare 0.2.0 changelog --- CHANGELOG.md | 98 ++++++++++++++++------------------------------------ 1 file changed, 30 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11607895260..550ffd6c2f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,109 +1,71 @@ # Changelog -## 0.2.0-beta.4 - 2026-07-23 - -### Improved - -- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361)) -- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim)) - -### Fixed - -- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379)) -- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang)) -- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger)) -- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363)) -- File edits preserve CRLF line endings and UTF-8 BOMs ([#2277](https://github.com/getpaseo/paseo/pull/2277) by [@dwyanewang](https://github.com/dwyanewang)) -- Oh My Pi custom messages marked hidden stay hidden in live and restored chats ([#2280](https://github.com/getpaseo/paseo/pull/2280) by [@isac322](https://github.com/isac322)) -- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368)) -- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369)) - -## 0.2.0-beta.3 - 2026-07-22 +## 0.2.0 - 2026-07-24 ### Added +- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx)) +- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309), [#2277](https://github.com/getpaseo/paseo/pull/2277), [#2382](https://github.com/getpaseo/paseo/pull/2382) by [@dwyanewang](https://github.com/dwyanewang)) +- Oh My Pi (OMP) as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223)) - Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs)) - Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs)) -- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324)) -- Use Oh My Pi's Write Approval mode to allow reads while requiring approval for changes ([#2228](https://github.com/getpaseo/paseo/pull/2228) by [@theslava](https://github.com/theslava)) - -### Improved - -- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter)) -- Oh My Pi advisor results now retain their structure and severity ([#2219](https://github.com/getpaseo/paseo/pull/2219) by [@ebg1223](https://github.com/ebg1223)) - -### Fixed - -- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331)) -- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316)) -- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315)) -- Oh My Pi slash commands now include commands from every supported source ([#2175](https://github.com/getpaseo/paseo/pull/2175) by [@bendavid](https://github.com/bendavid)) -- Oh My Pi chats no longer stay stuck as running after delayed or incomplete completion events ([#2261](https://github.com/getpaseo/paseo/pull/2261), [#2282](https://github.com/getpaseo/paseo/pull/2282) by [@isac322](https://github.com/isac322)) - -## 0.2.0-beta.2 - 2026-07-22 - -### Added - -- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309)) +- Browse workspace commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146), [#2312](https://github.com/getpaseo/paseo/pull/2312) by [@adradr](https://github.com/adradr)) - Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu)) +- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324)) - Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger)) - Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160)) - Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257)) -- Use Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue)) +- Allow Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue)) +- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119)) +- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951)) ### Improved -- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#2186](https://github.com/getpaseo/paseo/pull/2186)) -- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290)) +- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361)) +- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim)) - Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263)) +- CLI and MCP tools manage workspaces, agents, and schedules more consistently ([#2186](https://github.com/getpaseo/paseo/pull/2186)) +- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290)) - Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187)) - Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209)) - New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213)) -- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn)) -- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312)) - Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201)) +- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter)) +- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151)) +- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149)) +- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120)) +- Agent history errors now appear immediately instead of after a timeout ([#2124](https://github.com/getpaseo/paseo/pull/2124)) ### Fixed +- Terminal pairing QR codes remain scannable in narrow terminals ([#2381](https://github.com/getpaseo/paseo/pull/2381)) +- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379)) +- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang)) +- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger)) +- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363)) +- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368)) +- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369)) +- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331)) +- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316)) +- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315)) - Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx)) - Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd)) - Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313)) - OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258)) - Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214)) +- Oh My Pi background notices appear as task notifications instead of raw system text ([#2218](https://github.com/getpaseo/paseo/pull/2218) by [@ebg1223](https://github.com/ebg1223)) - Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991)) - The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194)) - Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268)) - Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago)) - Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang)) - Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210)) -- Session imports find older matches from the current workspace ([#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs)) - -## 0.2.0-beta.1 - 2026-07-17 - -### Added - -- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx)) -- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223)) -- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr)) -- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119)) -- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951)) - -### Improved - -- ACP provider catalog updated to the latest registry versions -- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151)) -- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149)) -- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120)) -- Agent history errors now appear immediately instead of after a timeout ([#2124](https://github.com/getpaseo/paseo/pull/2124)) - -### Fixed - - Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter)) - Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137)) - Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982)) - Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156)) - Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002)) -- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123)) +- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123), [#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs)) - Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd)) ## 0.1.110 - 2026-07-16 From d98c5e77f77fbf386553eeeaf85177a3d374ef90 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 13:27:13 +0200 Subject: [PATCH 057/420] chore(release): cut 0.2.0 --- package-lock.json | 42 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++--- packages/client/package.json | 6 ++-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 +++--- packages/website/package.json | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ae4a230dbb..2bba6a57684 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.2.0-beta.4", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.2.0-beta.4", + "version": "0.2.0", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35211,7 +35211,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.2.0-beta.4", + "version": "0.2.0", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36236,12 +36236,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.2.0-beta.4", + "version": "0.2.0", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.0-beta.4", - "@getpaseo/protocol": "0.2.0-beta.4", - "@getpaseo/server": "0.2.0-beta.4", + "@getpaseo/client": "0.2.0", + "@getpaseo/protocol": "0.2.0", + "@getpaseo/server": "0.2.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36487,10 +36487,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.2.0-beta.4", + "version": "0.2.0", "dependencies": { - "@getpaseo/protocol": "0.2.0-beta.4", - "@getpaseo/relay": "0.2.0-beta.4", + "@getpaseo/protocol": "0.2.0", + "@getpaseo/relay": "0.2.0", "zod": "^4.4.3" }, "devDependencies": { @@ -36501,7 +36501,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.2.0-beta.4", + "version": "0.2.0", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36744,7 +36744,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.0-beta.4", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37640,7 +37640,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.2.0-beta.4", + "version": "0.2.0", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -37872,7 +37872,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.2.0-beta.4", + "version": "0.2.0", "dependencies": { "zod": "^4.4.3" }, @@ -37885,7 +37885,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.2.0-beta.4", + "version": "0.2.0", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38103,15 +38103,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.2.0-beta.4", + "version": "0.2.0", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.0-beta.4", - "@getpaseo/highlight": "0.2.0-beta.4", - "@getpaseo/protocol": "0.2.0-beta.4", - "@getpaseo/relay": "0.2.0-beta.4", + "@getpaseo/client": "0.2.0", + "@getpaseo/highlight": "0.2.0", + "@getpaseo/protocol": "0.2.0", + "@getpaseo/relay": "0.2.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38648,7 +38648,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.2.0-beta.4", + "version": "0.2.0", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 8c8ca4bf2bc..17d451895fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.2.0-beta.4", + "version": "0.2.0", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 6abe2017d62..26875c61cce 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.2.0-beta.4", + "version": "0.2.0", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 8353e3b18f8..bc0333db213 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.2.0-beta.4", + "version": "0.2.0", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -27,9 +27,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.0-beta.4", - "@getpaseo/protocol": "0.2.0-beta.4", - "@getpaseo/server": "0.2.0-beta.4", + "@getpaseo/client": "0.2.0", + "@getpaseo/protocol": "0.2.0", + "@getpaseo/server": "0.2.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 13c7837745c..769bb447f30 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.2.0-beta.4", + "version": "0.2.0", "description": "Paseo client SDK package", "files": [ "dist", @@ -35,8 +35,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.2.0-beta.4", - "@getpaseo/relay": "0.2.0-beta.4", + "@getpaseo/protocol": "0.2.0", + "@getpaseo/relay": "0.2.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 076334a168d..d8d27190bc7 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.2.0-beta.4", + "version": "0.2.0", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 127e9c95a0a..d48a2f982a0 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.0-beta.4", + "version": "0.2.0", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index b7f4b5db742..601fe88d4cb 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.2.0-beta.4", + "version": "0.2.0", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index fc0342a1bb1..80275143fd0 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.2.0-beta.4", + "version": "0.2.0", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index a2a80ee22e7..6af768b682e 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.2.0-beta.4", + "version": "0.2.0", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index ffa1549fd89..38eb24a37a9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.2.0-beta.4", + "version": "0.2.0", "description": "Paseo backend server", "files": [ "dist/server", @@ -67,10 +67,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.0-beta.4", - "@getpaseo/highlight": "0.2.0-beta.4", - "@getpaseo/protocol": "0.2.0-beta.4", - "@getpaseo/relay": "0.2.0-beta.4", + "@getpaseo/client": "0.2.0", + "@getpaseo/highlight": "0.2.0", + "@getpaseo/protocol": "0.2.0", + "@getpaseo/relay": "0.2.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 49338979c5e..8835ebb24b6 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.2.0-beta.4", + "version": "0.2.0", "private": true, "type": "module", "scripts": { From f4136c6d3e86f96612266e098459bac8c23391e8 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:36:07 +0000 Subject: [PATCH 058/420] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 727c448a687..4f8925dbd1a 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-t7FqvPj7U5YidGH9OZ1kFnD2dPTYUyjcxBlzAJU7Mak= +sha256-kI8Qk19ztHbfI5zNP667R4JYqT9oiY1gLyTffVxSnz8= From 517083396101d94bb26c5ecdd910076afa9826cd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 14:17:29 +0200 Subject: [PATCH 059/420] fix(providers): keep Pi on the native integration (#2392) --- packages/app/src/assets/acp-provider-icons.ts | 2 -- packages/app/src/hooks/use-acp-provider-catalog.test.ts | 4 ++++ public-docs/supported-providers.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts index dc0462777ae..c31f51d46e1 100644 --- a/packages/app/src/assets/acp-provider-icons.ts +++ b/packages/app/src/assets/acp-provider-icons.ts @@ -58,8 +58,6 @@ export const ACP_PROVIDER_ICON_SVGS = { nova: '\n \n \n \n \n \n \n \n \n\n', opencode: '\n\n\n', - "pi-acp": - '\n\n\n\n', poolside: '\n\n\n', qoder: diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index 5128b953351..87e6d3c8669 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -34,6 +34,10 @@ describe("ACP provider catalog", () => { } }); + it("does not offer Pi's unsupported ACP adapter", () => { + expect(ACP_PROVIDER_CATALOG.some((entry) => entry.id === "pi-acp")).toBe(false); + }); + it("uses PATH commands for entries that were binary distributions upstream", () => { expect(findProvider("amp-acp").command).toEqual(["amp-acp"]); expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]); diff --git a/public-docs/supported-providers.md b/public-docs/supported-providers.md index 22190bb7584..d7aa848e62e 100644 --- a/public-docs/supported-providers.md +++ b/public-docs/supported-providers.md @@ -17,7 +17,7 @@ Work out of the box once the underlying CLI is installed and authenticated. - [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Anthropic's coding agent with MCP support, streaming, and deep reasoning. - [Codex](/docs/codex). OpenAI's workspace agent with sandbox controls and optional network access. - [OpenCode](https://opencode.ai/). Open-source coding assistant with multi-provider model support. -- [pi](https://github.com/svkozak/pi-acp). Minimal terminal-based coding agent with multi-provider LLM support. +- [Pi](https://pi.dev). Minimal terminal-based coding agent with multi-provider LLM support. ## ACP catalog From b218267c3cec718c13072cc4b831ac402939d946 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 14:21:44 +0200 Subject: [PATCH 060/420] Give Android store builds more memory Use a large EAS worker for production Android store builds so native ABI compilation cannot OOM-kill Hermes. --- packages/app/eas.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app/eas.json b/packages/app/eas.json index 1550f441f2b..80021f9346b 100644 --- a/packages/app/eas.json +++ b/packages/app/eas.json @@ -19,6 +19,9 @@ "channel": "production", "env": { "APP_VARIANT": "production" + }, + "android": { + "resourceClass": "large" } }, "production-apk": { From 967edab49703913daa983f7e94aeff044bc9cfa6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 15:10:21 +0200 Subject: [PATCH 061/420] Connect daemons to Hub through browser approval (#2208) * feat(cli): connect daemons to Hub through browser approval Let interactive setup complete through a short-lived browser authorization while preserving --token for automation and existing integrations. * ci: build server before CLI tests * fix(cli): keep Hub approval polling within expiry Bound both response headers and body consumption to the original authorization lifetime so stalled responses remain retryable without extending approval. Run the CLI test helper through Node and the resolved tsx entry for cross-platform behavior. * fix(cli): bound Hub registration startup Fail a stalled authorization start within a fixed timeout without retrying the non-idempotent request. Declare the response validator as a direct CLI runtime dependency. * fix(cli): restrict Hub activation URLs * fix(cli): harden Hub approval transport --- .github/workflows/ci.yml | 3 + package-lock.json | 3 +- packages/cli/package.json | 6 +- .../hub/cloud-device-authorization.test.ts | 202 +++++++++++++ .../hub/cloud-device-authorization.ts | 108 +++++++ .../commands/hub/device-authorization.test.ts | 273 ++++++++++++++++++ .../src/commands/hub/device-authorization.ts | 118 ++++++++ packages/cli/src/commands/hub/index.ts | 68 ++++- packages/cli/tests/helpers/test-daemon.ts | 32 +- 9 files changed, 787 insertions(+), 26 deletions(-) create mode 100644 packages/cli/src/commands/hub/cloud-device-authorization.test.ts create mode 100644 packages/cli/src/commands/hub/cloud-device-authorization.ts create mode 100644 packages/cli/src/commands/hub/device-authorization.test.ts create mode 100644 packages/cli/src/commands/hub/device-authorization.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bebab954a78..888b55ba629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -349,6 +349,9 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci + - name: Build server stack + run: npm run build:server + - name: Install agent CLIs for provider tests run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai diff --git a/package-lock.json b/package-lock.json index 2bba6a57684..6357980ff7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36247,7 +36247,8 @@ "mime-types": "^2.1.35", "tree-kill": "^1.2.2", "ws": "^8.14.2", - "yaml": "^2.8.4" + "yaml": "^2.8.4", + "zod": "^4.4.3" }, "bin": { "paseo": "bin/paseo" diff --git a/packages/cli/package.json b/packages/cli/package.json index bc0333db213..15fba8ca5cb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,8 @@ "build:clean": "npm run clean && npm run build", "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", - "test": "npm run test:local", + "test": "npm run test:unit && npm run test:local", + "test:unit": "vitest run src", "test:local": "tsx tests/run-all.ts", "test:e2e": "npm run test:local", "test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts" @@ -35,7 +36,8 @@ "mime-types": "^2.1.35", "tree-kill": "^1.2.2", "ws": "^8.14.2", - "yaml": "^2.8.4" + "yaml": "^2.8.4", + "zod": "^4.4.3" }, "devDependencies": { "@types/mime-types": "^3.0.1", diff --git a/packages/cli/src/commands/hub/cloud-device-authorization.test.ts b/packages/cli/src/commands/hub/cloud-device-authorization.test.ts new file mode 100644 index 00000000000..4a4145223ad --- /dev/null +++ b/packages/cli/src/commands/hub/cloud-device-authorization.test.ts @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { describe, it } from "vitest"; +import { CloudDeviceAuthorizationClient } from "./cloud-device-authorization.js"; + +describe("Cloud device authorization", () => { + it("accepts loopback HTTP activation URLs", async () => { + const cloud = await RegistrationCloud.start("loopback-authorization"); + try { + const authorization = await new CloudDeviceAuthorizationClient().start( + cloud.origin, + "Studio Mac", + ); + + assert.equal(authorization.verificationUri, `${cloud.origin}/activate`); + assert.equal( + authorization.verificationUriComplete, + `${cloud.origin}/activate?code=ABCD-EFGH-JKLMN`, + ); + } finally { + await cloud.stop(); + } + }); + + it("rejects a non-web activation URL at the Cloud boundary", async () => { + const cloud = await RegistrationCloud.start("non-web-authorization"); + try { + await assert.rejects(new CloudDeviceAuthorizationClient().start(cloud.origin, "Studio Mac"), { + name: "ZodError", + }); + assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/"]); + } finally { + await cloud.stop(); + } + }); + + it("fails when start headers arrive but the response body stalls", async () => { + const cloud = await RegistrationCloud.start("stalled-start-body"); + try { + await assert.rejects( + new CloudDeviceAuthorizationClient(100).start(cloud.origin, "Studio Mac"), + { message: "Cloud registration start timed out" }, + ); + assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/"]); + } finally { + await cloud.stop(); + } + }); + + it("retries when poll headers arrive but the response body stalls", async () => { + const cloud = await RegistrationCloud.start("stalled-poll-body"); + try { + const outcome = await new CloudDeviceAuthorizationClient().poll( + cloud.origin, + "device-code-with-more-than-thirty-two-characters", + 100, + ); + + assert.deepEqual(outcome, { status: "retry_later" }); + assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/poll"]); + } finally { + await cloud.stop(); + } + }); + + it("retries when a poll response body resets after headers arrive", async () => { + const cloud = await RegistrationCloud.start("reset-poll-body"); + try { + const outcome = await new CloudDeviceAuthorizationClient().poll( + cloud.origin, + "device-code-with-more-than-thirty-two-characters", + 1_000, + ); + + assert.deepEqual(outcome, { status: "retry_later" }); + assert.deepEqual(cloud.receivedPaths, ["/api/device-authorizations/poll"]); + } finally { + await cloud.stop(); + } + }); + + it("rejects a completed malformed poll response", async () => { + const cloud = await RegistrationCloud.start("malformed-poll-body"); + try { + await assert.rejects( + new CloudDeviceAuthorizationClient().poll( + cloud.origin, + "device-code-with-more-than-thirty-two-characters", + 1_000, + ), + { name: "SyntaxError" }, + ); + } finally { + await cloud.stop(); + } + }); + + it("rejects a completed poll response with an invalid shape", async () => { + const cloud = await RegistrationCloud.start("invalid-poll-body"); + try { + await assert.rejects( + new CloudDeviceAuthorizationClient().poll( + cloud.origin, + "device-code-with-more-than-thirty-two-characters", + 1_000, + ), + { name: "ZodError" }, + ); + } finally { + await cloud.stop(); + } + }); +}); + +type RegistrationCloudResponse = + | "loopback-authorization" + | "non-web-authorization" + | "stalled-start-body" + | "stalled-poll-body" + | "reset-poll-body" + | "malformed-poll-body" + | "invalid-poll-body"; + +class RegistrationCloud { + readonly receivedPaths: string[] = []; + + private constructor( + readonly origin: string, + private readonly server: Server, + ) {} + + static async start(responseBody: RegistrationCloudResponse): Promise { + let cloud: RegistrationCloud; + const server = createServer((request, response) => { + if (request.url !== undefined) cloud.receivedPaths.push(request.url); + response.writeHead(200, { "content-type": "application/json" }); + if (responseBody === "malformed-poll-body") { + response.end("not-json"); + return; + } + if (responseBody === "invalid-poll-body") { + response.end('{"status":"pending"}'); + return; + } + if (responseBody === "non-web-authorization") { + response.end( + JSON.stringify({ + deviceCode: "device-code-with-more-than-thirty-two-characters", + userCode: "ABCD-EFGH-JKLMN", + verificationUri: "https://cloud.paseo.test/activate", + verificationUriComplete: "file:///tmp/paseo-activate", + expiresAt: "2026-07-18T12:10:00.000Z", + interval: 5, + }), + ); + return; + } + if (responseBody === "loopback-authorization") { + response.end( + JSON.stringify({ + deviceCode: "device-code-with-more-than-thirty-two-characters", + userCode: "ABCD-EFGH-JKLMN", + verificationUri: `${cloud.origin}/activate`, + verificationUriComplete: `${cloud.origin}/activate?code=ABCD-EFGH-JKLMN`, + expiresAt: "2026-07-18T12:10:00.000Z", + interval: 5, + }), + ); + return; + } + if (responseBody === "stalled-start-body") { + response.write('{"deviceCode":"device-code-with-more-than-thirty-two-characters"'); + return; + } + if (responseBody === "reset-poll-body") { + response.flushHeaders(); + response.write('{"status":"pending"'); + setImmediate(() => response.socket?.destroy()); + return; + } + response.write('{"status":"pending","interval":5'); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + cloud = new RegistrationCloud(`http://127.0.0.1:${address.port}`, server); + return cloud; + } + + async stop(): Promise { + this.server.closeAllConnections(); + await new Promise((resolve, reject) => { + this.server.close((error) => { + if (error !== undefined) { + reject(error); + return; + } + resolve(); + }); + }); + } +} diff --git a/packages/cli/src/commands/hub/cloud-device-authorization.ts b/packages/cli/src/commands/hub/cloud-device-authorization.ts new file mode 100644 index 00000000000..9bf832619db --- /dev/null +++ b/packages/cli/src/commands/hub/cloud-device-authorization.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; + +const START_TIMEOUT_MS = 15_000; +const activationUrlSchema = z.url({ protocol: /^https?$/u }); + +const authorizationSchema = z.object({ + deviceCode: z.string().min(32), + userCode: z.string().min(1), + verificationUri: activationUrlSchema, + verificationUriComplete: activationUrlSchema, + expiresAt: z.string().datetime(), + interval: z.number().int().min(5), +}); + +const pollSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("pending"), interval: z.number().int().min(5) }), + z.object({ status: z.literal("slow_down"), interval: z.number().int().min(5) }), + z.object({ + status: z.literal("approved"), + interval: z.number().int().min(5), + enrollmentToken: z.string().min(32), + }), + z.object({ status: z.literal("denied"), interval: z.number().int().min(5) }), + z.object({ status: z.literal("expired"), interval: z.number().int().min(5) }), + z.object({ status: z.literal("enrolled"), interval: z.number().int().min(5) }), + z.object({ status: z.literal("retry_later") }), +]); + +export type DeviceAuthorization = z.infer; +export type DeviceAuthorizationPoll = z.infer; + +export interface CloudDeviceAuthorization { + start(hubUrl: string, displayName: string): Promise; + poll( + hubUrl: string, + deviceCode: string, + timeoutMilliseconds: number, + ): Promise; +} + +export class CloudDeviceAuthorizationClient implements CloudDeviceAuthorization { + constructor(private readonly startTimeoutMilliseconds = START_TIMEOUT_MS) {} + + async start(hubUrl: string, displayName: string): Promise { + const signal = AbortSignal.timeout(this.startTimeoutMilliseconds); + try { + const response = await fetch(endpoint(hubUrl, "/api/device-authorizations/"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ displayName }), + signal, + }); + if (!response.ok) throw new Error(`Cloud registration failed (${response.status})`); + return authorizationSchema.parse(await response.json()); + } catch (error) { + if (signal.aborted) { + throw new Error("Cloud registration start timed out", { cause: error }); + } + throw error; + } + } + + async poll( + hubUrl: string, + deviceCode: string, + timeoutMilliseconds: number, + ): Promise { + const signal = AbortSignal.timeout(timeoutMilliseconds); + let response: Response; + try { + response = await fetch(endpoint(hubUrl, "/api/device-authorizations/poll"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceCode }), + signal, + }); + } catch { + return { status: "retry_later" }; + } + if ([408, 425, 429].includes(response.status) || response.status >= 500) { + return { status: "retry_later" }; + } + if (!response.ok) throw new Error(`Cloud registration poll failed (${response.status})`); + let body: unknown; + try { + body = await response.json(); + } catch (error) { + if (signal.aborted || error instanceof TypeError) return { status: "retry_later" }; + throw error; + } + return pollSchema.parse(body); + } +} + +function endpoint(hubUrl: string, pathname: string): string { + const url = new URL(hubUrl); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error("Hub URL must be an HTTP or HTTPS origin without credentials or a query"); + } + url.pathname = `${url.pathname.replace(/\/$/u, "")}${pathname}`; + return url.toString(); +} diff --git a/packages/cli/src/commands/hub/device-authorization.test.ts b/packages/cli/src/commands/hub/device-authorization.test.ts new file mode 100644 index 00000000000..a7f1e63a220 --- /dev/null +++ b/packages/cli/src/commands/hub/device-authorization.test.ts @@ -0,0 +1,273 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { DeviceAuthorizationWorkflow, SystemBrowser } from "./device-authorization.js"; +import type { + CloudDeviceAuthorization, + DeviceAuthorizationPoll, +} from "./cloud-device-authorization.js"; +import { createHubCommand } from "./index.js"; + +describe("Hub device authorization", () => { + it("opens activation URLs on Windows without a command shell", async () => { + const launches: Array<{ command: string; args: string[] }> = []; + const browser = new SystemBrowser({ + hostPlatform: "win32", + launch: async (command, args) => void launches.push({ command, args }), + }); + + await browser.open("https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN"); + + assert.deepEqual(launches, [ + { + command: "rundll32.exe", + args: [ + "url.dll,FileProtocolHandler", + "https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN", + ], + }, + ]); + }); + + it("opens the browser, follows Cloud cadence, and returns the approved enrollment token", async () => { + const cloud = new FakeCloud([ + { status: "pending", interval: 5 }, + { status: "slow_down", interval: 10 }, + { status: "approved", interval: 10, enrollmentToken: "approved-enrollment-token-1234567890" }, + ]); + const authorization = new AuthorizationJourney(cloud); + + const token = await authorization.approve("https://cloud.paseo.test", "Studio Mac"); + + assert.equal(token, "approved-enrollment-token-1234567890"); + assert.deepEqual(authorization.observed(), { + starts: [{ hubUrl: "https://cloud.paseo.test", displayName: "Studio Mac" }], + polls: [ + { + hubUrl: "https://cloud.paseo.test", + deviceCode: "device-code-with-more-than-thirty-two-characters", + timeoutMilliseconds: 595_000, + }, + { + hubUrl: "https://cloud.paseo.test", + deviceCode: "device-code-with-more-than-thirty-two-characters", + timeoutMilliseconds: 590_000, + }, + { + hubUrl: "https://cloud.paseo.test", + deviceCode: "device-code-with-more-than-thirty-two-characters", + timeoutMilliseconds: 580_000, + }, + ], + waits: [5_000, 5_000, 10_000], + opened: ["https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN"], + instructions: ["https://cloud.paseo.test/activate ABCD-EFGH-JKLMN"], + }); + }); + + it("stops without an enrollment token when the browser denies the request", async () => { + const authorization = new AuthorizationJourney( + new FakeCloud([{ status: "denied", interval: 5 }]), + ); + + await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), { + message: "Daemon registration was denied", + }); + }); + + it("recovers the approved authority after its first poll response is lost", async () => { + const authorization = new AuthorizationJourney( + new FakeCloud([ + { status: "retry_later" }, + { + status: "approved", + interval: 5, + enrollmentToken: "stable-enrollment-token-after-response-loss", + }, + ]), + ); + + const token = await authorization.approve("https://cloud.paseo.test", "Studio Mac"); + + assert.equal(token, "stable-enrollment-token-after-response-loss"); + assert.deepEqual(authorization.observed().waits, [5_000, 5_000]); + }); + + it("retries timeout failures only while the fixed authorization expiry remains", async () => { + const authorization = new AuthorizationJourney( + new FakeCloud( + [{ status: "retry_later" }, { status: "retry_later" }], + "2026-07-18T12:00:11.000Z", + ), + ); + + await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), { + message: "Daemon registration expired", + }); + assert.deepEqual(authorization.observed().waits, [5_000, 5_000, 1_000]); + assert.deepEqual( + authorization.observed().polls.map(({ timeoutMilliseconds }) => timeoutMilliseconds), + [6_000, 1_000], + ); + }); + + it("stops without an enrollment token when the request expires", async () => { + const authorization = new AuthorizationJourney( + new FakeCloud([{ status: "expired", interval: 5 }]), + ); + + await assert.rejects(authorization.approve("https://cloud.paseo.test", "Studio Mac"), { + message: "Daemon registration expired", + }); + }); + + it("connects the real hub command with a browser-approved token", async () => { + const daemon = new FakeDaemon(); + + await createHubCommand({ + connect: async () => daemon, + authorize: async (url, displayName) => { + assert.equal(url, "https://cloud.paseo.test"); + assert.equal(displayName, "Studio Mac"); + return "approved-enrollment-token-1234567890"; + }, + displayName: () => "Studio Mac", + }).parseAsync(["node", "paseo hub", "connect", "https://cloud.paseo.test", "--json"], { + from: "node", + }); + + assert.deepEqual(daemon.connections, [ + { + url: "https://cloud.paseo.test", + token: "approved-enrollment-token-1234567890", + }, + ]); + assert.equal(daemon.closed, true); + }); + + it("bounds the default daemon name before starting browser authorization", async () => { + const daemon = new FakeDaemon(); + const names: string[] = []; + + await createHubCommand({ + connect: async () => daemon, + authorize: async (_url, displayName) => { + names.push(displayName); + return "approved-enrollment-token-1234567890"; + }, + displayName: () => ` ${"very-long-hostname".repeat(10)} `, + }).parseAsync(["node", "paseo hub", "connect", "https://cloud.paseo.test", "--json"], { + from: "node", + }); + + assert.deepEqual(names, ["very-long-hostname".repeat(10).slice(0, 100)]); + }); +}); + +class AuthorizationJourney { + private now = Date.parse("2026-07-18T12:00:00.000Z"); + private readonly waits: number[] = []; + private readonly opened: string[] = []; + private readonly instructions: string[] = []; + private readonly workflow: DeviceAuthorizationWorkflow; + + constructor(private readonly cloud: FakeCloud) { + this.workflow = new DeviceAuthorizationWorkflow({ + cloud, + waiter: { + wait: async (milliseconds) => { + this.waits.push(milliseconds); + this.now += milliseconds; + }, + now: () => this.now, + }, + browser: { open: async (url) => void this.opened.push(url) }, + reporter: { + instructions: (url, code) => void this.instructions.push(`${url} ${code}`), + }, + }); + } + + approve(hubUrl: string, displayName: string): Promise { + return this.workflow.authorize(hubUrl, displayName); + } + + observed() { + return { + starts: this.cloud.starts, + polls: this.cloud.polls, + waits: this.waits, + opened: this.opened, + instructions: this.instructions, + }; + } +} + +class FakeCloud implements CloudDeviceAuthorization { + readonly starts: Array<{ hubUrl: string; displayName: string }> = []; + readonly polls: Array<{ + hubUrl: string; + deviceCode: string; + timeoutMilliseconds: number; + }> = []; + + constructor( + private readonly outcomes: DeviceAuthorizationPoll[], + private readonly expiresAt = "2026-07-18T12:10:00.000Z", + ) {} + + async start(hubUrl: string, displayName: string) { + this.starts.push({ hubUrl, displayName }); + return { + deviceCode: "device-code-with-more-than-thirty-two-characters", + userCode: "ABCD-EFGH-JKLMN", + verificationUri: "https://cloud.paseo.test/activate", + verificationUriComplete: "https://cloud.paseo.test/activate?code=ABCD-EFGH-JKLMN", + expiresAt: this.expiresAt, + interval: 5, + }; + } + + async poll( + hubUrl: string, + deviceCode: string, + timeoutMilliseconds: number, + ): Promise { + this.polls.push({ hubUrl, deviceCode, timeoutMilliseconds }); + const outcome = this.outcomes[this.polls.length - 1]; + if (outcome === undefined) throw new Error("No Cloud poll outcome remains"); + return outcome; + } +} + +class FakeDaemon { + readonly connections: Array<{ url: string; token: string }> = []; + closed = false; + + async getHubStatus() { + return { status: hubStatus("not_connected") }; + } + + async connectHub(url: string, token: string) { + this.connections.push({ url, token }); + return { status: hubStatus("connected") }; + } + + async disconnectHub() { + return { status: hubStatus("not_connected") }; + } + + async close() { + this.closed = true; + } +} + +function hubStatus(state: string) { + return { + state, + daemonId: state === "connected" ? "daemon-1" : null, + hubOrigin: state === "connected" ? "https://cloud.paseo.test" : null, + scopes: state === "connected" ? ["hub.execution.*"] : [], + connectedAt: null, + lastError: null, + }; +} diff --git a/packages/cli/src/commands/hub/device-authorization.ts b/packages/cli/src/commands/hub/device-authorization.ts new file mode 100644 index 00000000000..22f7702a482 --- /dev/null +++ b/packages/cli/src/commands/hub/device-authorization.ts @@ -0,0 +1,118 @@ +import { spawn } from "node:child_process"; +import { platform } from "node:os"; +import { + CloudDeviceAuthorizationClient, + type CloudDeviceAuthorization, +} from "./cloud-device-authorization.js"; + +export interface AuthorizationWaiter { + wait(milliseconds: number): Promise; + now(): number; +} + +export interface BrowserOpener { + open(url: string): Promise; +} + +type BrowserLaunch = (command: string, args: string[]) => Promise; + +interface SystemBrowserOptions { + hostPlatform?: NodeJS.Platform; + launch?: BrowserLaunch; +} + +export class SystemBrowser implements BrowserOpener { + private readonly hostPlatform: NodeJS.Platform; + private readonly launch: BrowserLaunch; + + constructor(options: SystemBrowserOptions = {}) { + this.hostPlatform = options.hostPlatform ?? platform(); + this.launch = options.launch ?? launchDetached; + } + + async open(url: string): Promise { + if (this.hostPlatform === "win32") { + await this.launch("rundll32.exe", ["url.dll,FileProtocolHandler", url]); + return; + } + + await this.launch(this.hostPlatform === "darwin" ? "open" : "xdg-open", [url]); + } +} + +export interface AuthorizationReporter { + instructions(verificationUri: string, userCode: string): void; +} + +interface DeviceAuthorizationWorkflowOptions { + cloud: CloudDeviceAuthorization; + waiter: AuthorizationWaiter; + browser: BrowserOpener; + reporter: AuthorizationReporter; + openBrowser?: boolean; +} + +export class DeviceAuthorizationWorkflow { + constructor(private readonly options: DeviceAuthorizationWorkflowOptions) {} + + async authorize(hubUrl: string, displayName: string): Promise { + const authorization = await this.options.cloud.start(hubUrl, displayName); + this.options.reporter.instructions(authorization.verificationUri, authorization.userCode); + if (this.options.openBrowser !== false) { + await this.options.browser.open(authorization.verificationUriComplete).catch(() => undefined); + } + + let interval = authorization.interval; + const expiresAt = Date.parse(authorization.expiresAt); + while (true) { + const remaining = expiresAt - this.options.waiter.now(); + if (remaining <= 0) throw new Error("Daemon registration expired"); + await this.options.waiter.wait(Math.min(interval * 1_000, remaining)); + if (this.options.waiter.now() >= expiresAt) throw new Error("Daemon registration expired"); + const pollLifetime = expiresAt - this.options.waiter.now(); + if (pollLifetime <= 0) throw new Error("Daemon registration expired"); + const outcome = await this.options.cloud.poll(hubUrl, authorization.deviceCode, pollLifetime); + if (this.options.waiter.now() >= expiresAt) throw new Error("Daemon registration expired"); + if (outcome.status === "retry_later") continue; + interval = outcome.interval; + if (outcome.status === "approved") return outcome.enrollmentToken; + if (outcome.status === "denied") throw new Error("Daemon registration was denied"); + if (outcome.status === "expired") throw new Error("Daemon registration expired"); + if (outcome.status === "enrolled") { + throw new Error("Daemon registration was already used"); + } + } + } +} + +export function createDeviceAuthorizationWorkflow(): DeviceAuthorizationWorkflow { + return new DeviceAuthorizationWorkflow({ + cloud: new CloudDeviceAuthorizationClient(), + waiter: { + wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + now: Date.now, + }, + browser: new SystemBrowser(), + reporter: { + instructions(verificationUri, userCode) { + process.stderr.write(`Open ${verificationUri} and enter code ${userCode}\n`); + }, + }, + openBrowser: process.stderr.isTTY === true, + }); +} + +async function launchDetached(command: string, args: string[]): Promise { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + shell: false, + stdio: "ignore", + }); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + child.once("error", reject); + }); +} diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index fce9f1c65bf..813181b6279 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -1,7 +1,37 @@ import { Command } from "commander"; +import { hostname } from "node:os"; import { withOutput, type ListResult, type OutputSchema } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { connectToDaemon } from "../../utils/client.js"; +import { createDeviceAuthorizationWorkflow } from "./device-authorization.js"; + +interface HubCommandClient { + connectHub(url: string, token: string): Promise<{ status: HubStatus }>; + getHubStatus(): Promise<{ status: HubStatus }>; + disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>; + close(): Promise; +} + +interface HubStatus { + state: string; + daemonId: string | null; + hubOrigin: string | null; + scopes: string[]; + connectedAt: string | null; + lastError: string | null; +} + +interface HubCommandEnvironment { + connect(host: string | undefined): Promise; + authorize(url: string, displayName: string): Promise; + displayName(): string; +} + +const productionEnvironment: HubCommandEnvironment = { + connect: (host) => connectToDaemon({ host }), + authorize: (url, displayName) => createDeviceAuthorizationWorkflow().authorize(url, displayName), + displayName: hostname, +}; interface HubRow { state: string; @@ -55,10 +85,11 @@ function result( } async function withClient( + environment: HubCommandEnvironment, host: string | undefined, - action: (client: Awaited>) => Promise, + action: (client: HubCommandClient) => Promise, ): Promise { - const client = await connectToDaemon({ host }); + const client = await environment.connect(host); try { return await action(client); } finally { @@ -66,23 +97,36 @@ async function withClient( } } -export function createHubCommand(): Command { +export function createHubCommand( + environment: HubCommandEnvironment = productionEnvironment, +): Command { const hub = new Command("hub").description("Manage this daemon's Paseo Hub relationship"); addJsonAndDaemonHostOptions( - hub.command("connect").argument("").requiredOption("--token "), + hub.command("connect").argument("").option("--token "), ).action( withOutput(async (...args) => { const url = args[0] as string; - const options = args.at(-2) as { token: string; host?: string }; - return withClient(options.host, async (client) => - result((await client.connectHub(url, options.token)).status), - ); + const options = args.at(-2) as { token?: string; host?: string }; + return withClient(environment, options.host, async (client) => { + if (options.token !== undefined) { + return result((await client.connectHub(url, options.token)).status); + } + const existing = (await client.getHubStatus()).status; + if (existing.state !== "not_connected" && existing.state !== "revoked") { + throw new Error("This daemon already has a Hub relationship"); + } + const token = await environment.authorize( + url, + suggestedDisplayName(environment.displayName()), + ); + return result((await client.connectHub(url, token)).status); + }); }), ); addJsonAndDaemonHostOptions(hub.command("status")).action( withOutput(async (...args) => { const options = args.at(-2) as { host?: string }; - return withClient(options.host, async (client) => + return withClient(environment, options.host, async (client) => result((await client.getHubStatus()).status), ); }), @@ -94,7 +138,7 @@ export function createHubCommand(): Command { ).action( withOutput(async (...args) => { const options = args.at(-2) as { host?: string; force?: boolean }; - return withClient(options.host, async (client) => { + return withClient(environment, options.host, async (client) => { const response = await client.disconnectHub(options.force ?? false); return result(response.status, response.warning); }); @@ -102,3 +146,7 @@ export function createHubCommand(): Command { ); return hub; } + +function suggestedDisplayName(value: string): string { + return value.trim().slice(0, 100) || "Paseo daemon"; +} diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 95bbe3791a4..5052ec935a6 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -16,6 +16,7 @@ import { mkdtemp, rm, mkdir } from "fs/promises"; import { existsSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { fileURLToPath } from "url"; import { ChildProcess, spawn } from "child_process"; import { getAvailablePort } from "./network.ts"; @@ -43,6 +44,7 @@ const TEST_DAEMON_ENV_DEFAULTS: Record = { PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0", }; const TEST_DAEMON_HOST = "127.0.0.1"; +const TSX_ENTRY = fileURLToPath(import.meta.resolve("tsx/cli")); const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024; const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt( @@ -233,19 +235,23 @@ export async function startTestDaemon(options?: { const cliSrcPath = join(cliDir, "src", "index.ts"); // Start daemon process using tsx to run TypeScript directly - const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], { - env: { - ...process.env, - ...TEST_DAEMON_ENV_DEFAULTS, - PASEO_HOME: paseoHome, - PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, - // Force no TTY to prevent QR code output - CI: "true", - ...options?.env, + const daemonProcess = spawn( + process.execPath, + [TSX_ENTRY, cliSrcPath, "daemon", "start", "--foreground"], + { + env: { + ...process.env, + ...TEST_DAEMON_ENV_DEFAULTS, + PASEO_HOME: paseoHome, + PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, + // Force no TTY to prevent QR code output + CI: "true", + ...options?.env, + }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", }, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", - }); + ); const stdout = createOutputCapture(); const stderr = createOutputCapture(); @@ -345,7 +351,7 @@ export async function runPaseoCli( const cliSrcPath = join(cliDir, "src", "index.ts"); return new Promise((resolve, reject) => { - const proc = spawn("npx", ["tsx", cliSrcPath, ...args], { + const proc = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, ...args], { env: { ...process.env, ...TEST_DAEMON_ENV_DEFAULTS, From 21597bdc1b6363f90675d9a56beb5b7efa2a418a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 15:11:41 +0200 Subject: [PATCH 062/420] Fix image uploads using the wrong format (#2380) * fix(app): preserve image attachment MIME types Image intake discarded clipboard and desktop path metadata, then guessed JPEG when a later file object had no type. Resolve MIME once at the platform boundary and require it downstream. * fix(app): honor native picker MIME metadata --- .../app/src/attachments/file-types.test.ts | 25 +++++++++ packages/app/src/attachments/file-types.ts | 25 ++++++--- .../file-drop/use-drop-listeners.ts | 13 +++-- packages/app/src/composer/actions.test.ts | 6 ++- packages/app/src/composer/actions.ts | 2 +- .../hooks/image-attachment-picker.native.ts | 4 +- .../src/hooks/image-attachment-picker.test.ts | 45 +++++++++++++--- .../app/src/hooks/image-attachment-picker.ts | 53 +++++++++++++++---- .../src/hooks/picked-image-normalizer.test.ts | 24 +++++++++ .../app/src/hooks/picked-image-normalizer.ts | 10 ++-- .../src/hooks/use-image-attachment-picker.ts | 12 ++--- .../image-attachments-from-files.test.ts | 11 ++-- .../src/utils/image-attachments-from-files.ts | 22 +++++--- 13 files changed, 198 insertions(+), 54 deletions(-) diff --git a/packages/app/src/attachments/file-types.test.ts b/packages/app/src/attachments/file-types.test.ts index a26b57c2205..e3b181d9524 100644 --- a/packages/app/src/attachments/file-types.test.ts +++ b/packages/app/src/attachments/file-types.test.ts @@ -6,6 +6,7 @@ import { isRasterImageMimeType, isRasterImagePath, RASTER_IMAGE_FILE_EXTENSIONS, + resolveRasterImageMimeType, } from "./file-types"; describe("attachment file types", () => { @@ -37,4 +38,28 @@ describe("attachment file types", () => { new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]), ); }); + + it("uses explicit raster MIME metadata before the filename", () => { + expect( + resolveRasterImageMimeType({ mimeType: "image/jpeg", path: "/tmp/screenshot.png" }), + ).toBe("image/jpeg"); + expect( + resolveRasterImageMimeType({ + mimeType: "image/png; charset=binary", + path: "/tmp/screenshot.jpg", + }), + ).toBe("image/png"); + }); + + it("uses the filename only when MIME metadata is absent", () => { + expect(resolveRasterImageMimeType({ mimeType: "", path: "/tmp/screenshot.png" })).toBe( + "image/png", + ); + expect( + resolveRasterImageMimeType({ + mimeType: "application/octet-stream", + path: "/tmp/screenshot.png", + }), + ).toBeNull(); + }); }); diff --git a/packages/app/src/attachments/file-types.ts b/packages/app/src/attachments/file-types.ts index 9fc6f1027fe..628f833e81b 100644 --- a/packages/app/src/attachments/file-types.ts +++ b/packages/app/src/attachments/file-types.ts @@ -41,18 +41,31 @@ export function getRasterImageMimeTypeFromPath(path: string): string | null { return RASTER_IMAGE_MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? null; } +export function resolveRasterImageMimeType(input: { + mimeType?: string | null; + path?: string | null; +}): string | null { + const suppliedMimeType = input.mimeType?.trim(); + if (suppliedMimeType) { + const normalizedMimeType = suppliedMimeType.split(";", 1)[0]?.trim().toLowerCase(); + if (normalizedMimeType === "image/jpg") { + return "image/jpeg"; + } + return normalizedMimeType && RASTER_IMAGE_MIME_TYPES.has(normalizedMimeType) + ? normalizedMimeType + : null; + } + return input.path ? getRasterImageMimeTypeFromPath(input.path) : null; +} + export function isRasterImagePath(path: string): boolean { return getRasterImageMimeTypeFromPath(path) !== null; } export function isRasterImageMimeType(mimeType: string | null | undefined): boolean { - const normalized = mimeType?.split(";", 1)[0]?.trim().toLowerCase(); - return Boolean(normalized && RASTER_IMAGE_MIME_TYPES.has(normalized)); + return resolveRasterImageMimeType({ mimeType }) !== null; } export function isRasterImageFile(file: Pick): boolean { - if (isRasterImageMimeType(file.type)) { - return true; - } - return file.type.trim().length === 0 && isRasterImagePath(file.name); + return resolveRasterImageMimeType({ mimeType: file.type, path: file.name }) !== null; } diff --git a/packages/app/src/components/file-drop/use-drop-listeners.ts b/packages/app/src/components/file-drop/use-drop-listeners.ts index 6262109e71e..947c95d91ed 100644 --- a/packages/app/src/components/file-drop/use-drop-listeners.ts +++ b/packages/app/src/components/file-drop/use-drop-listeners.ts @@ -5,9 +5,9 @@ import type { ImageAttachment } from "@/composer/types"; import { getDesktopHost } from "@/desktop/host"; import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; import { - getRasterImageMimeTypeFromPath, isRasterImageFile, isRasterImagePath, + resolveRasterImageMimeType, } from "@/attachments/file-types"; import { isWeb } from "@/constants/platform"; import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types"; @@ -27,14 +27,21 @@ interface DesktopDragDropEvent { } async function filePathToImageAttachment(path: string): Promise { - const mimeType = getRasterImageMimeTypeFromPath(path) ?? "image/jpeg"; + const mimeType = resolveRasterImageMimeType({ path }); + if (!mimeType) { + throw new Error(`Unsupported image type for '${path}'.`); + } return await persistAttachmentFromFileUri({ uri: path, mimeType }); } async function fileToImageAttachment(file: File): Promise { + const mimeType = resolveRasterImageMimeType({ mimeType: file.type, path: file.name }); + if (!mimeType) { + throw new Error(`Unsupported image type for '${file.name}'.`); + } return await persistAttachmentFromBlob({ blob: file, - mimeType: file.type || "image/jpeg", + mimeType, fileName: file.name, }); } diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 8b577fcd7fa..178fe9eb6a4 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -321,7 +321,11 @@ describe("pickAndPersistImages", () => { const persister = createFakePersister(); const result = await pickAndPersistImages({ pickImages: async () => [ - { source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null }, + { + source: { kind: "file_uri", uri: "/tmp/x.jpg" }, + mimeType: "image/jpeg", + fileName: null, + }, ], persister, }); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index af3d123d780..44fc3174782 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -93,7 +93,7 @@ export async function pickAndPersistImages(input: { return await Promise.all( result.map(async (picked) => { const fileName = picked.fileName ?? null; - const mimeType = picked.mimeType || "image/jpeg"; + const mimeType = picked.mimeType; if (picked.source.kind === "blob") { return await input.persister.persistFromBlob({ blob: picked.source.blob, diff --git a/packages/app/src/hooks/image-attachment-picker.native.ts b/packages/app/src/hooks/image-attachment-picker.native.ts index dfb36c575d7..4c53eb86b1b 100644 --- a/packages/app/src/hooks/image-attachment-picker.native.ts +++ b/packages/app/src/hooks/image-attachment-picker.native.ts @@ -34,6 +34,8 @@ export async function normalizePickedImageAssets( return normalizePickedImageAssetsWith(assets, exportPickedImageAsPng); } -export async function openImagePathsWithDesktopDialog(_dialog?: unknown): Promise { +export async function pickImagesWithDesktopDialog( + _dialog?: unknown, +): Promise { throw new Error("Desktop dialog API is not available on native."); } diff --git a/packages/app/src/hooks/image-attachment-picker.test.ts b/packages/app/src/hooks/image-attachment-picker.test.ts index 821312db2e1..a7255549652 100644 --- a/packages/app/src/hooks/image-attachment-picker.test.ts +++ b/packages/app/src/hooks/image-attachment-picker.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; import type { DesktopDialogBridge, DesktopDialogOpenOptions } from "@/desktop/host"; -import { - normalizePickedImageAssets, - openImagePathsWithDesktopDialog, -} from "./image-attachment-picker"; +import { normalizePickedImageAssets, pickImagesWithDesktopDialog } from "./image-attachment-picker"; function fakeDialogReturning(selection: string | string[] | null): { dialog: DesktopDialogBridge; @@ -42,6 +39,27 @@ describe("image-attachment-picker", () => { expect(result[0]?.mimeType).toBe("image/png"); }); + it("derives the type of a type-less picked File from its name", async () => { + const file = new File(["image"], "picked.png"); + + const result = await normalizePickedImageAssets([ + { + uri: "blob:test", + mimeType: null, + fileName: null, + file, + }, + ]); + + expect(result).toEqual([ + { + source: { kind: "blob", blob: file }, + mimeType: "image/png", + fileName: "picked.png", + }, + ]); + }); + it("keeps filesystem picker results as file uris", async () => { const result = await normalizePickedImageAssets([ { @@ -78,7 +96,7 @@ describe("image-attachment-picker", () => { it("uses the desktop dialog api when available", async () => { const { dialog, recordedOptions } = fakeDialogReturning(["/tmp/one.png", "/tmp/two.jpg"]); - const result = await openImagePathsWithDesktopDialog(dialog); + const result = await pickImagesWithDesktopDialog(dialog); expect(recordedOptions).toHaveLength(1); expect(recordedOptions[0]).toMatchObject({ @@ -86,14 +104,25 @@ describe("image-attachment-picker", () => { directory: false, title: "Attach images", }); - expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]); + expect(result).toEqual([ + { + source: { kind: "file_uri", uri: "/tmp/one.png" }, + mimeType: "image/png", + fileName: "one.png", + }, + { + source: { kind: "file_uri", uri: "/tmp/two.jpg" }, + mimeType: "image/jpeg", + fileName: "two.jpg", + }, + ]); }); it("throws when desktop dialog API is not available", async () => { - await expect(openImagePathsWithDesktopDialog(null)).rejects.toThrow( + await expect(pickImagesWithDesktopDialog(null)).rejects.toThrow( "Desktop dialog API is not available.", ); - await expect(openImagePathsWithDesktopDialog({})).rejects.toThrow( + await expect(pickImagesWithDesktopDialog({})).rejects.toThrow( "Desktop dialog API is not available.", ); }); diff --git a/packages/app/src/hooks/image-attachment-picker.ts b/packages/app/src/hooks/image-attachment-picker.ts index fc13350c79c..c01a6ba8a19 100644 --- a/packages/app/src/hooks/image-attachment-picker.ts +++ b/packages/app/src/hooks/image-attachment-picker.ts @@ -1,5 +1,6 @@ import type { DesktopDialogBridge } from "@/desktop/host"; -import { RASTER_IMAGE_FILE_EXTENSIONS } from "@/attachments/file-types"; +import { RASTER_IMAGE_FILE_EXTENSIONS, resolveRasterImageMimeType } from "@/attachments/file-types"; +import { getFileNameFromPath } from "@/attachments/utils"; import { i18n } from "@/i18n/i18next"; import { isAbsolutePath } from "@/utils/path"; @@ -7,7 +8,7 @@ export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blo export interface PickedImageAttachmentInput { source: PickedImageSource; - mimeType?: string | null; + mimeType: string; fileName?: string | null; } @@ -30,30 +31,52 @@ async function blobFromUri(uri: string): Promise { return await response.blob(); } +function requirePickedImageMimeType(input: { + mimeType?: string | null; + path?: string | null; +}): string { + const mimeType = resolveRasterImageMimeType(input); + if (!mimeType) { + throw new Error(`Unsupported image type for '${input.path ?? "selected image"}'.`); + } + return mimeType; +} + export async function normalizePickedImageAssets( assets: readonly ExpoImagePickerAssetLike[], ): Promise { return await Promise.all( assets.map(async (asset) => { if (asset.file instanceof Blob) { + const fileName = asset.fileName ?? asset.file.name ?? null; return { source: { kind: "blob", blob: asset.file }, - mimeType: asset.mimeType ?? asset.file.type ?? null, - fileName: asset.fileName ?? asset.file.name ?? null, + mimeType: requirePickedImageMimeType({ + mimeType: asset.mimeType || asset.file.type, + path: fileName ?? asset.uri, + }), + fileName, }; } if (shouldTreatAsFileUri(asset.uri)) { return { source: { kind: "file_uri", uri: asset.uri }, - mimeType: asset.mimeType ?? null, + mimeType: requirePickedImageMimeType({ + mimeType: asset.mimeType, + path: asset.fileName ?? asset.uri, + }), fileName: asset.fileName ?? null, }; } + const blob = await blobFromUri(asset.uri); return { - source: { kind: "blob", blob: await blobFromUri(asset.uri) }, - mimeType: asset.mimeType ?? null, + source: { kind: "blob", blob }, + mimeType: requirePickedImageMimeType({ + mimeType: asset.mimeType || blob.type, + path: asset.fileName ?? asset.uri, + }), fileName: asset.fileName ?? null, }; }), @@ -67,9 +90,9 @@ function normalizeDesktopDialogSelection(selection: string | string[] | null): s return Array.isArray(selection) ? selection : [selection]; } -export async function openImagePathsWithDesktopDialog( +export async function pickImagesWithDesktopDialog( dialog: DesktopDialogBridge | null | undefined, -): Promise { +): Promise { const options = { directory: false, multiple: true, @@ -87,5 +110,15 @@ export async function openImagePathsWithDesktopDialog( throw new Error("Desktop dialog API is not available."); } - return normalizeDesktopDialogSelection(await dialogOpen(options)); + return normalizeDesktopDialogSelection(await dialogOpen(options)).map((path) => { + const mimeType = resolveRasterImageMimeType({ path }); + if (!mimeType) { + throw new Error(`Unsupported image type for '${path}'.`); + } + return { + source: { kind: "file_uri" as const, uri: path }, + mimeType, + fileName: getFileNameFromPath(path), + }; + }); } diff --git a/packages/app/src/hooks/picked-image-normalizer.test.ts b/packages/app/src/hooks/picked-image-normalizer.test.ts index a00144af26c..0454cafb3dd 100644 --- a/packages/app/src/hooks/picked-image-normalizer.test.ts +++ b/packages/app/src/hooks/picked-image-normalizer.test.ts @@ -53,6 +53,30 @@ describe("native image attachment picker", () => { expect(recordedUris).toEqual([]); }); + it("uses explicit native MIME metadata before the URI extension", async () => { + const { exportAsPng, recordedUris } = fakeExportAsPng(); + + const result = await normalizePickedImageAssetsWith( + [ + { + uri: "file:///photos/screenshot.jpg", + mimeType: "image/png", + fileName: "screenshot.png", + }, + ], + exportAsPng, + ); + + expect(result).toEqual([ + { + source: { kind: "file_uri", uri: "file:///photos/screenshot.jpg" }, + mimeType: "image/png", + fileName: "screenshot.png", + }, + ]); + expect(recordedUris).toEqual([]); + }); + it("turns a native picked HEIC-like asset into a PNG attachment input", async () => { const { exportAsPng, recordedUris } = fakeExportAsPng(); diff --git a/packages/app/src/hooks/picked-image-normalizer.ts b/packages/app/src/hooks/picked-image-normalizer.ts index a662d10ff4f..dfe1b019f3e 100644 --- a/packages/app/src/hooks/picked-image-normalizer.ts +++ b/packages/app/src/hooks/picked-image-normalizer.ts @@ -2,7 +2,7 @@ export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blo export interface PickedImageAttachmentInput { source: PickedImageSource; - mimeType?: string | null; + mimeType: string; fileName?: string | null; } @@ -62,13 +62,15 @@ function pickedAssetSupportedFormat( asset: ExpoImagePickerAssetLike, ): SupportedPickedImageFormat | null { const uriExtension = extensionFromPath(asset.uri); - if (uriExtension) { - return supportedFormatForExtension(uriExtension); + const uriFormat = supportedFormatForExtension(uriExtension); + if (uriExtension && !uriFormat) { + return null; } return ( + supportedFormatForMimeType(asset.mimeType) ?? supportedFormatForExtension(extensionFromPath(asset.fileName)) ?? - supportedFormatForMimeType(asset.mimeType) + uriFormat ); } diff --git a/packages/app/src/hooks/use-image-attachment-picker.ts b/packages/app/src/hooks/use-image-attachment-picker.ts index 30790bc0935..4f8b3fd481f 100644 --- a/packages/app/src/hooks/use-image-attachment-picker.ts +++ b/packages/app/src/hooks/use-image-attachment-picker.ts @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { normalizePickedImageAssets, - openImagePathsWithDesktopDialog, + pickImagesWithDesktopDialog, type PickedImageAttachmentInput, } from "@/hooks/image-attachment-picker"; import { isWeb } from "@/constants/platform"; @@ -51,15 +51,11 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { try { if (isWeb && isElectronRuntime()) { - const selectedPaths = await openImagePathsWithDesktopDialog(getDesktopHost()?.dialog); - if (selectedPaths.length === 0) { + const selectedImages = await pickImagesWithDesktopDialog(getDesktopHost()?.dialog); + if (selectedImages.length === 0) { return null; } - return selectedPaths.map((path) => ({ - source: { kind: "file_uri" as const, uri: path }, - mimeType: null, - fileName: null, - })); + return selectedImages; } const hasPermission = await ensurePermission(); diff --git a/packages/app/src/utils/image-attachments-from-files.test.ts b/packages/app/src/utils/image-attachments-from-files.test.ts index d59dc100075..3dac77a83b5 100644 --- a/packages/app/src/utils/image-attachments-from-files.test.ts +++ b/packages/app/src/utils/image-attachments-from-files.test.ts @@ -92,7 +92,7 @@ describe("collectImageFilesFromClipboardData", () => { ], }); - expect(files).toEqual([imagePng]); + expect(files).toEqual([{ file: imagePng, mimeType: "image/png" }]); }); it("ignores SVG clipboard files", () => { @@ -127,7 +127,10 @@ describe("filesToImageAttachments", () => { type: "", }); - const attachments = await filesToImageAttachments([pngFile, typeLessFile]); + const attachments = await filesToImageAttachments([ + { file: pngFile, mimeType: "image/png" }, + { file: typeLessFile, mimeType: "image/png" }, + ]); expect(attachments).toEqual([ { @@ -141,7 +144,7 @@ describe("filesToImageAttachments", () => { }, { id: "att-2", - mimeType: "image/jpeg", + mimeType: "image/png", storageType: "web-indexeddb", storageKey: "att-2", fileName: "second", @@ -156,7 +159,7 @@ describe("filesToImageAttachments", () => { type: "image/png", }); - const [attachment] = await filesToImageAttachments([large]); + const [attachment] = await filesToImageAttachments([{ file: large, mimeType: "image/png" }]); expect(attachment?.storageType).toBe("web-indexeddb"); expect(attachment?.byteSize).toBe(4 * 1024 * 1024); diff --git a/packages/app/src/utils/image-attachments-from-files.ts b/packages/app/src/utils/image-attachments-from-files.ts index 11483aba553..e44b1d23ff2 100644 --- a/packages/app/src/utils/image-attachments-from-files.ts +++ b/packages/app/src/utils/image-attachments-from-files.ts @@ -1,6 +1,6 @@ import type { AttachmentMetadata } from "@/attachments/types"; import { persistAttachmentFromBlob } from "@/attachments/service"; -import { isRasterImageMimeType } from "@/attachments/file-types"; +import { resolveRasterImageMimeType } from "@/attachments/file-types"; export interface ClipboardItemLike { kind?: string; @@ -14,40 +14,46 @@ export interface ClipboardDataLike { export type ImageAttachmentFromFile = AttachmentMetadata; +export interface ClipboardImageFile { + file: File; + mimeType: string; +} + export function collectImageFilesFromClipboardData( clipboardData?: ClipboardDataLike | null, -): File[] { +): ClipboardImageFile[] { if (!clipboardData?.items) { return []; } - const files: File[] = []; + const files: ClipboardImageFile[] = []; for (const item of Array.from(clipboardData.items)) { if (item?.kind !== "file") { continue; } - if (!isRasterImageMimeType(item.type)) { + const mimeType = resolveRasterImageMimeType({ mimeType: item.type }); + if (!mimeType) { continue; } const file = item.getAsFile?.(); if (!file) { continue; } - files.push(file); + files.push({ file, mimeType }); } return files; } export async function filesToImageAttachments( - files: readonly File[], + files: readonly ClipboardImageFile[], ): Promise { const attachments = await Promise.all( - files.map(async (file) => { + files.map(async ({ file, mimeType }) => { try { return await persistAttachmentFromBlob({ blob: file, - mimeType: file.type || "image/jpeg", + mimeType, fileName: file.name, }); } catch (error) { From 21404fbdec19ba07ac780b7a1da9d93f3b4fffc2 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Fri, 24 Jul 2026 06:12:14 -0700 Subject: [PATCH 063/420] feat(cli): manage workspace scripts (#1992) * feat(cli): manage workspace scripts * docs: document workspace script management --------- Co-authored-by: Mohamed Boudra --- docs/architecture.md | 1 + docs/service-proxy.md | 12 ++ packages/cli/src/cli.ts | 4 + packages/cli/src/commands/script/index.ts | 39 +++++ packages/cli/src/commands/script/ls.ts | 32 ++++ packages/cli/src/commands/script/schema.ts | 17 ++ packages/cli/src/commands/script/shared.ts | 78 +++++++++ packages/cli/src/commands/script/start.ts | 36 +++++ packages/cli/src/commands/script/stop.ts | 36 +++++ packages/client/src/daemon-client.ts | 41 +++++ packages/protocol/src/messages.ts | 64 ++++++++ .../protocol/src/messages.workspaces.test.ts | 36 +++++ .../src/server/agent/tools/paseo-tools.ts | 81 ++++++++++ packages/server/src/server/bootstrap.ts | 22 ++- packages/server/src/server/session.ts | 102 +++++++++++- .../workspace-scripts-service.test.ts | 48 ++++++ .../workspace-scripts-service.ts | 152 ++++++++++++------ .../server/src/server/websocket-server.ts | 2 + public-docs/cli.md | 16 +- public-docs/mcp.md | 14 +- public-docs/worktrees.md | 2 + skills/paseo/SKILL.md | 20 ++- 22 files changed, 800 insertions(+), 55 deletions(-) create mode 100644 packages/cli/src/commands/script/index.ts create mode 100644 packages/cli/src/commands/script/ls.ts create mode 100644 packages/cli/src/commands/script/schema.ts create mode 100644 packages/cli/src/commands/script/shared.ts create mode 100644 packages/cli/src/commands/script/start.ts create mode 100644 packages/cli/src/commands/script/stop.ts diff --git a/docs/architecture.md b/docs/architecture.md index ce218985316..0b8afa495ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,6 +121,7 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex - `paseo daemon start/stop/restart/status/pair/set-password` - `paseo chat ls/create/inspect/post/read/wait/delete` - `paseo terminal ls/create/capture/send-keys/kill` +- `paseo script ls/start/stop` - `paseo loop run/ls/inspect/logs/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` - `paseo heartbeat create/update/delete` diff --git a/docs/service-proxy.md b/docs/service-proxy.md index 6f4436aff2d..ee8f1ceca82 100644 --- a/docs/service-proxy.md +++ b/docs/service-proxy.md @@ -26,6 +26,18 @@ dev--feature-auth--miniweb.localhost Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions. +## Managing workspace scripts + +Configured `paseo.json` scripts can be managed without addressing their backing terminal directly: + +```bash +paseo script ls [--cwd | --workspace ] +paseo script start [--cwd | --workspace ] +paseo script stop [--cwd | --workspace ] +``` + +The commands return the same script metadata shown by the workspace: lifecycle, service port, proxy URLs, health, exit code, and supervised terminal ID. `stop` terminates the managed terminal rather than only removing the proxy route, so normal script lifecycle cleanup remains authoritative. MCP exposes matching `list_workspace_scripts`, `start_workspace_script`, and `stop_workspace_script` tools; those require an explicit workspace ID. + ## Configuration Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`: diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9124fc6c35b..72deee4a61a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -7,6 +7,7 @@ import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js"; import { createSpeechCommand } from "./commands/speech/index.js"; +import { createScriptCommand } from "./commands/script/index.js"; import { createTerminalCommand } from "./commands/terminal/index.js"; import { createWorktreeCommand } from "./commands/worktree/index.js"; import { createWorkspaceCommand } from "./commands/workspace/index.js"; @@ -171,6 +172,9 @@ export function createCli(): Command { // Terminal commands program.addCommand(createTerminalCommand()); + // Workspace script commands + program.addCommand(createScriptCommand()); + // Loop commands program.addCommand(createLoopCommand()); diff --git a/packages/cli/src/commands/script/index.ts b/packages/cli/src/commands/script/index.ts new file mode 100644 index 00000000000..92232c33cea --- /dev/null +++ b/packages/cli/src/commands/script/index.ts @@ -0,0 +1,39 @@ +import { Command } from "commander"; +import { withOutput } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { runLsCommand } from "./ls.js"; +import { runStartCommand } from "./start.js"; +import { runStopCommand } from "./stop.js"; + +function addWorkspaceSelectionOptions(command: Command): Command { + return command + .option("--cwd ", "Workspace directory (default: current directory)") + .option( + "--workspace ", + "Workspace ID (required when a directory has multiple workspaces)", + ); +} + +export function createScriptCommand(): Command { + const script = new Command("script").description("Manage configured workspace scripts"); + + addJsonAndDaemonHostOptions( + addWorkspaceSelectionOptions( + script.command("ls").description("List configured workspace scripts"), + ), + ).action(withOutput(runLsCommand)); + + addJsonAndDaemonHostOptions( + addWorkspaceSelectionOptions( + script.command("start").description("Start a configured workspace script").argument(""), + ), + ).action(withOutput(runStartCommand)); + + addJsonAndDaemonHostOptions( + addWorkspaceSelectionOptions( + script.command("stop").description("Stop a running workspace script").argument(""), + ), + ).action(withOutput(runStopCommand)); + + return script; +} diff --git a/packages/cli/src/commands/script/ls.ts b/packages/cli/src/commands/script/ls.ts new file mode 100644 index 00000000000..cd7d98e9f8d --- /dev/null +++ b/packages/cli/src/commands/script/ls.ts @@ -0,0 +1,32 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { + connectWorkspaceScriptClient, + resolveWorkspaceScriptWorkspaceId, + toWorkspaceScriptCommandError, + type WorkspaceScriptCommandOptions, +} from "./shared.js"; +import { workspaceScriptSchema, type WorkspaceScriptRow } from "./schema.js"; + +export async function runLsCommand( + options: WorkspaceScriptCommandOptions, + _command: Command, +): Promise> { + const client = await connectWorkspaceScriptClient(options.host); + try { + const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); + const payload = await client.listWorkspaceScripts(workspaceId); + if (payload.error) { + throw new Error(payload.error); + } + return { type: "list", data: payload.scripts ?? [], schema: workspaceScriptSchema }; + } catch (error) { + throw toWorkspaceScriptCommandError( + "WORKSPACE_SCRIPT_LIST_FAILED", + "list workspace scripts", + error, + ); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/script/schema.ts b/packages/cli/src/commands/script/schema.ts new file mode 100644 index 00000000000..2ec9e282c89 --- /dev/null +++ b/packages/cli/src/commands/script/schema.ts @@ -0,0 +1,17 @@ +import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; +import type { OutputSchema } from "../../output/index.js"; + +export type WorkspaceScriptRow = WorkspaceScriptPayload; + +export const workspaceScriptSchema: OutputSchema = { + idField: "scriptName", + columns: [ + { header: "NAME", field: "scriptName", width: 20 }, + { header: "TYPE", field: "type", width: 9 }, + { header: "LIFECYCLE", field: "lifecycle", width: 10 }, + { header: "HEALTH", field: (script) => script.health ?? "-", width: 10 }, + { header: "PORT", field: (script) => script.port ?? "-", width: 7 }, + { header: "PROXY URL", field: (script) => script.proxyUrl ?? "-", width: 42 }, + { header: "TERMINAL", field: (script) => script.terminalId ?? "-", width: 12 }, + ], +}; diff --git a/packages/cli/src/commands/script/shared.ts b/packages/cli/src/commands/script/shared.ts new file mode 100644 index 00000000000..4e3308bdfbc --- /dev/null +++ b/packages/cli/src/commands/script/shared.ts @@ -0,0 +1,78 @@ +import { resolve } from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, CommandOptions } from "../../output/index.js"; + +export interface WorkspaceScriptCommandOptions extends CommandOptions { + host?: string; + cwd?: string; + workspace?: string; +} + +export async function connectWorkspaceScriptClient(host?: string): Promise { + const daemonHost = getDaemonHost({ host }); + try { + const client = await connectToDaemon({ host }); + // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. + if (!client.getLastServerInfoMessage()?.features?.workspaceScriptManagement) { + await client.close().catch(() => {}); + throw { + code: "DAEMON_UPDATE_REQUIRED", + message: "Update the host to use workspace script management.", + } satisfies CommandError; + } + return client; + } catch (error) { + if (error && typeof error === "object" && "code" in error && "message" in error) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${daemonHost}: ${message}`, + details: "Start the daemon with: paseo daemon start", + } satisfies CommandError; + } +} + +export async function resolveWorkspaceScriptWorkspaceId( + client: DaemonClient, + options: WorkspaceScriptCommandOptions, +): Promise { + if (options.workspace) { + return options.workspace; + } + + const cwd = resolve(options.cwd ?? process.cwd()); + const payload = await client.fetchWorkspaces({ page: { limit: 200 } }); + const matches = payload.entries.filter( + (workspace) => resolve(workspace.workspaceDirectory) === cwd, + ); + if (matches.length === 1) { + return matches[0]!.id; + } + if (matches.length > 1) { + throw { + code: "WORKSPACE_AMBIGUOUS", + message: `Multiple workspaces use ${cwd}`, + details: "Pass --workspace to select one.", + } satisfies CommandError; + } + throw { + code: "WORKSPACE_NOT_FOUND", + message: `No Paseo workspace found for ${cwd}`, + details: "Open the directory in Paseo first, or pass --workspace .", + } satisfies CommandError; +} + +export function toWorkspaceScriptCommandError( + code: string, + action: string, + error: unknown, +): CommandError { + if (error && typeof error === "object" && "code" in error && "message" in error) { + return error as CommandError; + } + const message = error instanceof Error ? error.message : String(error); + return { code, message: `Failed to ${action}: ${message}` }; +} diff --git a/packages/cli/src/commands/script/start.ts b/packages/cli/src/commands/script/start.ts new file mode 100644 index 00000000000..1b377267fa6 --- /dev/null +++ b/packages/cli/src/commands/script/start.ts @@ -0,0 +1,36 @@ +import type { Command } from "commander"; +import type { CommandError, SingleResult } from "../../output/index.js"; +import { + connectWorkspaceScriptClient, + resolveWorkspaceScriptWorkspaceId, + toWorkspaceScriptCommandError, + type WorkspaceScriptCommandOptions, +} from "./shared.js"; +import { workspaceScriptSchema, type WorkspaceScriptRow } from "./schema.js"; + +export async function runStartCommand( + scriptName: string, + options: WorkspaceScriptCommandOptions, + _command: Command, +): Promise> { + const client = await connectWorkspaceScriptClient(options.host); + try { + const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); + const payload = await client.startWorkspaceScriptWithStatus(workspaceId, scriptName); + if (payload.error || !payload.script) { + throw { + code: "WORKSPACE_SCRIPT_START_FAILED", + message: payload.error ?? `Script '${scriptName}' did not return status metadata`, + } satisfies CommandError; + } + return { type: "single", data: payload.script, schema: workspaceScriptSchema }; + } catch (error) { + throw toWorkspaceScriptCommandError( + "WORKSPACE_SCRIPT_START_FAILED", + "start workspace script", + error, + ); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/script/stop.ts b/packages/cli/src/commands/script/stop.ts new file mode 100644 index 00000000000..68556560577 --- /dev/null +++ b/packages/cli/src/commands/script/stop.ts @@ -0,0 +1,36 @@ +import type { Command } from "commander"; +import type { CommandError, SingleResult } from "../../output/index.js"; +import { + connectWorkspaceScriptClient, + resolveWorkspaceScriptWorkspaceId, + toWorkspaceScriptCommandError, + type WorkspaceScriptCommandOptions, +} from "./shared.js"; +import { workspaceScriptSchema, type WorkspaceScriptRow } from "./schema.js"; + +export async function runStopCommand( + scriptName: string, + options: WorkspaceScriptCommandOptions, + _command: Command, +): Promise> { + const client = await connectWorkspaceScriptClient(options.host); + try { + const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); + const payload = await client.stopWorkspaceScript(workspaceId, scriptName); + if (payload.error || !payload.script) { + throw { + code: "WORKSPACE_SCRIPT_STOP_FAILED", + message: payload.error ?? `Script '${scriptName}' did not return status metadata`, + } satisfies CommandError; + } + return { type: "single", data: payload.script, schema: workspaceScriptSchema }; + } catch (error) { + throw toWorkspaceScriptCommandError( + "WORKSPACE_SCRIPT_STOP_FAILED", + "stop workspace script", + error, + ); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 50e686fad33..8e67ea4b5a9 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2179,6 +2179,47 @@ export class DaemonClient { }); } + async listWorkspaceScripts( + workspaceId: string, + requestId?: string, + ): Promise< + Extract["payload"] + > { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "workspace.script.list.request", workspaceId }, + responseType: "workspace.script.list.response", + }); + } + + async startWorkspaceScriptWithStatus( + workspaceId: string, + scriptName: string, + requestId?: string, + ): Promise< + Extract["payload"] + > { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "workspace.script.start.request", workspaceId, scriptName }, + responseType: "workspace.script.start.response", + }); + } + + async stopWorkspaceScript( + workspaceId: string, + scriptName: string, + requestId?: string, + ): Promise< + Extract["payload"] + > { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "workspace.script.stop.request", workspaceId, scriptName }, + responseType: "workspace.script.stop.response", + }); + } + async archiveWorkspace( workspaceId: string, requestId?: string, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f2e490766c2..d65825fae81 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2328,6 +2328,26 @@ export const StartWorkspaceScriptRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceScriptListRequestSchema = z.object({ + type: z.literal("workspace.script.list.request"), + workspaceId: z.string(), + requestId: z.string(), +}); + +export const WorkspaceScriptStartRequestSchema = z.object({ + type: z.literal("workspace.script.start.request"), + workspaceId: z.string(), + scriptName: z.string(), + requestId: z.string(), +}); + +export const WorkspaceScriptStopRequestSchema = z.object({ + type: z.literal("workspace.script.stop.request"), + workspaceId: z.string(), + scriptName: z.string(), + requestId: z.string(), +}); + export const SubscribeTerminalRequestSchema = z.object({ type: z.literal("subscribe_terminal_request"), terminalId: z.string(), @@ -2530,6 +2550,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CreateTerminalRequestSchema, RenameTerminalRequestSchema, StartWorkspaceScriptRequestSchema, + WorkspaceScriptListRequestSchema, + WorkspaceScriptStartRequestSchema, + WorkspaceScriptStopRequestSchema, SubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema, TerminalInputSchema, @@ -2793,6 +2816,8 @@ export const ServerInfoStatusPayloadSchema = z selectiveAgentTimeline: z.boolean().optional(), // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: z.boolean().optional(), + // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. + workspaceScriptManagement: z.boolean().optional(), }) .optional(), }) @@ -3382,6 +3407,30 @@ export const StartWorkspaceScriptResponseMessageSchema = z.object({ }), }); +const WorkspaceScriptOperationPayloadSchema = z.object({ + requestId: z.string(), + workspaceId: z.string(), + scriptName: z.string().optional(), + script: WorkspaceScriptPayloadSchema.nullable().optional(), + scripts: z.array(WorkspaceScriptPayloadSchema).optional(), + error: z.string().nullable(), +}); + +export const WorkspaceScriptListResponseMessageSchema = z.object({ + type: z.literal("workspace.script.list.response"), + payload: WorkspaceScriptOperationPayloadSchema, +}); + +export const WorkspaceScriptStartResponseMessageSchema = z.object({ + type: z.literal("workspace.script.start.response"), + payload: WorkspaceScriptOperationPayloadSchema, +}); + +export const WorkspaceScriptStopResponseMessageSchema = z.object({ + type: z.literal("workspace.script.stop.response"), + payload: WorkspaceScriptOperationPayloadSchema, +}); + // COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer parse daemon editor RPC responses. export const LegacyListAvailableEditorsResponseMessageSchema = z.object({ type: z.literal("list_available_editors_response"), @@ -5121,6 +5170,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ WorkspaceGithubSearchRepositoriesResponseSchema, ProjectGithubCloneResponseSchema, StartWorkspaceScriptResponseMessageSchema, + WorkspaceScriptListResponseMessageSchema, + WorkspaceScriptStartResponseMessageSchema, + WorkspaceScriptStopResponseMessageSchema, LegacyListAvailableEditorsResponseMessageSchema, LegacyOpenInEditorResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema, @@ -5304,6 +5356,18 @@ export type ProjectGithubCloneResponse = z.infer; +export type WorkspaceScriptListRequest = z.infer; +export type WorkspaceScriptStartRequest = z.infer; +export type WorkspaceScriptStopRequest = z.infer; +export type WorkspaceScriptListResponseMessage = z.infer< + typeof WorkspaceScriptListResponseMessageSchema +>; +export type WorkspaceScriptStartResponseMessage = z.infer< + typeof WorkspaceScriptStartResponseMessageSchema +>; +export type WorkspaceScriptStopResponseMessage = z.infer< + typeof WorkspaceScriptStopResponseMessageSchema +>; export type LegacyListAvailableEditorsResponseMessage = z.infer< typeof LegacyListAvailableEditorsResponseMessageSchema >; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index a1c07a5ca0d..72146149fe9 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -622,6 +622,42 @@ describe("workspace message schemas", () => { expect(parsed.payload.workspace.workspaceKind).toBe("directory"); }); + test("parses workspace script management request and response payloads", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "workspace.script.stop.request", + requestId: "req-script-stop", + workspaceId: "ws-repo", + scriptName: "web", + }), + ).toMatchObject({ type: "workspace.script.stop.request", scriptName: "web" }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.script.start.response", + payload: { + requestId: "req-script-start", + workspaceId: "ws-repo", + scriptName: "web", + script: { + scriptName: "web", + type: "service", + hostname: "web--repo.localhost", + port: 3000, + proxyUrl: "http://web--repo.localhost:6767", + lifecycle: "running", + health: "healthy", + terminalId: "terminal-1", + }, + error: null, + }, + }), + ).toMatchObject({ + type: "workspace.script.start.response", + payload: { script: { terminalId: "terminal-1", lifecycle: "running" } }, + }); + }); + test("parses script_status_update payload", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "script_status_update", diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 12aa430792c..906fd16a236 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -10,6 +10,7 @@ import { AgentListItemPayloadSchema, AgentPermissionResponseSchema, AgentSnapshotPayloadSchema, + WorkspaceScriptPayloadSchema, } from "../../messages.js"; import type { AgentListItemPayload } from "../../messages.js"; import { @@ -74,6 +75,7 @@ import type { WorkspaceRegistry, } from "../../workspace-registry.js"; import { resolveWorktreeSourceCwd } from "../../workspace-source.js"; +import type { WorkspaceScriptsService } from "../../session/workspace-scripts/workspace-scripts-service.js"; import { type ArchiveCommandDependencies, type CreatePaseoWorktreeCommandInput, @@ -112,6 +114,7 @@ export interface PaseoToolHostDependencies { title?: string | null, projectId?: string, ) => Promise; + workspaceScripts?: Pick; markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"]; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; @@ -537,6 +540,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase agentManager, agentStorage, terminalManager, + workspaceScripts, scheduleService, providerSnapshotManager, callerAgentId, @@ -2220,6 +2224,83 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); + registerTool( + "list_workspace_scripts", + { + title: "List workspace scripts", + description: + "List configured workspace scripts and their lifecycle, service port, proxy URL, health, and terminal ID.", + inputSchema: { + workspaceId: z.string().describe("Workspace ID whose configured scripts to list."), + }, + outputSchema: { + scripts: z.array(WorkspaceScriptPayloadSchema), + }, + }, + async ({ workspaceId }) => { + if (!workspaceScripts) { + throw new Error("Workspace script management is not configured"); + } + return { + content: [], + structuredContent: ensureValidJson({ scripts: await workspaceScripts.list(workspaceId) }), + }; + }, + ); + + registerTool( + "start_workspace_script", + { + title: "Start workspace script", + description: + "Start one configured workspace script through Paseo's managed workspace-script launcher.", + inputSchema: { + workspaceId: z.string().describe("Workspace ID containing the configured script."), + scriptName: z.string().min(1).describe("Configured paseo.json script name to start."), + }, + outputSchema: { + script: WorkspaceScriptPayloadSchema, + }, + }, + async ({ workspaceId, scriptName }) => { + if (!workspaceScripts) { + throw new Error("Workspace script management is not configured"); + } + return { + content: [], + structuredContent: ensureValidJson({ + script: await workspaceScripts.launch({ workspaceId, scriptName }), + }), + }; + }, + ); + + registerTool( + "stop_workspace_script", + { + title: "Stop workspace script", + description: "Stop a running workspace script through its supervised terminal lifecycle.", + inputSchema: { + workspaceId: z.string().describe("Workspace ID containing the running script."), + scriptName: z.string().min(1).describe("Configured paseo.json script name to stop."), + }, + outputSchema: { + script: WorkspaceScriptPayloadSchema, + }, + }, + async ({ workspaceId, scriptName }) => { + if (!workspaceScripts) { + throw new Error("Workspace script management is not configured"); + } + return { + content: [], + structuredContent: ensureValidJson({ + script: await workspaceScripts.stop({ workspaceId, scriptName }), + }), + }; + }, + ); + registerTool( "list_terminals", { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 725a6773369..f9e19049b53 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -171,12 +171,14 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; -import type { PersistedConfig } from "./persisted-config.js"; +import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; +import { createWorkspaceScriptsService } from "./session/workspace-scripts/workspace-scripts-service.js"; +import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { createManagedProcessRegistry, createSystemManagedProcessTable, @@ -1250,6 +1252,24 @@ export async function createPaseoDaemon( await emitWorkspaceUpdatesExternal([workspace.workspaceId]); return workspace; }, + workspaceScripts: createWorkspaceScriptsService({ + serviceProxy, + scriptRuntimeStore, + terminalManager, + workspaceRegistry, + projectRegistry, + workspaceGitService, + getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), + serviceProxyPublicBaseUrl, + resolveScriptHealth: (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), + logger, + // MCP operations do not belong to one WebSocket session, so lifecycle + // status updates fan out to every connected client. + emit: (message) => wsServer?.broadcast(wrapSessionMessage(message)), + spawnWorkspaceScript, + globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts, + }), markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c060450c98c..2119218dcb9 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -13,6 +13,9 @@ import { type SessionOutboundMessage, type GitSetupOptions, type StartWorkspaceScriptRequest, + type WorkspaceScriptListRequest, + type WorkspaceScriptStartRequest, + type WorkspaceScriptStopRequest, type CloseItemsRequest, type DirectorySuggestionsRequest, type ProjectPlacementPayload, @@ -2157,10 +2160,18 @@ export class Session { } private dispatchTerminalMessage(msg: SessionInboundMessage): Promise | undefined { - if (msg.type === "start_workspace_script_request") { - return this.handleStartWorkspaceScriptRequest(msg); + switch (msg.type) { + case "start_workspace_script_request": + return this.handleStartWorkspaceScriptRequest(msg); + case "workspace.script.list.request": + return this.handleWorkspaceScriptListRequest(msg); + case "workspace.script.start.request": + return this.handleWorkspaceScriptStartRequest(msg); + case "workspace.script.stop.request": + return this.handleWorkspaceScriptStopRequest(msg); + default: + return this.terminalController.dispatch(msg); } - return this.terminalController.dispatch(msg); } // eslint-disable-next-line complexity @@ -5496,6 +5507,91 @@ export class Session { return this.workspaceScripts.start(request); } + private async handleWorkspaceScriptListRequest( + request: WorkspaceScriptListRequest, + ): Promise { + try { + const scripts = await this.workspaceScripts.list(request.workspaceId); + this.emit({ + type: "workspace.script.list.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scripts, + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.script.list.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scripts: [], + error: error instanceof Error ? error.message : "Failed to list workspace scripts", + }, + }); + } + } + + private async handleWorkspaceScriptStartRequest( + request: WorkspaceScriptStartRequest, + ): Promise { + try { + const script = await this.workspaceScripts.launch(request); + this.emit({ + type: "workspace.script.start.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scriptName: request.scriptName, + script, + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.script.start.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scriptName: request.scriptName, + script: null, + error: error instanceof Error ? error.message : "Failed to start workspace script", + }, + }); + } + } + + private async handleWorkspaceScriptStopRequest( + request: WorkspaceScriptStopRequest, + ): Promise { + try { + const script = await this.workspaceScripts.stop(request); + this.emit({ + type: "workspace.script.stop.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scriptName: request.scriptName, + script, + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.script.stop.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scriptName: request.scriptName, + script: null, + error: error instanceof Error ? error.message : "Failed to stop workspace script", + }, + }); + } + } + // COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs. private async handleLegacyListAvailableEditorsRequest( request: Extract, diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 137b83ac1c7..c3776397475 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -220,6 +220,54 @@ describe("emitStatusUpdate", () => { }); }); +describe("stop", () => { + test("kills the supervised terminal and returns the stopped service metadata", async () => { + const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-")); + tempDirs.push(dir); + writeFileSync( + join(dir, "paseo.json"), + JSON.stringify({ scripts: { web: { type: "service", command: "npm run web", port: 3000 } } }), + ); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: "ws-1", + scriptName: "web", + type: "service", + lifecycle: "running", + terminalId: "terminal-1", + exitCode: null, + }); + const terminalManager = { + getTerminal: (terminalId: string) => (terminalId === "terminal-1" ? {} : undefined), + async killTerminalAndWait(terminalId: string) { + expect(terminalId).toBe("terminal-1"); + runtimeStore.set({ + workspaceId: "ws-1", + scriptName: "web", + type: "service", + lifecycle: "stopped", + terminalId, + exitCode: 143, + }); + }, + } as unknown as TerminalManager; + const { service } = buildService({ + workspace: { workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord, + scriptRuntimeStore: runtimeStore, + terminalManager, + }); + + await expect(service.stop({ workspaceId: "ws-1", scriptName: "web" })).resolves.toMatchObject({ + scriptName: "web", + type: "service", + port: 3000, + lifecycle: "stopped", + exitCode: 143, + terminalId: "terminal-1", + }); + }); +}); + describe("start", () => { test("reports an error when workspace scripts are unavailable", async () => { const { service, emitted, spawnCalls } = buildService({ terminalManager: null }); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 800179a1f09..b43e13548bf 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -3,6 +3,7 @@ import type { SessionOutboundMessage, StartWorkspaceScriptRequest, WorkspaceDescriptorPayload, + WorkspaceScriptPayload, } from "../../messages.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { ServiceProxySubsystem } from "../../service-proxy.js"; @@ -43,6 +44,9 @@ export interface WorkspaceScriptsService { project?: PersistedProjectRecord | null, ): WorkspaceScriptsPayload; emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise; + list(workspaceId: string): Promise; + launch(input: { workspaceId: string; scriptName: string }): Promise; + stop(input: { workspaceId: string; scriptName: string }): Promise; start(request: StartWorkspaceScriptRequest): Promise; } @@ -93,11 +97,10 @@ export function createWorkspaceScriptsService(deps: { currentBranch, }; } - if (!snapshot) return undefined; return { projectSlug: deriveProjectSlug( workspace.cwd, - snapshot.git.isGit ? snapshot.git.remoteUrl : null, + snapshot?.git.isGit ? snapshot.git.remoteUrl : null, ), currentBranch, }; @@ -137,47 +140,104 @@ export function createWorkspaceScriptsService(deps: { } } + async function getWorkspace(workspaceId: string) { + const workspace = await workspaceRegistry.get(workspaceId); + if (!workspace) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + return workspace; + } + + function requireAvailable(): { + serviceProxy: ServiceProxySubsystem; + runtimeStore: WorkspaceScriptRuntimeStore; + terminalManager: TerminalManager; + } { + if (!terminalManager || !serviceProxy || !scriptRuntimeStore) { + throw new Error("Workspace scripts are not available on this daemon"); + } + return { serviceProxy, runtimeStore: scriptRuntimeStore, terminalManager }; + } + + async function list(workspaceId: string): Promise { + requireAvailable(); + const workspace = await getWorkspace(workspaceId); + const project = await projectRegistry.get(workspace.projectId); + return buildSnapshot(workspace, project); + } + + async function launchProcess(input: { workspaceId: string; scriptName: string }) { + const available = requireAvailable(); + const workspace = await getWorkspace(input.workspaceId); + const project = await projectRegistry.get(workspace.projectId); + const gitMetadata = resolveGitMetadata(workspace, project); + const result = await spawnWorkspaceScript({ + repoRoot: workspace.cwd, + workspaceId: workspace.workspaceId, + projectSlug: gitMetadata.projectSlug, + branchName: gitMetadata.currentBranch, + scriptName: input.scriptName, + daemonPort: getDaemonTcpPort?.() ?? null, + daemonListenHost: getDaemonTcpHost?.() ?? null, + serviceProxyPublicBaseUrl, + serviceProxy: available.serviceProxy, + runtimeStore: available.runtimeStore, + terminalManager: available.terminalManager, + globalServicePorts, + logger, + onLifecycleChanged: () => { + void emitStatusUpdate(workspace.workspaceId, workspace.cwd); + }, + }); + return { workspace, project, terminalId: result.terminalId }; + } + + async function launch(input: { + workspaceId: string; + scriptName: string; + }): Promise { + const { workspace, project } = await launchProcess(input); + const script = buildSnapshot(workspace, project).find( + (entry) => entry.scriptName === input.scriptName, + ); + if (!script) { + throw new Error(`Script '${input.scriptName}' did not produce a status record`); + } + void emitStatusUpdate(workspace.workspaceId, workspace.cwd); + return script; + } + + async function stop(input: { + workspaceId: string; + scriptName: string; + }): Promise { + const available = requireAvailable(); + const workspace = await getWorkspace(input.workspaceId); + const project = await projectRegistry.get(workspace.projectId); + const runtime = available.runtimeStore.get(input); + if (!runtime || runtime.lifecycle !== "running") { + throw new Error(`Script '${input.scriptName}' is not running`); + } + if (!available.terminalManager.getTerminal(runtime.terminalId)) { + throw new Error(`Terminal for script '${input.scriptName}' is no longer available`); + } + + // The launcher's terminal exit listener owns route removal and runtime state updates. + await available.terminalManager.killTerminalAndWait(runtime.terminalId); + + const script = buildSnapshot(workspace, project).find( + (entry) => entry.scriptName === input.scriptName, + ); + if (!script) { + throw new Error(`Script '${input.scriptName}' did not produce a status record`); + } + void emitStatusUpdate(workspace.workspaceId, workspace.cwd); + return script; + } + async function start(request: StartWorkspaceScriptRequest): Promise { try { - if (!terminalManager || !serviceProxy || !scriptRuntimeStore) { - throw new Error("Workspace scripts are not available on this daemon"); - } - - const workspace = await workspaceRegistry.get(request.workspaceId); - if (!workspace) { - throw new Error(`Workspace not found: ${request.workspaceId}`); - } - const project = await projectRegistry.get(workspace.projectId); - const projectSlug = project - ? deriveProjectServiceSlug(project) - : deriveProjectSlug( - workspace.cwd, - workspaceGitService.peekSnapshot(workspace.cwd)?.git.remoteUrl ?? null, - ); - const branchName = - workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? - workspace.branch ?? - null; - - const serviceResult = await spawnWorkspaceScript({ - repoRoot: workspace.cwd, - workspaceId: workspace.workspaceId, - projectSlug, - branchName, - scriptName: request.scriptName, - daemonPort: getDaemonTcpPort?.() ?? null, - daemonListenHost: getDaemonTcpHost?.() ?? null, - serviceProxyPublicBaseUrl, - serviceProxy, - runtimeStore: scriptRuntimeStore, - terminalManager, - globalServicePorts, - logger, - onLifecycleChanged: () => { - void emitStatusUpdate(workspace.workspaceId, workspace.cwd); - }, - }); - + const { workspace, terminalId } = await launchProcess(request); void emitStatusUpdate(workspace.workspaceId, workspace.cwd); emit({ type: "start_workspace_script_response", @@ -185,18 +245,14 @@ export function createWorkspaceScriptsService(deps: { requestId: request.requestId, workspaceId: request.workspaceId, scriptName: request.scriptName, - terminalId: serviceResult.terminalId, + terminalId, error: null, }, }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to start workspace script"; logger.error( - { - err: error, - workspaceId: request.workspaceId, - scriptName: request.scriptName, - }, + { err: error, workspaceId: request.workspaceId, scriptName: request.scriptName }, "Failed to start workspace script", ); emit({ @@ -212,5 +268,5 @@ export function createWorkspaceScriptsService(deps: { } } - return { buildSnapshot, emitStatusUpdate, start }; + return { buildSnapshot, emitStatusUpdate, list, launch, stop, start }; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b7bc4b881d3..0959c0349c3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1445,6 +1445,8 @@ export class VoiceAssistantWebSocketServer { selectiveAgentTimeline: true, // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: true, + // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. + workspaceScriptManagement: true, }, }; } diff --git a/public-docs/cli.md b/public-docs/cli.md index 14b564cc113..6d1e6394af5 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -1,6 +1,6 @@ --- title: CLI -description: "Paseo CLI reference: manage agents, workspaces, schedules, daemons, and permissions from your terminal." +description: "Paseo CLI reference: manage agents, workspaces, scripts, schedules, daemons, and permissions from your terminal." nav: CLI order: 3 category: Getting started @@ -86,6 +86,20 @@ paseo workspace archive Add `--forge ` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services. +## Workspace scripts + +List, start, and stop the scripts configured in a workspace's `paseo.json`: + +```bash +paseo script ls +paseo script start web +paseo script stop web +``` + +By default, Paseo selects the workspace whose directory is the current directory. Pass `--cwd ` to select a different directory, or `--workspace ` when a directory has multiple workspaces. These commands also accept `--host` and the standard output options such as `--json`. + +The output includes each script's lifecycle and supervised terminal ID. Services also include their assigned port, proxy URL, and health. See [Git worktrees](/docs/worktrees#scripts-and-services) for `paseo.json` configuration. + ## Listing agents ```bash diff --git a/public-docs/mcp.md b/public-docs/mcp.md index 71ba24f085a..309f18e260c 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -1,6 +1,6 @@ --- title: MCP reference -description: Reference for the Paseo tools agents use to manage agents, workspaces, terminals, and schedules. +description: Reference for the Paseo tools agents use to manage agents, workspaces, scripts, terminals, and schedules. nav: MCP reference order: 33 category: Orchestration @@ -55,6 +55,18 @@ MCP does not expose an agent-detach tool. Detaching is a manual user action in t For worktree isolation, `create_workspace` accepts the same useful choices as the app: branch off from a base, check out an existing branch, or check out a pull request. The worktree remains an implementation detail of the workspace lifecycle. +### Workspace scripts + +These tools manage scripts configured in a workspace's `paseo.json`. Each requires an explicit `workspaceId`; start and stop also require the configured `scriptName`. + +| Tool | Function | +| ------------------------ | --------------------------------------------------------------------------------------- | +| `list_workspace_scripts` | List configured scripts with lifecycle, terminal, port, proxy URL, and health metadata. | +| `start_workspace_script` | Start a configured script through Paseo's managed launcher. | +| `stop_workspace_script` | Stop a running script through its supervised terminal. | + +See [Git worktrees](/docs/worktrees#scripts-and-services) for `paseo.json` configuration. + ### Terminals | Tool | Function | diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index fbe3e72d065..bdc8a24f230 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -114,6 +114,8 @@ Commands run with the worktree as `cwd`. Use `$PASEO_SOURCE_CHECKOUT_PATH` to re `scripts` are named commands you can run inside a worktree on demand. Mark one as a _service_ and Paseo supervises it as a long-running process, assigns it a port, and routes HTTP traffic to it through the daemon's reverse proxy. +Run them from the app, or manage them from automation with [`paseo script`](/docs/cli#workspace-scripts) and the [workspace-script MCP tools](/docs/mcp#workspace-scripts). + ### Plain scripts ```json diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 865972c2e8d..096d8cb09a8 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -1,6 +1,6 @@ --- name: paseo -description: Paseo reference for managing workspaces, agents, schedules, and heartbeats. +description: Paseo reference for managing workspaces, workspace scripts, agents, schedules, and heartbeats. --- Paseo is a daemon that supervises AI coding agents on your machine. Control it through tools or a CLI. @@ -15,6 +15,24 @@ Paseo is a daemon that supervises AI coding agents on your machine. Control it t Worktree creation and reference accounting are implementation details of `isolation: "worktree"`. +## Workspace scripts + +Configured `paseo.json` scripts use the same supervised lifecycle from tools and the CLI. + +**`list_workspace_scripts`** — `{ workspaceId }`. Lists configured scripts with lifecycle, service port, proxy URLs, health, exit code, and terminal ID. + +**`start_workspace_script`** — `{ workspaceId, scriptName }`. Starts one configured script through Paseo's managed workspace-script launcher and returns its status metadata. + +**`stop_workspace_script`** — `{ workspaceId, scriptName }`. Stops a running script through its supervised terminal and returns the stopped status metadata. + +The matching CLI surface accepts either an explicit workspace ID or resolves the current directory: + +```bash +paseo script ls [--cwd | --workspace ] +paseo script start [--cwd | --workspace ] +paseo script stop [--cwd | --workspace ] +``` + ## Agents **`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Optional: `workspaceId`, `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, workspaceId, … }`. From 7250009ab7ee72142a08344b8a0b7a12af666e53 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 15:12:29 +0200 Subject: [PATCH 064/420] feat(app): copy terminal IDs from tab menus (#2371) --- .../e2e/workspace-terminal-tab-rename.spec.ts | 52 +++++++++++++++++-- .../app/src/components/split-container.tsx | 8 +++ 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 + .../workspace/workspace-desktop-tabs-row.tsx | 9 ++++ .../screens/workspace/workspace-screen.tsx | 25 +++++++++ .../workspace/workspace-tab-menu.test.ts | 24 ++++++++- .../screens/workspace/workspace-tab-menu.ts | 21 ++++++++ 14 files changed, 148 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts index 78d36e6cd8d..6ee477f7c19 100644 --- a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts +++ b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "./fixtures"; +import { test, expect, type Page } from "./fixtures"; import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher"; import { renameModalInput, renameModalSubmit } from "./helpers/rename"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; @@ -36,7 +36,52 @@ async function waitForCreatedTerminalId(workspace: SeededWorkspace): Promise { + if (terminalId) { + await workspace.client.killTerminal(terminalId).catch(() => undefined); + } + await workspace.cleanup(); +} + +async function readClipboard(page: Page): Promise { + return page.evaluate(() => navigator.clipboard.readText()); +} + test.describe("Workspace terminal tab rename", () => { + test("right-click copy terminal id writes the terminal id to the clipboard", async ({ + context, + page, + }) => { + test.setTimeout(60_000); + + const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-copy-id-" }); + let terminalId: string | null = null; + + try { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await gotoWorkspace(page, workspace.workspaceId); + await clickNewTerminal(page); + terminalId = await waitForCreatedTerminalId(workspace); + + const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first(); + await expect(tab).toBeVisible({ timeout: 15_000 }); + + await tab.click({ button: "right" }); + const copyTerminalId = page.getByTestId( + `workspace-tab-context-terminal_${terminalId}-copy-terminal-id`, + ); + await expect(copyTerminalId).toBeVisible({ timeout: 10_000 }); + await copyTerminalId.click(); + + await expect.poll(() => readClipboard(page)).toBe(terminalId); + } finally { + await cleanupTerminal(workspace, terminalId); + } + }); + test("right-click rename persists the terminal title and updates the tab label", async ({ page, }) => { @@ -74,10 +119,7 @@ test.describe("Workspace terminal tab rename", () => { .poll(() => fetchTerminalTitle(workspace, terminalId!)) .toBe("My Renamed Terminal"); } finally { - if (terminalId) { - await workspace.client.killTerminal(terminalId).catch(() => undefined); - } - await workspace.cleanup(); + await cleanupTerminal(workspace, terminalId); } }); }); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 59e684968f5..79e3df7a7ec 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -95,6 +95,7 @@ interface SplitContainerProps { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -373,6 +374,7 @@ export function SplitContainer({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -592,6 +594,7 @@ export function SplitContainer({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} @@ -738,6 +741,7 @@ function SplitNodeView({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -795,6 +799,7 @@ function SplitNodeView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} @@ -844,6 +849,7 @@ function SplitNodeView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} @@ -899,6 +905,7 @@ function SplitPaneView({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -1044,6 +1051,7 @@ function SplitPaneView({ onCloseTab={onCloseTab} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index bfff7e1294a..ee1ecbaba88 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -492,6 +492,7 @@ export const ar: TranslationResources = { openFor: "فتح القائمة لـ{{label}}", copyResumeCommand: "نسخ أمر السيرة الذاتية", copyAgentId: "نسخ معرف الوكيل", + copyTerminalId: "نسخ معرف المحطة", copyFilePath: "Copy file path", rename: "إعادة تسمية", closeAbove: "إغلاق علامات التبويب أعلاه", @@ -529,6 +530,7 @@ export const ar: TranslationResources = { toasts: { copyFailed: "فشل النسخ", agentIdCopiedLabel: "AgentID", + terminalIdCopiedLabel: "معرف المحطة", resumeCommandCopiedLabel: "أمر الاستئناف", filePathCopiedLabel: "File path", resumeIdUnavailable: "السيرة الذاتية ID غير متوفرة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index b6fa7cd7a3a..63f6a4f7ee5 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -491,6 +491,7 @@ export const en = { openFor: "Open menu for {{label}}", copyResumeCommand: "Copy resume command", copyAgentId: "Copy agent id", + copyTerminalId: "Copy terminal id", copyFilePath: "Copy file path", rename: "Rename", closeAbove: "Close tabs above", @@ -528,6 +529,7 @@ export const en = { toasts: { copyFailed: "Copy failed", agentIdCopiedLabel: "Agent ID", + terminalIdCopiedLabel: "Terminal ID", resumeCommandCopiedLabel: "resume command", filePathCopiedLabel: "File path", resumeIdUnavailable: "Resume ID not available", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7372e60a316..fc4d6ea60e1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -496,6 +496,7 @@ export const es: TranslationResources = { openFor: "Menú abierto para{{label}}", copyResumeCommand: "Copiar comando de reanudación", copyAgentId: "Copiar ID del agente", + copyTerminalId: "Copiar ID del terminal", copyFilePath: "Copy file path", rename: "Rebautizar", closeAbove: "Cerrar pestañas arriba", @@ -534,6 +535,7 @@ export const es: TranslationResources = { toasts: { copyFailed: "Copia fallida", agentIdCopiedLabel: "AgentID", + terminalIdCopiedLabel: "ID del terminal", resumeCommandCopiedLabel: "reanudar el comando", filePathCopiedLabel: "File path", resumeIdUnavailable: "ReanudarIDno disponible", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 00dfc82451f..7a69a199603 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -496,6 +496,7 @@ export const fr: TranslationResources = { openFor: "Ouvrir le menu pour{{label}}", copyResumeCommand: "Copier la commande de reprise", copyAgentId: "Copier l'identifiant de l'agent", + copyTerminalId: "Copier l'identifiant du terminal", copyFilePath: "Copy file path", rename: "Rebaptiser", closeAbove: "Fermer les onglets ci-dessus", @@ -534,6 +535,7 @@ export const fr: TranslationResources = { toasts: { copyFailed: "Échec de la copie", agentIdCopiedLabel: "AgentID", + terminalIdCopiedLabel: "Identifiant du terminal", resumeCommandCopiedLabel: "reprendre la commande", filePathCopiedLabel: "File path", resumeIdUnavailable: "ReprendreIDnon disponible", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 391994a474f..888d8864b48 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -496,6 +496,7 @@ export const ja: TranslationResources = { openFor: "{{label}}のメニューを開く", copyResumeCommand: "再開コマンドをコピー", copyAgentId: "エージェントIDをコピー", + copyTerminalId: "ターミナルIDをコピー", copyFilePath: "ファイルパスをコピー", rename: "名前を変更", closeAbove: "上のタブを閉じる", @@ -534,6 +535,7 @@ export const ja: TranslationResources = { toasts: { copyFailed: "コピーに失敗しました", agentIdCopiedLabel: "エージェントID", + terminalIdCopiedLabel: "ターミナルID", resumeCommandCopiedLabel: "再開コマンド", filePathCopiedLabel: "ファイルパス", resumeIdUnavailable: "再開IDが利用できません", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index dea623dcf5d..c1d6827e525 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -496,6 +496,7 @@ export const ptBR: TranslationResources = { openFor: "Abrir menu de {{label}}", copyResumeCommand: "Copiar comando de retomada", copyAgentId: "Copiar ID do agente", + copyTerminalId: "Copiar ID do terminal", copyFilePath: "Copiar caminho do arquivo", rename: "Renomear", closeAbove: "Fechar abas acima", @@ -533,6 +534,7 @@ export const ptBR: TranslationResources = { toasts: { copyFailed: "Falha ao copiar", agentIdCopiedLabel: "ID do agente", + terminalIdCopiedLabel: "ID do terminal", resumeCommandCopiedLabel: "comando de retomada", filePathCopiedLabel: "Caminho do arquivo", resumeIdUnavailable: "ID de retomada indisponível", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index c799196facd..a46d91034e9 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -496,6 +496,7 @@ export const ru: TranslationResources = { openFor: "Открыть меню для{{label}}", copyResumeCommand: "Копировать команду возобновления", copyAgentId: "Скопировать идентификатор агента", + copyTerminalId: "Скопировать идентификатор терминала", copyFilePath: "Copy file path", rename: "Переименовать", closeAbove: "Закрыть вкладки выше", @@ -533,6 +534,7 @@ export const ru: TranslationResources = { toasts: { copyFailed: "Не удалось скопировать", agentIdCopiedLabel: "AgentID", + terminalIdCopiedLabel: "Идентификатор терминала", resumeCommandCopiedLabel: "команда возобновления", filePathCopiedLabel: "File path", resumeIdUnavailable: "Резюме ID недоступно", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 5f805fc5c0f..63f4b97301e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -492,6 +492,7 @@ export const zhCN: TranslationResources = { openFor: "打开 {{label}} 的菜单", copyResumeCommand: "复制恢复命令", copyAgentId: "复制 Agent ID", + copyTerminalId: "复制 Terminal ID", copyFilePath: "Copy file path", rename: "重命名", closeAbove: "关闭上方标签", @@ -529,6 +530,7 @@ export const zhCN: TranslationResources = { toasts: { copyFailed: "复制失败", agentIdCopiedLabel: "Agent ID", + terminalIdCopiedLabel: "Terminal ID", resumeCommandCopiedLabel: "恢复命令", filePathCopiedLabel: "File path", resumeIdUnavailable: "恢复 ID 不可用", diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 5689f318dcb..7206b7f9e6b 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -419,6 +419,7 @@ interface WorkspaceDesktopTabsRowProps { onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -764,6 +765,7 @@ export function WorkspaceDesktopTabsRow({ onCloseTab, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -847,6 +849,7 @@ export function WorkspaceDesktopTabsRow({ () => ({ copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), copyAgentId: t("workspace.tabs.menu.copyAgentId"), + copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), copyFilePath: t("workspace.tabs.menu.copyFilePath"), rename: t("workspace.tabs.menu.rename"), closeAbove: t("workspace.tabs.menu.closeAbove"), @@ -945,6 +948,7 @@ export function WorkspaceDesktopTabsRow({ normalizedWorkspaceId={normalizedWorkspaceId} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} @@ -976,6 +980,7 @@ export function WorkspaceDesktopTabsRow({ onCloseTabsToLeft, onCloseTabsToRight, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onCopyResumeCommand, onNavigateTab, @@ -1097,6 +1102,7 @@ function ResolvedDesktopTabChip({ normalizedWorkspaceId, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -1123,6 +1129,7 @@ function ResolvedDesktopTabChip({ normalizedWorkspaceId: string; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -1149,6 +1156,7 @@ function ResolvedDesktopTabChip({ tabCount, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -1166,6 +1174,7 @@ function ResolvedDesktopTabChip({ onCloseTabsToLeft, onCloseTabsToRight, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onCopyResumeCommand, labels, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d3e1e0e872f..58be94ce3a1 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -424,6 +424,7 @@ interface MobileWorkspaceTabSwitcherProps { onSelectSwitcherTab: (key: string) => void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -612,6 +613,7 @@ function MobileWorkspaceTabOption({ onPress, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -630,6 +632,7 @@ function MobileWorkspaceTabOption({ onPress: () => void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -643,6 +646,7 @@ function MobileWorkspaceTabOption({ () => ({ copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), copyAgentId: t("workspace.tabs.menu.copyAgentId"), + copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), copyFilePath: t("workspace.tabs.menu.copyFilePath"), rename: t("workspace.tabs.menu.rename"), closeAbove: t("workspace.tabs.menu.closeAbove"), @@ -665,6 +669,7 @@ function MobileWorkspaceTabOption({ menuTestIDBase, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -733,6 +738,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onSelectSwitcherTab, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -789,6 +795,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onPress={onPress} onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} onCopyFilePath={onCopyFilePath} onReloadAgent={onReloadAgent} onRenameTab={onRenameTab} @@ -807,6 +814,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ normalizedWorkspaceId, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -2772,6 +2780,19 @@ function WorkspaceScreenContent({ [toast, t], ); + const handleCopyTerminalId = useCallback( + async (terminalId: string) => { + if (!terminalId) return; + try { + await Clipboard.setStringAsync(terminalId); + toast.copied(t("workspace.tabs.toasts.terminalIdCopiedLabel")); + } catch { + toast.error(t("workspace.tabs.toasts.copyFailed")); + } + }, + [toast, t], + ); + const handleCopyFilePath = useCallback( async (path: string) => { if (!path) return; @@ -3645,6 +3666,7 @@ function WorkspaceScreenContent({ onCloseTab={handleCloseTabById} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onCopyTerminalId={handleCopyTerminalId} onCopyFilePath={handleCopyFilePath} onReloadAgent={handleReloadAgent} onRenameTab={handleRenameTab} @@ -3681,6 +3703,7 @@ function WorkspaceScreenContent({ handleCloseTabById, handleCopyResumeCommand, handleCopyAgentId, + handleCopyTerminalId, handleCopyFilePath, handleReloadAgent, handleRenameTab, @@ -3762,6 +3785,7 @@ function WorkspaceScreenContent({ onSelectSwitcherTab={handleSelectSwitcherTab} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onCopyTerminalId={handleCopyTerminalId} onCopyFilePath={handleCopyFilePath} onReloadAgent={handleReloadAgent} onRenameTab={handleRenameTab} @@ -3784,6 +3808,7 @@ function WorkspaceScreenContent({ onCloseTab={handleCloseTabById} onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} + onCopyTerminalId={handleCopyTerminalId} onCopyFilePath={handleCopyFilePath} onReloadAgent={handleReloadAgent} onRenameTab={handleRenameTab} diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts index 56de7207c12..348419e362d 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -34,6 +34,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-agent_123", onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId: vi.fn(), onCopyFilePath, onReloadAgent, onRenameTab, @@ -64,6 +65,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-menu-agent_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), @@ -99,6 +101,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-menu-draft_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), @@ -127,6 +130,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-agent_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), @@ -156,6 +160,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-agent_123", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab, @@ -174,8 +179,9 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(onRenameTab).toHaveBeenCalledWith(tab); }); - it("includes rename as the first entry for terminal tabs", () => { + it("includes copy id and rename for terminal tabs", () => { const onRenameTab = vi.fn(); + const onCopyTerminalId = vi.fn(); const terminalTab: WorkspaceTabDescriptor = { key: "terminal_abc", tabId: "terminal_abc", @@ -190,6 +196,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-terminal_abc", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId, onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab, @@ -200,12 +207,22 @@ describe("buildWorkspaceTabMenuEntries", () => { }); const labels = entries.filter((entry) => entry.kind === "item").map((entry) => entry.label); - expect(labels[0]).toBe("Rename"); + expect(labels[0]).toBe("Copy terminal id"); + expect(labels[1]).toBe("Rename"); expect(labels).not.toContain("Copy resume command"); expect(labels).not.toContain("Copy agent id"); expect(labels).not.toContain("Copy file path"); expect(labels).not.toContain("Reload agent"); + const copyTerminalIdEntry = entries.find( + (entry) => entry.kind === "item" && entry.key === "copy-terminal-id", + ); + if (!copyTerminalIdEntry || copyTerminalIdEntry.kind !== "item") { + throw new Error("Copy terminal id entry missing"); + } + copyTerminalIdEntry.onSelect(); + expect(onCopyTerminalId).toHaveBeenCalledWith("terminal-abc"); + const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename"); if (!renameEntry || renameEntry.kind !== "item") { throw new Error("Rename entry missing"); @@ -230,6 +247,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase: "workspace-tab-context-file_abc", onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath, onReloadAgent: vi.fn(), onRenameTab: vi.fn(), @@ -272,6 +290,7 @@ describe("buildWorkspaceTabMenuEntries", () => { tabCount: 1, onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), @@ -302,6 +321,7 @@ describe("buildWorkspaceTabMenuEntries", () => { menuTestIDBase, onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), + onCopyTerminalId: vi.fn(), onCopyFilePath: vi.fn(), onReloadAgent: vi.fn(), onRenameTab: vi.fn(), diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index 4845b316006..d6e41e3b39f 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -8,6 +8,7 @@ export type WorkspaceTabMenuSurface = "desktop" | "mobile"; export interface WorkspaceTabMenuLabels { copyResumeCommand: string; copyAgentId: string; + copyTerminalId: string; copyFilePath: string; rename: string; closeAbove: string; @@ -23,6 +24,7 @@ export interface WorkspaceTabMenuLabels { export const DEFAULT_WORKSPACE_TAB_MENU_LABELS: WorkspaceTabMenuLabels = { copyResumeCommand: i18n.t("workspace.tabs.menu.copyResumeCommand"), copyAgentId: i18n.t("workspace.tabs.menu.copyAgentId"), + copyTerminalId: i18n.t("workspace.tabs.menu.copyTerminalId"), copyFilePath: i18n.t("workspace.tabs.menu.copyFilePath"), rename: i18n.t("workspace.tabs.menu.rename"), closeAbove: i18n.t("workspace.tabs.menu.closeAbove"), @@ -68,6 +70,7 @@ interface BuildWorkspaceTabMenuEntriesInput { menuTestIDBase: string; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -84,6 +87,7 @@ interface BuildWorkspaceDesktopTabActionsInput { tabCount: number; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; @@ -161,6 +165,7 @@ export function buildWorkspaceTabMenuEntries( menuTestIDBase, onCopyResumeCommand, onCopyAgentId, + onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, @@ -200,6 +205,21 @@ export function buildWorkspaceTabMenuEntries( }); } + if (tab.target.kind === "terminal") { + const { terminalId } = tab.target; + entries.push({ + kind: "item", + key: "copy-terminal-id", + label: labels.copyTerminalId, + icon: "copy", + hint: terminalId.slice(0, 7), + testID: `${menuTestIDBase}-copy-terminal-id`, + onSelect: () => { + void onCopyTerminalId(terminalId); + }, + }); + } + if (tab.target.kind === "file") { const filePath = tab.target.path; entries.push({ @@ -306,6 +326,7 @@ export function buildWorkspaceDesktopTabActions( menuTestIDBase: contextMenuTestId, onCopyResumeCommand: input.onCopyResumeCommand, onCopyAgentId: input.onCopyAgentId, + onCopyTerminalId: input.onCopyTerminalId, onCopyFilePath: input.onCopyFilePath, onReloadAgent: input.onReloadAgent, onRenameTab: input.onRenameTab, From 055db7454d1bb15ad0c4c874f8bc019b10e5dfb9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 15:14:37 +0200 Subject: [PATCH 065/420] Stop stale client sockets from exhausting daemon memory (#2169) * fix(server): bound stale websocket connections Track application ownership per physical socket and terminate abandoned or backpressured transports without disrupting replacement connections. * fix(server): tighten relay backpressure accounting Count pending encryption and transport backlog together, and release socket leases before forced termination. * refactor(server): reuse client heartbeat for socket liveness * refactor(server): remove unused websocket close codes --- docs/architecture.md | 4 +- packages/server/src/server/relay-transport.ts | 59 +----- .../websocket-server.liveness.e2e.test.ts | 196 ++++++++++++++++++ .../server/src/server/websocket-server.ts | 152 +++++++++++--- .../websocket/encrypted-relay-socket.test.ts | 120 +++++++++++ .../websocket/encrypted-relay-socket.ts | 100 +++++++++ .../server/websocket/physical-socket.test.ts | 68 ++++++ .../src/server/websocket/physical-socket.ts | 78 +++++++ 8 files changed, 702 insertions(+), 75 deletions(-) create mode 100644 packages/server/src/server/websocket-server.liveness.e2e.test.ts create mode 100644 packages/server/src/server/websocket/encrypted-relay-socket.test.ts create mode 100644 packages/server/src/server/websocket/encrypted-relay-socket.ts create mode 100644 packages/server/src/server/websocket/physical-socket.test.ts create mode 100644 packages/server/src/server/websocket/physical-socket.ts diff --git a/docs/architecture.md b/docs/architecture.md index 0b8afa495ef..c46e463c784 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -209,7 +209,9 @@ There is no dedicated welcome message; the server emits a `status` session messa **Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). -Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. +Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. + +Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id. Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default. diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 4aeca54f38a..9eb8cc85956 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -4,12 +4,12 @@ import { WebSocket } from "ws"; import type pino from "pino"; import { createDaemonChannel, - type EncryptedChannel, type Transport as RelayTransport, type KeyPair, } from "@getpaseo/relay/e2ee"; import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import type { ExternalSocketMetadata } from "./websocket-server.js"; +import { createEncryptedRelaySocket } from "./websocket/encrypted-relay-socket.js"; interface RelayTransportOptions { logger: pino.Logger; @@ -27,8 +27,10 @@ export interface RelayTransportController { interface RelaySocketLike { readyState: number; + bufferedAmount?: number; send: (data: string | Uint8Array | ArrayBuffer) => void; close: (code?: number, reason?: string) => void; + terminate?: () => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void; } @@ -60,18 +62,6 @@ function createDefaultRelayWebSocket(url: string): RelayWebSocketLike { return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS); } -function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): string | ArrayBuffer { - if (typeof data === "string") return data; - if (data instanceof ArrayBuffer) return data; - if (ArrayBuffer.isView(data)) { - const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - const out = new Uint8Array(view.byteLength); - out.set(view); - return out.buffer; - } - return String(data); -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -450,7 +440,12 @@ async function attachEncryptedSocket( emitter.emit("error", error); }, }); - const encryptedSocket = createEncryptedSocket(channel, emitter); + const encryptedSocket = createEncryptedRelaySocket({ + channel, + emitter, + getTransportBufferedAmount: () => socket.bufferedAmount, + terminateTransport: () => socket.terminate(), + }); await attachSocket(encryptedSocket, metadata); attached = true; for (const message of pendingMessages) { @@ -502,42 +497,6 @@ function createRelayTransportAdapter( return relayTransport; } -function createEncryptedSocket(channel: EncryptedChannel, emitter: EventEmitter): RelaySocketLike { - let readyState = 1; - - channel.setState("open"); - - const close = (code?: number, reason?: string) => { - if (readyState === 3) return; - readyState = 3; - channel.close(code, reason); - }; - - emitter.on("close", () => { - if (readyState === 3) return; - readyState = 3; - }); - - return { - get readyState() { - return readyState; - }, - send: (data) => { - const outbound = normalizeRelaySendPayload(data); - void channel.send(outbound).catch((error) => { - emitter.emit("error", error); - }); - }, - close, - on: (event, listener) => { - emitter.on(event, listener); - }, - once: (event, listener) => { - emitter.once(event, listener); - }, - }; -} - function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer { if (!isBinary) { if (typeof data === "string") return data; diff --git a/packages/server/src/server/websocket-server.liveness.e2e.test.ts b/packages/server/src/server/websocket-server.liveness.e2e.test.ts new file mode 100644 index 00000000000..cbbc0f7053f --- /dev/null +++ b/packages/server/src/server/websocket-server.liveness.e2e.test.ts @@ -0,0 +1,196 @@ +import { expect, test } from "vitest"; +import { WebSocket, type RawData } from "ws"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/index.js"; +import { WSOutboundMessageSchema, type WSOutboundMessage } from "./messages.js"; + +const LARGE_REQUEST_BYTES = 512 * 1024; +const BURST_MESSAGE_COUNT = 32; +const TEST_TIMEOUT_MS = 30_000; + +interface SocketClose { + code: number; + reason: string; +} + +class ResumedPhysicalSocketSession { + private replacement: WebSocket | null = null; + + private constructor( + private readonly daemon: TestPaseoDaemon, + private readonly original: WebSocket, + ) {} + + static async launch(): Promise { + const daemon = await createTestPaseoDaemon(); + const original = await connectSocket(daemon.port, "stale-physical-socket"); + return new ResumedPhysicalSocketSession(daemon, original); + } + + async abandonOriginal(): Promise { + this.original.pause(); + } + + async resumeSameClient(): Promise { + this.replacement = await connectSocket(this.daemon.port, "stale-physical-socket"); + } + + async broadcastUntilOriginalCloses(): Promise { + const replacement = this.requireReplacement(); + const originalClose = waitForClose(this.original); + const finalRequestId = largeRequestId(BURST_MESSAGE_COUNT - 1); + const finalResponse = waitForMessage(replacement, (message) => { + return ( + message.type === "session" && + message.message.type === "pong" && + message.message.payload.requestId === finalRequestId + ); + }); + + for (let index = 0; index < BURST_MESSAGE_COUNT; index += 1) { + replacement.send( + JSON.stringify({ + type: "session", + message: { + type: "ping", + requestId: largeRequestId(index), + clientSentAt: index, + }, + }), + ); + } + + await finalResponse; + this.original.resume(); + return originalClose; + } + + async replacementRoundTrip(): Promise { + const replacement = this.requireReplacement(); + const requestId = "replacement-still-active"; + await sendAndWait( + replacement, + { + type: "session", + message: { type: "ping", requestId, clientSentAt: 1 }, + }, + (message) => + message.type === "session" && + message.message.type === "pong" && + message.message.payload.requestId === requestId, + ); + } + + async close(): Promise { + this.original.terminate(); + this.replacement?.terminate(); + await this.daemon.close(); + } + + private requireReplacement(): WebSocket { + if (!this.replacement) throw new Error("Replacement socket is not connected"); + return this.replacement; + } +} + +test( + "a resumed stale socket is bounded and removed without disrupting its replacement", + async () => { + const session = await ResumedPhysicalSocketSession.launch(); + try { + await session.abandonOriginal(); + await session.resumeSameClient(); + + const originalClose = await session.broadcastUntilOriginalCloses(); + + expect(originalClose).toEqual({ code: 1006, reason: "" }); + await session.replacementRoundTrip(); + } finally { + await session.close(); + } + }, + TEST_TIMEOUT_MS, +); + +async function connectSocket(port: number, clientId: string): Promise { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + await waitForOpen(socket); + await sendAndWait( + socket, + { + type: "hello", + clientId, + clientType: "browser", + protocolVersion: 1, + }, + (message) => + message.type === "session" && + message.message.type === "status" && + message.message.payload.status === "server_info", + ); + await sendAndWait(socket, { type: "ping" }, (message) => message.type === "pong"); + return socket; +} + +function largeRequestId(index: number): string { + return `${index}:`.padEnd(LARGE_REQUEST_BYTES, "x"); +} + +function sendAndWait( + socket: WebSocket, + message: unknown, + matches: (message: WSOutboundMessage) => boolean, +): Promise { + const response = waitForMessage(socket, matches); + socket.send(JSON.stringify(message)); + return response; +} + +function waitForOpen(socket: WebSocket): Promise { + return new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); +} + +function waitForClose(socket: WebSocket): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.off("close", onClose); + reject(new Error("Timed out waiting for WebSocket to close")); + }, TEST_TIMEOUT_MS); + const onClose = (code: number, reason: Buffer) => { + clearTimeout(timeout); + resolve({ code, reason: reason.toString() }); + }; + socket.once("close", onClose); + }); +} + +function waitForMessage( + socket: WebSocket, + matches: (message: WSOutboundMessage) => boolean, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for WebSocket message")); + }, TEST_TIMEOUT_MS); + const onMessage = (data: RawData) => { + const parsed = WSOutboundMessageSchema.safeParse(JSON.parse(data.toString())); + if (!parsed.success || !matches(parsed.data)) return; + cleanup(); + resolve(parsed.data); + }; + const onClose = () => { + cleanup(); + reject(new Error("WebSocket closed before the expected message arrived")); + }; + const cleanup = () => { + clearTimeout(timeout); + socket.off("message", onMessage); + socket.off("close", onClose); + }; + socket.on("message", onMessage); + socket.on("close", onClose); + }); +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 0959c0349c3..104eb08e9b6 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -89,6 +89,14 @@ import { } from "@getpaseo/protocol/browser-automation/capabilities"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; +import { + APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS, + ApplicationSocketLease, + MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, + outboundFrameByteLength, + physicalSocketHasCapacity, + sendBoundedPhysicalFrame, +} from "./websocket/physical-socket.js"; const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; @@ -365,6 +373,7 @@ export interface WebSocketLike { bufferedAmount?: number; send: (data: string | Uint8Array | ArrayBuffer) => void; close: (code?: number, reason?: string) => void; + terminate?: () => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void; } @@ -420,6 +429,12 @@ interface SocketSessionOptions { hubRelationships?: HubRelationshipManagement; } +interface ClosePhysicalSocketParams { + ws: WebSocketLike; + logMessage: string; + logFields?: Record; +} + const SLOW_REQUEST_THRESHOLD_MS = 500; const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000; const HELLO_TIMEOUT_MS = 15_000; @@ -520,6 +535,8 @@ export class VoiceAssistantWebSocketServer { private readonly runtimeMetrics = new WebSocketRuntimeMetricsWindow(); private lastRuntimeMetricsSnapshot: WebSocketRuntimeDiagnosticPayload | null = null; private runtimeMetricsInterval: ReturnType | null = null; + private applicationSocketLeaseInterval: ReturnType | null = null; + private readonly applicationSocketLease = new ApplicationSocketLease(); private eventLoopDelayMonitor: ReturnType | null = null; private unsubscribeSpeechReadiness: (() => void) | null = null; private unsubscribeDaemonConfigChange: (() => void) | null = null; @@ -656,6 +673,7 @@ export class VoiceAssistantWebSocketServer { this.wss = this.createWebSocketServer(server, wsConfig, auth); this.startRuntimeMetricsInterval(); + this.startApplicationSocketLeaseInterval(); this.logger.info("WebSocket server initialized on /ws"); } @@ -743,6 +761,19 @@ export class VoiceAssistantWebSocketServer { (runtimeMetricsInterval as unknown as { unref?: () => void }).unref?.(); } + private startApplicationSocketLeaseInterval(): void { + const interval = setInterval(() => { + for (const ws of this.applicationSocketLease.listExpired()) { + this.closePhysicalSocket({ + ws, + logMessage: "Closing physical WebSocket with expired application lease", + }); + } + }, APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS); + this.applicationSocketLeaseInterval = interval; + (interval as unknown as { unref?: () => void }).unref?.(); + } + // Main-loop stall visibility: terminal frames and agent traffic share one event // loop, so delay percentiles here are the ground truth for "the daemon is busy". private snapshotEventLoopDelay(): { p50Ms: number; p99Ms: number; maxMs: number } | null { @@ -819,17 +850,10 @@ export class VoiceAssistantWebSocketServer { } public broadcast(message: WSOutboundMessage): void { - const payload = JSON.stringify(message); - for (const [ws, connection] of this.sessions) { - if (connection.kind !== "trusted") { - continue; - } - // WebSocket.OPEN = 1 - if (ws.readyState === 1) { - ws.send(payload); - this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount); - } - } + const trustedSockets = [...this.sessions] + .filter(([, connection]) => connection.kind === "trusted") + .map(([ws]) => ws); + this.sendMessageToSockets(trustedSockets, message); } public listTrustedSessions(): Session[] { @@ -925,6 +949,11 @@ export class VoiceAssistantWebSocketServer { clearInterval(this.runtimeMetricsInterval); this.runtimeMetricsInterval = null; } + if (this.applicationSocketLeaseInterval) { + clearInterval(this.applicationSocketLeaseInterval); + this.applicationSocketLeaseInterval = null; + } + this.applicationSocketLease.clear(); this.flushRuntimeMetrics({ final: true }); this.eventLoopDelayMonitor?.disable(); this.eventLoopDelayMonitor = null; @@ -994,37 +1023,108 @@ export class VoiceAssistantWebSocketServer { } private sendToClient(ws: WebSocketLike, message: WSOutboundMessage): void { - // WebSocket.OPEN = 1. The check is a fast path; the socket can still - // transition to closed between here and ws.send(), so guard the send too — - // a synchronous throw here would propagate as an uncaughtException. - if (ws.readyState !== 1) { + this.sendMessageToSockets([ws], message); + } + + private sendMessageToSockets(sockets: Iterable, message: WSOutboundMessage): void { + const writableSockets = [...sockets].filter((ws) => this.ensureOutboundCapacity(ws, 0)); + if (writableSockets.length === 0) { return; } + + let payload: string; try { - ws.send(JSON.stringify(message)); - this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount); + payload = JSON.stringify(message); } catch (err) { - this.logger.warn({ err }, "ws_send_failed"); + this.logger.warn({ err }, "ws_serialize_failed"); + return; + } + + const payloadBytes = outboundFrameByteLength(payload); + for (const ws of writableSockets) { + this.sendFrameToClient(ws, payload, payloadBytes, () => { + this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount); + }); } } private sendBinaryToClient(ws: WebSocketLike, frame: Uint8Array): void { + this.sendFrameToClient(ws, frame, outboundFrameByteLength(frame), () => { + this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount); + }); + } + + private sendFrameToClient( + ws: WebSocketLike, + frame: string | Uint8Array, + frameBytes: number, + recordSent: () => void, + ): void { + try { + const sent = sendBoundedPhysicalFrame({ + socket: ws, + frame, + frameBytes, + onHighWater: () => this.closeAtOutboundHighWater(ws), + }); + if (sent) recordSent(); + } catch (err) { + this.logger.warn({ err }, "ws_send_failed"); + } + } + + private ensureOutboundCapacity(ws: WebSocketLike, frameBytes: number): boolean { + if (ws.readyState !== 1) return false; + if (physicalSocketHasCapacity(ws, frameBytes)) return true; + + this.closeAtOutboundHighWater(ws); + return false; + } + + private closeAtOutboundHighWater(ws: WebSocketLike): void { + this.closePhysicalSocket({ + ws, + logMessage: "Closing physical WebSocket at outbound high-water mark", + logFields: { + bufferedAmount: ws.bufferedAmount, + maxBufferedBytes: MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, + }, + }); + } + + private closePhysicalSocket(params: ClosePhysicalSocketParams): void { + const { ws, logMessage, logFields } = params; + this.applicationSocketLease.release(ws); if (ws.readyState !== 1) { return; } + const identity = this.socketIdentities.get(ws); + this.logger.warn( + { + ...(identity ? toConnectionLogFields(identity) : {}), + ...logFields, + }, + logMessage, + ); try { - ws.send(frame); - this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount); + // A close frame queues behind application data, so it cannot enforce a + // hard memory cutoff. Production transports expose terminate(). + if (ws.terminate) { + ws.terminate(); + } else { + ws.close(); + } } catch (err) { - this.logger.warn({ err }, "ws_send_binary_failed"); + this.logger.warn( + { err, ...(identity ? toConnectionLogFields(identity) : {}) }, + "ws_close_failed", + ); } } private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void { const sockets = connection.kind === "trusted" ? connection.sockets : [connection.socket]; - for (const ws of sockets) { - this.sendToClient(ws, message); - } + this.sendMessageToSockets(sockets, message); } private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void { @@ -1521,6 +1621,7 @@ export class VoiceAssistantWebSocketServer { error?: Error; }, ): Promise { + this.applicationSocketLease.release(ws); const identity = this.socketIdentities.get(ws); const identityFields = identity ? toConnectionLogFields(identity) : {}; const pending = this.clearPendingConnection(ws); @@ -1837,6 +1938,8 @@ export class VoiceAssistantWebSocketServer { return; } + this.applicationSocketLease.renew(ws); + const activeConnection = this.sessions.get(ws); const pendingConnection = this.pendingConnections.get(ws); const log = @@ -1872,6 +1975,7 @@ export class VoiceAssistantWebSocketServer { this.recordInboundMessageType(message.type); if (message.type === "ping") { + this.applicationSocketLease.claim(ws); this.sendToClient(ws, { type: "pong" }); return; } diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts new file mode 100644 index 00000000000..3add059fc6e --- /dev/null +++ b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts @@ -0,0 +1,120 @@ +import { EventEmitter } from "node:events"; +import { expect, test } from "vitest"; +import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES } from "./physical-socket.js"; +import { + createEncryptedRelaySocket, + type EncryptedRelayChannel, +} from "./encrypted-relay-socket.js"; + +class BlockingChannel implements EncryptedRelayChannel { + readonly sent: Array = []; + readonly closes: Array<{ code?: number; reason?: string }> = []; + private resolveSend: (() => void) | null = null; + + setState(state: "open"): void { + expect(state).toBe("open"); + } + + send(data: string | ArrayBuffer): Promise { + this.sent.push(data); + return new Promise((resolve) => { + this.resolveSend = resolve; + }); + } + + close(code?: number, reason?: string): void { + this.closes.push({ code, reason }); + } + + drain(): void { + this.resolveSend?.(); + } +} + +test("the encrypted send queue terminates its physical transport at the hard bound", async () => { + const channel = new BlockingChannel(); + let terminations = 0; + const socket = createEncryptedRelaySocket({ + channel, + emitter: new EventEmitter(), + getTransportBufferedAmount: () => 0, + terminateTransport: () => { + terminations += 1; + }, + }); + + socket.send(new Uint8Array(5 * 1024 * 1024)); + expect(channel.sent).toHaveLength(1); + expect(socket.bufferedAmount).toBeGreaterThan(5 * 1024 * 1024); + + socket.send(new Uint8Array(2 * 1024 * 1024)); + + expect(channel.sent).toHaveLength(1); + expect(terminations).toBe(1); + expect(channel.closes).toEqual([]); + expect(socket.readyState).toBe(3); + + channel.drain(); + await Promise.resolve(); +}); + +test("underlying relay backpressure rejects binary before encryption and terminates physically", () => { + const channel = new BlockingChannel(); + let terminations = 0; + const socket = createEncryptedRelaySocket({ + channel, + emitter: new EventEmitter(), + getTransportBufferedAmount: () => MAX_PHYSICAL_SOCKET_BUFFERED_BYTES - 1, + terminateTransport: () => { + terminations += 1; + }, + }); + + socket.send(new Uint8Array(1)); + + expect(channel.sent).toEqual([]); + expect(channel.closes).toEqual([]); + expect(terminations).toBe(1); +}); + +test("pending encryption and underlying relay backpressure share one hard bound", () => { + const channel = new BlockingChannel(); + let transportBufferedAmount = 3 * 1024 * 1024; + let terminations = 0; + const socket = createEncryptedRelaySocket({ + channel, + emitter: new EventEmitter(), + getTransportBufferedAmount: () => transportBufferedAmount, + terminateTransport: () => { + terminations += 1; + }, + }); + + socket.send(new Uint8Array(3 * 1024 * 1024)); + expect(channel.sent).toHaveLength(1); + + transportBufferedAmount = 4 * 1024 * 1024; + socket.send(new Uint8Array(1)); + + expect(channel.sent).toHaveLength(1); + expect(terminations).toBe(1); +}); + +test("explicit encrypted-socket termination forcibly terminates the relay transport", () => { + const channel = new BlockingChannel(); + let terminations = 0; + const socket = createEncryptedRelaySocket({ + channel, + emitter: new EventEmitter(), + getTransportBufferedAmount: () => 0, + terminateTransport: () => { + terminations += 1; + }, + }); + + socket.terminate(); + + expect(terminations).toBe(1); + expect(channel.closes).toEqual([]); + expect(socket.readyState).toBe(3); +}); diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.ts b/packages/server/src/server/websocket/encrypted-relay-socket.ts new file mode 100644 index 00000000000..4b00f8200ab --- /dev/null +++ b/packages/server/src/server/websocket/encrypted-relay-socket.ts @@ -0,0 +1,100 @@ +import { EventEmitter } from "node:events"; +import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, outboundFrameByteLength } from "./physical-socket.js"; + +// NaCl adds a 24-byte nonce and 16-byte authenticator before base64 encoding. +const ENCRYPTED_FRAME_OVERHEAD_BYTES = 40; + +export interface EncryptedRelayChannel { + setState: (state: "open") => void; + send: (data: string | ArrayBuffer) => Promise; + close: (code?: number, reason?: string) => void; +} + +export interface EncryptedRelaySocket { + readonly readyState: number; + readonly bufferedAmount: number; + send: (data: string | Uint8Array | ArrayBuffer) => void; + close: (code?: number, reason?: string) => void; + terminate: () => void; + on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; + once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void; +} + +export function createEncryptedRelaySocket(params: { + channel: EncryptedRelayChannel; + emitter: EventEmitter; + getTransportBufferedAmount: () => number | undefined; + terminateTransport: () => void; +}): EncryptedRelaySocket { + const { channel, emitter, getTransportBufferedAmount, terminateTransport } = params; + let readyState = 1; + let pendingEncryptedBytes = 0; + + channel.setState("open"); + + const terminate = () => { + if (readyState === 3) return; + readyState = 3; + terminateTransport(); + }; + + const close = (code?: number, reason?: string) => { + if (readyState === 3) return; + readyState = 3; + channel.close(code, reason); + }; + + emitter.on("close", () => { + readyState = 3; + }); + + return { + get readyState() { + return readyState; + }, + get bufferedAmount() { + return pendingEncryptedBytes + (getTransportBufferedAmount() ?? 0); + }, + send: (data) => { + if (readyState !== 1) return; + const outbound = normalizeRelaySendPayload(data); + const outboundBytes = encryptedRelayFrameByteLength(outbound); + const queuedBytes = pendingEncryptedBytes + (getTransportBufferedAmount() ?? 0); + if (queuedBytes + outboundBytes > MAX_PHYSICAL_SOCKET_BUFFERED_BYTES) { + terminate(); + return; + } + pendingEncryptedBytes += outboundBytes; + void channel + .send(outbound) + .catch((error) => { + emitter.emit("error", error); + }) + .finally(() => { + pendingEncryptedBytes -= outboundBytes; + }); + }, + close, + terminate, + on: (event, listener) => { + emitter.on(event, listener); + }, + once: (event, listener) => { + emitter.once(event, listener); + }, + }; +} + +function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): string | ArrayBuffer { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return data; + const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const out = new Uint8Array(view.byteLength); + out.set(view); + return out.buffer; +} + +function encryptedRelayFrameByteLength(data: string | ArrayBuffer): number { + const encryptedBytes = outboundFrameByteLength(data) + ENCRYPTED_FRAME_OVERHEAD_BYTES; + return 4 * Math.ceil(encryptedBytes / 3); +} diff --git a/packages/server/src/server/websocket/physical-socket.test.ts b/packages/server/src/server/websocket/physical-socket.test.ts new file mode 100644 index 00000000000..9b334cc112e --- /dev/null +++ b/packages/server/src/server/websocket/physical-socket.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "vitest"; +import { + APPLICATION_SOCKET_LEASE_MS, + ApplicationSocketLease, + MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, + sendBoundedPhysicalFrame, +} from "./physical-socket.js"; + +test("sockets remain exempt until they send an application ping", () => { + let now = 0; + const lease = new ApplicationSocketLease(() => now); + const legacySocket = {}; + now = APPLICATION_SOCKET_LEASE_MS * 10; + + expect(lease.listExpired()).toEqual([]); + lease.renew(legacySocket); + expect(lease.listExpired()).toEqual([]); +}); + +test("inbound activity renews a claimed lease", () => { + let now = 0; + const lease = new ApplicationSocketLease(() => now); + const applicationSocket = {}; + lease.claim(applicationSocket); + + now = APPLICATION_SOCKET_LEASE_MS - 1; + lease.renew(applicationSocket); + now += APPLICATION_SOCKET_LEASE_MS - 1; + expect(lease.listExpired()).toEqual([]); + + now += 1; + expect(lease.listExpired()).toEqual([applicationSocket]); + lease.release(applicationSocket); + expect(lease.listExpired()).toEqual([]); +}); + +test("an application ping claims a socket lease", () => { + let now = 0; + const lease = new ApplicationSocketLease(() => now); + const rawSocket = {}; + + lease.claim(rawSocket); + now = APPLICATION_SOCKET_LEASE_MS; + + expect(lease.listExpired()).toEqual([rawSocket]); +}); + +test("the shared physical send boundary rejects binary above the hard bound", () => { + const sent: Array = []; + let terminated = false; + const socket = { + readyState: 1, + bufferedAmount: MAX_PHYSICAL_SOCKET_BUFFERED_BYTES - 1, + send: (data: string | Uint8Array | ArrayBuffer) => sent.push(data), + }; + + const accepted = sendBoundedPhysicalFrame({ + socket, + frame: new Uint8Array(2), + onHighWater: () => { + terminated = true; + }, + }); + + expect(accepted).toBe(false); + expect(sent).toEqual([]); + expect(terminated).toBe(true); +}); diff --git a/packages/server/src/server/websocket/physical-socket.ts b/packages/server/src/server/websocket/physical-socket.ts new file mode 100644 index 00000000000..eed29ae350c --- /dev/null +++ b/packages/server/src/server/websocket/physical-socket.ts @@ -0,0 +1,78 @@ +// Terminal streams begin snapshot catch-up at 4 MiB. The physical socket gets +// another 4 MiB to recover before the daemon enforces the hard memory bound. +export const MAX_PHYSICAL_SOCKET_BUFFERED_BYTES = 8 * 1024 * 1024; +// Current clients ping every 10 seconds. Four delayed cycles fit inside the +// lease without making an abandoned application socket linger for minutes. +export const APPLICATION_SOCKET_LEASE_MS = 45_000; +export const APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS = 10_000; + +type Clock = () => number; + +export class ApplicationSocketLease { + private readonly deadlines = new Map(); + + constructor(private readonly clock: Clock = Date.now) {} + + claim(socket: TSocket): void { + this.deadlines.set(socket, this.clock() + APPLICATION_SOCKET_LEASE_MS); + } + + renew(socket: TSocket): void { + if (this.deadlines.has(socket)) { + this.claim(socket); + } + } + + release(socket: TSocket): void { + this.deadlines.delete(socket); + } + + listExpired(): TSocket[] { + const now = this.clock(); + const expired: TSocket[] = []; + for (const [socket, deadline] of this.deadlines) { + if (deadline > now) continue; + expired.push(socket); + } + return expired; + } + + clear(): void { + this.deadlines.clear(); + } +} + +export function outboundFrameByteLength(data: string | Uint8Array | ArrayBuffer): number { + if (typeof data === "string") return Buffer.byteLength(data); + return data.byteLength; +} + +interface BoundedPhysicalSocket { + readyState: number; + bufferedAmount?: number; + send: (data: string | Uint8Array | ArrayBuffer) => void; +} + +export function physicalSocketHasCapacity( + socket: Pick, + frameBytes: number, +): boolean { + if (typeof socket.bufferedAmount !== "number") return true; + return socket.bufferedAmount + frameBytes <= MAX_PHYSICAL_SOCKET_BUFFERED_BYTES; +} + +export function sendBoundedPhysicalFrame(params: { + socket: BoundedPhysicalSocket; + frame: string | Uint8Array | ArrayBuffer; + frameBytes?: number; + onHighWater: () => void; +}): boolean { + const { socket, frame, frameBytes = outboundFrameByteLength(frame), onHighWater } = params; + if (socket.readyState !== 1) return false; + if (!physicalSocketHasCapacity(socket, frameBytes)) { + onHighWater(); + return false; + } + socket.send(frame); + return true; +} From 19565f6605a83083b5968cd5dc89e163510e9bed Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Fri, 24 Jul 2026 15:15:50 +0200 Subject: [PATCH 066/420] fix(app): show project name in command center workspace search (#2345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching the command center for a branch name like "master" returned a Workspaces list where every row looked identical: the title was the branch and the subtitle read " · ". On a single-host machine the hostname repeats on every row, so there was no way to tell which project each "master" workspace belonged to. Add the project name to the workspace subtitle (host · project · branch) and gate the host label behind multi-host, matching the Agents section. The host is dropped on single-host setups, so the subtitle reads "project · branch" and rows are distinguishable. Because searchText is derived from the subtitle, typing a project name now matches its workspaces too. Extract the shared join into joinSubtitleParts() and route both the workspace and agent subtitles through it, removing the duplicated filter/join the Agents section hand-rolled. Co-authored-by: Claude Opus 4.8 --- .../app/e2e/command-center-workspaces.spec.ts | 46 ++++++++++++++++++- .../app/src/command-center/command-center.tsx | 27 ++++++----- .../app/src/command-center/results.test.ts | 33 +++++++++++++ packages/app/src/command-center/results.ts | 5 ++ 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/packages/app/e2e/command-center-workspaces.spec.ts b/packages/app/e2e/command-center-workspaces.spec.ts index e03c21a3fb6..d515d366988 100644 --- a/packages/app/e2e/command-center-workspaces.spec.ts +++ b/packages/app/e2e/command-center-workspaces.spec.ts @@ -55,9 +55,15 @@ test.describe("Command center workspaces", () => { ); await expect(row).toBeVisible({ timeout: 30_000 }); await expect(row).toContainText(WORKSPACE_TITLE); - await expect(row).toContainText(PRIMARY_HOST_LABEL); await expect(row).toContainText(WORKSPACE_BRANCH); + // The subtitle disambiguates by project: host · project · branch (multi-host). + const subtitle = row.getByTestId("command-center-workspace-subtitle"); + await expect(subtitle).toContainText(PRIMARY_HOST_LABEL); + await expect(subtitle).toContainText(seeded.projectDisplayName); + await expect(subtitle).toContainText(WORKSPACE_BRANCH); + + // The agent subtitle is unchanged by the shared-helper refactor. const agentRow = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`); await expect(agentRow).toContainText(AGENT_TITLE); await expect(agentRow).toContainText(PRIMARY_HOST_LABEL); @@ -89,6 +95,10 @@ test.describe("Command center workspaces", () => { await expect(agentRow).toBeVisible(); await expect(row).not.toBeVisible(); + // The project name is now part of the workspace searchText. + await input.fill(seeded.projectDisplayName); + await expect(row).toBeVisible(); + await input.fill(AGENT_TITLE); await expect(agentRow).toBeVisible(); await expect(row).not.toBeVisible(); @@ -103,4 +113,38 @@ test.describe("Command center workspaces", () => { await seeded.cleanup(); } }); + + test("single-host workspace subtitle omits the host and shows project · branch", async ({ + page, + }) => { + const seeded = await seedWorkspace({ + repoPrefix: "command-center-workspace-single-", + title: WORKSPACE_TITLE, + }); + + try { + execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], { + cwd: seeded.repoPath, + stdio: "ignore", + }); + const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath); + if (!refreshed.success) { + throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`); + } + + // No secondary host: with a single host, the host label is gated away. + await gotoAppShell(page); + + const panel = await openCommandCenter(page); + const row = panel.getByTestId( + `command-center-workspace-${getServerId()}:${seeded.workspaceId}`, + ); + await expect(row).toBeVisible({ timeout: 30_000 }); + + const subtitle = row.getByTestId("command-center-workspace-subtitle"); + await expect(subtitle).toHaveText(`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`); + } finally { + await seeded.cleanup(); + } + }); }); diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 584c80bfc53..11d59048cb3 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -52,6 +52,7 @@ import type { CommandCenterContribution, CommandCenterIconProps } from "./contri import { useCommandCenterActions, useCommandCenterContributions } from "./provider"; import { buildContributionSections, + joinSubtitleParts, moveActiveResultId, preserveActiveResultId, projectCommandCenterRows, @@ -203,7 +204,7 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe const { t } = useTranslation(); const { agents } = useAggregatedAgents(); const { projects } = useProjects({ enabled: open }); - const showAgentHost = useHosts().length > 1; + const showHost = useHosts().length > 1; return useMemo(() => { if (!open) return []; @@ -213,9 +214,11 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe for (const workspace of host.workspaces) { if (workspace.archivingAt) continue; const title = workspace.title ?? workspace.name; - const subtitle = workspace.currentBranch - ? `${host.serverName} · ${workspace.currentBranch}` - : host.serverName; + const subtitle = joinSubtitleParts([ + showHost ? host.serverName : null, + project.projectName, + workspace.currentBranch, + ]); const searchText = `${title} ${subtitle}`.toLowerCase(); allWorkspaces.push({ kind: "workspace", @@ -251,13 +254,11 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) : undefined; const location = workspaceTitle ?? shortenPath(agent.cwd); - const subtitle = [ - showAgentHost ? agent.serverLabel : null, + const subtitle = joinSubtitleParts([ + showHost ? agent.serverLabel : null, location, formatTimeAgo(agent.lastActivityAt), - ] - .filter((part): part is string => Boolean(part)) - .join(" · "); + ]); return { kind: "agent", id: `agent:${agent.serverId}:${agent.id}`, @@ -282,7 +283,7 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe }, { id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults }, ]; - }, [agents, open, projects, query, showAgentHost, t]); + }, [agents, open, projects, query, showHost, t]); } interface CommandCenterState { @@ -461,7 +462,11 @@ function ResultContent({ result }: { result: CommandCenterResult }) { {result.title} - + {result.subtitle} diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts index 3405a6a5122..0bc8b76262e 100644 --- a/packages/app/src/command-center/results.test.ts +++ b/packages/app/src/command-center/results.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { CommandCenterContribution } from "./contributions"; import { buildContributionSections, + joinSubtitleParts, moveActiveResultId, preserveActiveResultId, projectCommandCenterRows, @@ -103,3 +104,35 @@ describe("Command Center result projection", () => { expect(projection.offsets[151]).toBe(32 + 150 * 56); }); }); + +describe("joinSubtitleParts", () => { + it("joins all present parts with a middle dot", () => { + expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c"); + }); + + it("drops null and undefined parts", () => { + expect(joinSubtitleParts(["host", null, "master"])).toBe("host · master"); + expect(joinSubtitleParts(["host", undefined, "master"])).toBe("host · master"); + }); + + it("drops empty strings (Boolean parity — agents subtitle refactor guard)", () => { + expect(joinSubtitleParts(["", "paseo", "master"])).toBe("paseo · master"); + }); + + it("returns an empty string when every part is null or empty", () => { + expect(joinSubtitleParts([null, undefined, ""])).toBe(""); + }); + + it("returns a single part unchanged, with no separator", () => { + expect(joinSubtitleParts([null, "paseo", null])).toBe("paseo"); + }); + + it("builds the workspace subtitle in host · project · branch order", () => { + // Single-host: host gated away, project leads. + expect(joinSubtitleParts([null, "paseo", "master"])).toBe("paseo · master"); + // Multi-host: host first, then project, then branch. + expect(joinSubtitleParts(["host", "paseo", "master"])).toBe("host · paseo · master"); + // No branch: degrades to project (or host · project). + expect(joinSubtitleParts([null, "paseo", null])).toBe("paseo"); + }); +}); diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts index ab2f3a837f6..da3ea5c2508 100644 --- a/packages/app/src/command-center/results.ts +++ b/packages/app/src/command-center/results.ts @@ -63,6 +63,11 @@ function matchesQuery(searchText: string, query: string): boolean { return !normalized || searchText.includes(normalized); } +/** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */ +export function joinSubtitleParts(parts: readonly (string | null | undefined)[]): string { + return parts.filter((part): part is string => Boolean(part)).join(" · "); +} + function contributionSearchText(contribution: CommandCenterContribution): string { const presentationText = contribution.presentation.kind === "action" From 99440201bd91068c6f035d1194b5a96e6d0349ec Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Fri, 24 Jul 2026 15:16:05 +0200 Subject: [PATCH 067/420] fix(server): show Claude model-scoped weekly usage limits (#2303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude usage card showed only Session and Weekly. Anthropic moved model-scoped weekly limits out of the top-level `seven_day_` keys into a `limits[]` array, and `seven_day_opus` / `seven_day_omelette` now return null, so the scoped bar silently disappeared. Nothing errored, so the card looked healthy while under-reporting. Scoped limits are now read from `limits[]`, restoring a `Weekly · Fable` bar. Session and all-models weekly keep coming from the top-level keys, which is also where the Claude CLI reads them from. A response mid-migration can describe one limit twice, as a legacy key and as a `limits[]` entry, so both shapes normalize into a single `ScopedLimit` and one predicate decides whether two are the same limit: same dimension, same id when both carry one, else same normalized name. That is the only comparison in the file. Comparing display labels would conflate a surface with a model of the same name, and collapse ids that differ only by punctuation. The `limits[]` entry supplies identity, since that is the shape the API is migrating towards. Its `percent` and `resets_at` are nullable, so each field falls back to the legacy twin rather than discarding a value the response did carry. Legacy scoped windows adopt the same id scheme, so `weekly_opus` becomes `weekly_model_opus`. Window ids are internal React keys, not user-facing, and unifying them is what lets a limit keep one id whichever shape of the response carried it. Two supporting changes: - `limits[]` entries are validated one at a time. The response goes through a single parse, so one malformed entry would otherwise throw and take the windows that already parsed with it. - The provider stores the logger it was already handed and warns when a response parses but yields no windows, when a scope resolves to no name, and when an entry is unparseable. This failure was silent at every level, which is why it went unnoticed. Warn rather than debug, because file logging defaults to info. Scoped windows render even at 0% and inactive, so a bar does not appear and disappear between refreshes. Closes #2302 Co-authored-by: Claude Opus 4.8 --- .../quota-fetcher/providers/claude.ts | 297 ++++++++++++--- .../services/quota-fetcher/service.test.ts | 352 +++++++++++++++++- 2 files changed, 604 insertions(+), 45 deletions(-) diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/services/quota-fetcher/providers/claude.ts index 1684f787a17..0ff4e9b8b70 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ b/packages/server/src/services/quota-fetcher/providers/claude.ts @@ -41,11 +41,29 @@ const ClaudeUsageWindowSchema = z.object({ resets_at: z.string().nullish(), }); +// Model- and surface-scoped weekly limits live in a `limits[]` array rather than a +// top-level `seven_day_` key. Entries are validated one at a time (see +// scopedLimitsFromResponse) so a single malformed or newly-shaped entry cannot take down +// the windows that already parsed from the top-level keys. +const ClaudeScopeLabelSchema = z + .object({ id: z.string().nullish(), display_name: z.string().nullish() }) + .nullish(); + +const ClaudeLimitSchema = z.object({ + kind: z.string(), + percent: ApiNumberSchema.nullish(), + resets_at: z.string().nullish(), + scope: z.object({ model: ClaudeScopeLabelSchema, surface: ClaudeScopeLabelSchema }).nullish(), +}); + const ClaudeUsageResponseSchema = z.object({ five_hour: ClaudeUsageWindowSchema.nullish(), seven_day: ClaudeUsageWindowSchema.nullish(), seven_day_opus: ClaudeUsageWindowSchema.nullish(), seven_day_omelette: ClaudeUsageWindowSchema.nullish(), + // Deliberately permissive: an additive section must never regress the top-level + // windows, so shape validation happens per entry rather than here. + limits: z.array(z.unknown()).nullish(), extra_usage: z .object({ is_enabled: z.boolean().optional(), @@ -61,6 +79,9 @@ const ClaudeTokenRefreshSchema = z.object({ type ClaudeCredentials = z.infer; type ClaudeUsageResponse = z.infer; type ClaudeTokenRefresh = z.infer; +type ClaudeLimit = z.infer; + +const SCOPED_WEEKLY_KIND = "weekly_scoped"; interface ClaudeCredentialRecord { oauth: { accessToken: string } & NonNullable; @@ -85,6 +106,197 @@ function buildClaudePlan( return tier ? `${label} ${tier}` : label; } +/** + * A weekly limit scoped to one model or one surface, normalized away from whichever + * shape of the response described it. + * + * The API describes the same limit two ways during the migration: a legacy top-level + * `seven_day_` key, and an entry in `limits[]`. Everything downstream works on + * this one representation so the two shapes are reconciled exactly once, in + * `reconcileScopedLimits`, rather than at each place a window is built. + */ +interface ScopedLimit { + dimension: "model" | "surface"; + /** The API's own identifier. Null on every response observed so far. */ + id: string | null; + /** Display name, or the id when the API sends no display name. */ + name: string; + usedPct: number | null; + resetsAt: string | null; +} + +// Windows that describe no particular model or surface. +const UNSCOPED_WINDOWS: ReadonlyArray<{ + field: "five_hour" | "seven_day"; + id: string; + label: string; +}> = [ + { field: "five_hour", id: "five_hour", label: "Session" }, + { field: "seven_day", id: "weekly", label: "Weekly" }, +]; + +// Scoped windows from before `limits[]` existed. Declaring the dimension here is what +// stops a *surface* named "Omelette" from being mistaken for the legacy Omelette *model* +// window: these keys are model-scoped by definition. +const LEGACY_SCOPED_WINDOWS: ReadonlyArray<{ + field: "seven_day_opus" | "seven_day_omelette"; + name: string; +}> = [ + { field: "seven_day_opus", name: "Opus" }, + { field: "seven_day_omelette", name: "Omelette" }, +]; + +/** Fold a name down to the characters an id is allowed to carry. */ +function normalizeName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +/** + * Whether two descriptions denote the same limit. This is the single definition of + * identity for scoped limits; nothing else may compare them, and in particular nothing + * may compare display labels, which are presentation rather than identity. + * + * - Different dimensions are never the same limit, so a surface and a model sharing a + * name stay apart. + * - When both sides carry the API's own id, that id decides, so `fable-pro` and + * `fable_pro` stay apart. + * - Otherwise fall back to the normalized name, which is the only link available between + * a legacy key (never has an id) and its `limits[]` counterpart. + */ +function isSameLimit(a: ScopedLimit, b: ScopedLimit): boolean { + if (a.dimension !== b.dimension) return false; + if (a.id && b.id) return a.id === b.id; + return normalizeName(a.name) === normalizeName(b.name); +} + +/** + * Merge the legacy and `limits[]` descriptions into one limit per identity. + * + * A `limits[]` entry wins on identity because that is the representation the API is + * migrating towards, so a limit keeps the same window id whichever shape carried it. + * Its values are nullable though, so each field falls back to the legacy twin instead of + * discarding a number the response did contain. + */ +function reconcileScopedLimits( + legacy: ScopedLimit[], + fromLimitsArray: ScopedLimit[], +): ScopedLimit[] { + const reconciled = [...legacy]; + for (const limit of fromLimitsArray) { + const index = reconciled.findIndex((candidate) => isSameLimit(candidate, limit)); + if (index === -1) { + reconciled.push(limit); + continue; + } + const twin = reconciled[index]; + reconciled[index] = { + ...limit, + usedPct: limit.usedPct ?? twin?.usedPct ?? null, + resetsAt: limit.resetsAt ?? twin?.resetsAt ?? null, + }; + } + return reconciled; +} + +function scopedLimitFromLegacy( + spec: (typeof LEGACY_SCOPED_WINDOWS)[number], + window: z.infer, +): ScopedLimit { + return { + dimension: "model", + id: null, + name: spec.name, + usedPct: window.utilization, + resetsAt: window.resets_at ?? null, + }; +} + +/** The scope of a `limits[]` entry, or null when it names nothing renderable. */ +function scopedLimitFromEntry(limit: ClaudeLimit): ScopedLimit | null { + for (const dimension of ["model", "surface"] as const) { + const entry = limit.scope?.[dimension]; + const id = entry?.id?.trim() || null; + const name = entry?.display_name?.trim() || id; + if (name) { + return { + dimension, + id, + name, + usedPct: limit.percent ?? null, + resetsAt: limit.resets_at ?? null, + }; + } + } + return null; +} + +// The client uses window ids as React keys, so they must be stable across refreshes and +// unique within a response. An API-supplied id is already an identifier and is used +// verbatim (ids elsewhere carry punctuation too, e.g. MiniMax's `interval_MiniMax-M2.7`); +// only a name fallback is normalized. Normalizing an id would collapse `fable-pro` and +// `fable_pro` into one window. +function scopedWindowId(limit: ScopedLimit): string { + return `weekly_${limit.dimension}_${limit.id ?? normalizeName(limit.name)}`; +} + +// Backstop for the one residual case identity cannot rule out: an entry whose verbatim id +// equals another entry's normalized name. Suffix rather than drop, because a missing bar +// is the bug this change exists to fix. +function uniqueWindowId(candidate: string, taken: Set): string { + if (!taken.has(candidate)) return candidate; + for (let suffix = 2; ; suffix += 1) { + const next = `${candidate}_${suffix}`; + if (!taken.has(next)) return next; + } +} + +function legacyScopedLimits(resp: ClaudeUsageResponse): ScopedLimit[] { + const limits: ScopedLimit[] = []; + for (const spec of LEGACY_SCOPED_WINDOWS) { + const window = resp[spec.field]; + if (window) limits.push(scopedLimitFromLegacy(spec, window)); + } + return limits; +} + +function unscopedWindows(resp: ClaudeUsageResponse): ProviderUsageWindow[] { + const windows: ProviderUsageWindow[] = []; + for (const spec of UNSCOPED_WINDOWS) { + const window = resp[spec.field]; + if (!window) continue; + windows.push( + windowFromUsedPct({ + id: spec.id, + label: spec.label, + utilizationPct: window.utilization, + resetsAt: window.resets_at ?? null, + tone: toneFromUsedPct(window.utilization), + }), + ); + } + return windows; +} + +function scopedWindows(limits: ScopedLimit[]): ProviderUsageWindow[] { + const taken = new Set(); + return limits.map((limit) => { + const id = uniqueWindowId(scopedWindowId(limit), taken); + taken.add(id); + // Emitted even at 0% and inactive: a zero bar answers "how much of this model have I + // used", and the bar must not come and go between refreshes. + return windowFromUsedPct({ + id, + label: `Weekly \u00b7 ${limit.name}`, + utilizationPct: limit.usedPct, + resetsAt: limit.resetsAt, + tone: toneFromUsedPct(limit.usedPct), + }); + }); +} + async function readClaudeKeychainCredentials(): Promise { try { const { stdout } = await execFileAsync( @@ -104,12 +316,14 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { readonly providerId = "claude"; readonly displayName = "Claude"; + private readonly logger: Logger; private readonly claudeHome: string; private readonly readKeychainCredentials: () => Promise; private readonly platform: typeof process.platform; private readonly fetchApi: ProviderApiFetch; constructor(options: ClaudeQuotaProviderOptions) { + this.logger = options.logger.child({ module: "claude-quota-provider" }); this.claudeHome = options.claudeHome || process.env["CLAUDE_HOME"] || join(homedir(), ".claude"); this.readKeychainCredentials = options.claudeKeychainReader ?? readClaudeKeychainCredentials; @@ -149,50 +363,17 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { } } - const windows: ProviderUsageWindow[] = []; - if (resp.five_hour) { - windows.push( - windowFromUsedPct({ - id: "five_hour", - label: "Session", - utilizationPct: resp.five_hour.utilization, - resetsAt: resp.five_hour.resets_at ?? null, - tone: toneFromUsedPct(resp.five_hour.utilization), - }), - ); - } - if (resp.seven_day) { - windows.push( - windowFromUsedPct({ - id: "weekly", - label: "Weekly", - utilizationPct: resp.seven_day.utilization, - resetsAt: resp.seven_day.resets_at ?? null, - tone: toneFromUsedPct(resp.seven_day.utilization), - }), - ); - } - if (resp.seven_day_opus) { - windows.push( - windowFromUsedPct({ - id: "weekly_opus", - label: "Weekly · Opus", - utilizationPct: resp.seven_day_opus.utilization, - resetsAt: resp.seven_day_opus.resets_at ?? null, - tone: toneFromUsedPct(resp.seven_day_opus.utilization), - }), - ); - } - if (resp.seven_day_omelette) { - windows.push( - windowFromUsedPct({ - id: "weekly_omelette", - label: "Weekly · Omelette", - utilizationPct: resp.seven_day_omelette.utilization, - resetsAt: resp.seven_day_omelette.resets_at ?? null, - tone: toneFromUsedPct(resp.seven_day_omelette.utilization), - }), - ); + const scoped = reconcileScopedLimits( + legacyScopedLimits(resp), + this.scopedLimitsFromResponse(resp.limits), + ); + const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)]; + + if (windows.length === 0) { + // The response parsed but described nothing. That silence is how the previous + // shape change went unnoticed, so make it greppable. `warn` and not `debug` + // because file logging defaults to `info`. + this.logger.warn("Claude usage response parsed but produced no windows"); } const details: ProviderUsageDetail[] = []; @@ -217,6 +398,34 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { }; } + /** + * Scoped limits carried by `limits[]`. + * + * Entries are validated one at a time so a single malformed or newly-shaped entry + * cannot fail the whole response and take the windows that already parsed with it. + */ + private scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] { + if (!limits) return []; + + const parsed: ScopedLimit[] = []; + for (const entry of limits) { + const result = ClaudeLimitSchema.safeParse(entry); + if (!result.success) { + this.logger.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry"); + continue; + } + if (result.data.kind !== SCOPED_WEEKLY_KIND) continue; + + const limit = scopedLimitFromEntry(result.data); + if (!limit) { + this.logger.warn("Skipping scoped Claude usage limit with no resolvable scope name"); + continue; + } + parsed.push(limit); + } + return parsed; + } + private async readCredentials(): Promise { const credPath = join(this.claudeHome, ".credentials.json"); diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 5dfc1840801..7b1044cef10 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -425,7 +425,7 @@ describe("real provider usage fetchers", () => { windows: expect.arrayContaining([ expect.objectContaining({ id: "five_hour", usedPct: 11 }), expect.objectContaining({ id: "weekly", usedPct: 1 }), - expect.objectContaining({ id: "weekly_opus", usedPct: 0.5 }), + expect.objectContaining({ id: "weekly_model_opus", usedPct: 0.5 }), ]), }); expect(fetchApi).toHaveBeenCalledWith( @@ -1025,3 +1025,353 @@ describe("usage bars escalate as they fill", () => { ); }); }); + +// Model- and surface-scoped weekly limits arrive in a `limits[]` array rather than the +// top-level `seven_day_*` keys, which now return null on most accounts. +describe("ClaudeQuotaProvider scoped weekly limits", () => { + let claudeHome: string; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-limits-")); + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + }); + + function fableLimit(overrides: Record = {}) { + return { + kind: "weekly_scoped", + group: "weekly", + percent: 0, + severity: "normal", + resets_at: "2026-06-04T00:00:00Z", + is_active: false, + scope: { model: { id: null, display_name: "Fable" }, surface: null }, + ...overrides, + }; + } + + function claudeProvider(body: unknown) { + writeClaudeCredentials(claudeHome, "at_valid"); + const logger = createLogger() as unknown as { warn: ReturnType }; + const provider = new ClaudeQuotaProvider({ + logger: logger as never, + claudeHome, + claudeKeychainReader: async () => null, + fetch: mockFetch( + new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), + ), + }); + return { provider, logger }; + } + + it("renders a scoped weekly limit as its own window", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_model_fable", label: "Weekly · Fable" }), + ); + }); + + it("renders a scoped window that is at zero and inactive", async () => { + const { provider } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit({ percent: 0, is_active: false })], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_model_fable", usedPct: 0, remainingPct: 100 }), + ); + }); + + it("ignores session and all-models entries so they do not duplicate the top-level windows", async () => { + const { provider } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [ + { kind: "session", percent: 6, resets_at: "2026-06-01T21:00:00Z", scope: null }, + { kind: "weekly_all", percent: 23, resets_at: "2026-06-04T00:00:00Z", scope: null }, + fableLimit(), + ], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => window.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_fable", + ]); + }); + + it("labels a surface-scoped limit from its surface name", async () => { + const { provider } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [ + fableLimit({ scope: { model: null, surface: { id: "code", display_name: "Code" } } }), + ], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toContainEqual( + expect.objectContaining({ id: "weekly_surface_code", label: "Weekly · Code" }), + ); + }); + + it("skips a scoped limit with no resolvable label rather than rendering an unlabelled bar", async () => { + const { provider, logger } = claudeProvider({ + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [fableLimit({ scope: { model: { id: null, display_name: null }, surface: null } })], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows.map((window) => window.id)).toEqual(["weekly"]); + expect(logger.warn).toHaveBeenCalled(); + }); + + // Regression: an additive section must never take down data that already parsed. + it("keeps the top-level windows when a limits entry is malformed", async () => { + const { provider, logger } = claudeProvider({ + five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, + seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, + limits: [{ percent: "not-a-kind" }, fableLimit()], + }); + + const usage = await provider.fetchUsage(); + + expect(usage.status).toBe("available"); + expect(usage.windows.map((window) => window.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_fable", + ]); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("warns when a successful response describes no windows at all", async () => { + const { provider, logger } = claudeProvider({ limits: [] }); + + const usage = await provider.fetchUsage(); + + expect(usage.windows).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + "Claude usage response parsed but produced no windows", + ); + }); +}); +/** + * The reconciliation matrix. + * + * Four review rounds on PR #2303 each found a different hole in how the two + * representations of one scoped limit get combined, because each fix was tested against + * the case that was reported rather than the space of cases. This walks the space: + * every combination of which representation carries the limit, whether the `limits[]` + * entry supplies values, and whether the two descriptions denote the same limit at all. + */ +describe("ClaudeQuotaProvider scoped limit reconciliation", () => { + let claudeHome: string; + + beforeEach(() => { + claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-matrix-")); + }); + + afterEach(() => { + rmSync(claudeHome, { recursive: true, force: true }); + }); + + const RESETS = "2026-06-04T00:00:00Z"; + + function scoped(scope: unknown, percent: number | null = 30, resetsAt: string | null = RESETS) { + return { kind: "weekly_scoped", percent, resets_at: resetsAt, scope }; + } + + const model = (name: string | null, id: string | null = null) => ({ + model: { id, display_name: name }, + surface: null, + }); + const surface = (name: string | null, id: string | null = null) => ({ + model: null, + surface: { id, display_name: name }, + }); + + async function windowsFor(body: Record) { + writeClaudeCredentials(claudeHome, "at_valid"); + const usage = await new ClaudeQuotaProvider({ + logger: createLogger(), + claudeHome, + claudeKeychainReader: async () => null, + fetch: mockFetch( + new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), + ), + }).fetchUsage(); + return usage.windows; + } + + it("which representation carries the limit: legacy only", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + }); + expect(windows).toEqual([ + expect.objectContaining({ + id: "weekly_model_omelette", + label: "Weekly · Omelette", + usedPct: 12, + }), + ]); + }); + + it("which representation carries the limit: limits[] only", async () => { + const windows = await windowsFor({ limits: [scoped(model("Fable"), 2)] }); + expect(windows).toEqual([ + expect.objectContaining({ + id: "weekly_model_fable", + label: "Weekly · Fable", + usedPct: 2, + }), + ]); + }); + + it("which representation carries the limit: both, same limit — one bar, scoped identity", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + limits: [scoped(model("Omelette"), 30)], + }); + expect(windows).toEqual([ + expect.objectContaining({ id: "weekly_model_omelette", usedPct: 30 }), + ]); + }); + + it("which representation carries the limit: both, different limits — two bars", async () => { + const windows = await windowsFor({ + seven_day_opus: { utilization: 8, resets_at: RESETS }, + limits: [scoped(model("Fable"), 2)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_opus", "weekly_model_fable"]); + }); + + it("which representation carries the limit: neither", async () => { + const windows = await windowsFor({ seven_day: { utilization: 23, resets_at: RESETS } }); + expect(windows.map((w) => w.id)).toEqual(["weekly"]); + }); + + const legacy = { seven_day_omelette: { utilization: 12, resets_at: RESETS } }; + + it("value fallback when the scoped entry is sparse: scoped values win when present", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), 30, "2026-06-09T00:00:00Z")], + }); + expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: "2026-06-09T00:00:00Z" }); + }); + + it("value fallback when the scoped entry is sparse: percentage falls back per field", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), null, "2026-06-09T00:00:00Z")], + }); + expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: "2026-06-09T00:00:00Z" }); + }); + + it("value fallback when the scoped entry is sparse: reset time falls back per field", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), 30, null)], + }); + expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: RESETS }); + }); + + it("value fallback when the scoped entry is sparse: both fall back when the scoped entry only names the limit", async () => { + const windows = await windowsFor({ + ...legacy, + limits: [scoped(model("Omelette"), null, null)], + }); + expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: RESETS }); + }); + + it("value fallback when the scoped entry is sparse: stays empty when neither side has a value", async () => { + const windows = await windowsFor({ limits: [scoped(model("Fable"), null, null)] }); + expect(windows[0]).toMatchObject({ id: "weekly_model_fable", usedPct: null }); + }); + + it("identity: a surface never matches a legacy model window of the same name", async () => { + const windows = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + limits: [scoped(surface("Omelette"), 30)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_omelette", "weekly_surface_omelette"]); + expect(windows[0]).toMatchObject({ usedPct: 12 }); + expect(windows[1]).toMatchObject({ usedPct: 30 }); + }); + + it("identity: a model and a surface of the same name stay apart", async () => { + const windows = await windowsFor({ + limits: [scoped(model("Code"), 4), scoped(surface("Code"), 9)], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_code", "weekly_surface_code"]); + }); + + it("identity: ids decide when both sides have one", async () => { + const windows = await windowsFor({ + limits: [ + scoped(model("Fable-Pro", "fable-pro"), 4), + scoped(model("Fable_Pro", "fable_pro"), 9), + ], + }); + expect(windows.map((w) => w.id)).toEqual(["weekly_model_fable-pro", "weekly_model_fable_pro"]); + }); + + it("identity: names decide when ids are absent, so indistinguishable entries merge", async () => { + const windows = await windowsFor({ + limits: [scoped(model("Fable Pro"), 4), scoped(model("Fable-Pro"), 9)], + }); + expect(windows).toEqual([ + expect.objectContaining({ id: "weekly_model_fable_pro", usedPct: 9 }), + ]); + }); + + it("identity: a renamed scope keeps its id when the API supplies one", async () => { + const before = await windowsFor({ limits: [scoped(model("Fable", "fable"), 2)] }); + const after = await windowsFor({ limits: [scoped(model("Fable 5", "fable"), 2)] }); + expect(before[0]?.id).toBe("weekly_model_fable"); + expect(after[0]?.id).toBe("weekly_model_fable"); + expect(after[0]?.label).toBe("Weekly · Fable 5"); + }); + + it("identity: a limit keeps one id whichever representation carries it", async () => { + const viaLegacy = await windowsFor({ + seven_day_omelette: { utilization: 12, resets_at: RESETS }, + }); + const viaLimits = await windowsFor({ limits: [scoped(model("Omelette"), 12)] }); + expect(viaLegacy[0]?.id).toBe(viaLimits[0]?.id); + }); + + it("ordering and unscoped windows: puts session and weekly ahead of the scoped bars", async () => { + const windows = await windowsFor({ + five_hour: { utilization: 6, resets_at: RESETS }, + seven_day: { utilization: 23, resets_at: RESETS }, + seven_day_opus: { utilization: 8, resets_at: RESETS }, + limits: [ + { kind: "session", percent: 6, resets_at: RESETS, scope: null }, + { kind: "weekly_all", percent: 23, resets_at: RESETS, scope: null }, + scoped(model("Fable"), 2), + ], + }); + expect(windows.map((w) => w.id)).toEqual([ + "five_hour", + "weekly", + "weekly_model_opus", + "weekly_model_fable", + ]); + }); +}); From cf36b2cc40effd66206688bca0ef7979c5480eab Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Fri, 24 Jul 2026 15:16:37 +0200 Subject: [PATCH 068/420] fix(app): pin the active workspace from a collapsed sidebar section (#2299) Cmd+Shift+P did nothing whenever the active workspace's sidebar row was not rendered. The `workspace.pin` handler was registered by the row itself, gated on `selected && canPin`, so collapsing the row's section unmounted the only handler for the action and the dispatcher found zero candidates. The keypress was swallowed with no toast and no error. Move the action to a single always-mounted handler keyed on the active route selection, following the existing `useGlobalNewWorkspaceAction` / `useActiveWorktreeNewAction` pattern, and delete the two per-row registrations. Besides the reported case this also fixes a collapsed status group, a collapsed Pinned section (so unpinning works too), and focus mode. The handler lives in a headless component rather than being called from the root layout, so subscribing to the active workspace's pin state does not re-render the whole app shell. Two supporting changes: - The controller now takes a narrow `PinnableWorkspace` instead of a full `SidebarWorkspaceEntry`, so a caller without a sidebar row can build one. Its in-flight guard moves to module scope, because the row menus and the shortcut hold separate controller instances and a per-instance guard would let a keypress and a menu click fire two concurrent, opposite RPCs. - The handler resolves the descriptor id via `useWorkspaceFields` rather than reusing the route id. The route carries an opaque workspace id that is not guaranteed to equal the descriptor id, which is why `selectWorkspace` resolves it through `resolveWorkspaceMapKeyByIdentity`. Both the RPC and the in-flight key need the descriptor id so that rows and this handler agree on one identity. `workspace.archive` has the same row-scoped defect and is deliberately left alone: it carries per-row state (`isArchiving`, optimistic hiding, the risky-worktree confirm) and needs its own change. Covered by a Playwright spec: the three collapse cases fail without this fix, plus one-RPC-per-press and a rejected-pin case that asserts the error toast and that the next press still succeeds. Co-authored-by: Claude Opus 4.8 --- .../sidebar-workspace-pin-shortcut.spec.ts | 266 ++++++++++++++++++ packages/app/src/app/_layout.tsx | 2 + .../src/components/sidebar-workspace-list.tsx | 11 - .../sidebar/sidebar-status-list.tsx | 11 - .../workspace-pin-shortcut-handler.tsx | 9 + .../hooks/use-global-workspace-pin-action.ts | 64 +++++ .../src/hooks/use-sidebar-workspace-pin.ts | 27 +- 7 files changed, 360 insertions(+), 30 deletions(-) create mode 100644 packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts create mode 100644 packages/app/src/components/workspace-pin-shortcut-handler.tsx create mode 100644 packages/app/src/hooks/use-global-workspace-pin-action.ts diff --git a/packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts b/packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts new file mode 100644 index 00000000000..b9a0bb89e89 --- /dev/null +++ b/packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts @@ -0,0 +1,266 @@ +import { test, expect, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { daemonWsRoutePattern } from "./helpers/daemon-port"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; + +// The pin shortcut used to be registered by the sidebar row itself, so it silently did nothing +// whenever the row was unmounted — a collapsed project section being the common case. It now +// lives in a single always-mounted handler keyed on the active route selection. +const PIN_SHORTCUT = "ControlOrMeta+Shift+P"; + +function workspaceRow(page: Page, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); +} + +function pinnedSection(page: Page) { + return page.getByTestId("sidebar-pinned-section"); +} + +// Opens the workspace so it becomes the active route selection, which is what the shortcut acts on. +async function openWorkspace(page: Page, workspaceId: string) { + const row = workspaceRow(page, workspaceId); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); +} + +// The project key is host-scoped and not exposed by the seed helper, so the header is addressed by +// its display name, scoped to project rows so a workspace row can never match. Pressing the header +// toggles the section, which unmounts every workspace row under it. +async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise { + const header = page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: project.projectDisplayName }); + await expect(header).toHaveCount(1, { timeout: 30_000 }); + + await header.click(); + await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 }); +} + +async function switchToStatusGrouping(page: Page): Promise { + await page.getByTestId("sidebar-display-preferences-menu").click(); + await page.getByTestId("sidebar-grouping-status").click(); + await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 }); +} + +// Status mode buckets workspaces by state rather than project, so the group holding this workspace +// is discovered from the rows container it sits in rather than assumed. +async function collapseStatusGroupContaining(page: Page, workspaceId: string): Promise { + const rows = page + .locator('[data-testid^="sidebar-status-group-rows-"]') + .filter({ has: workspaceRow(page, workspaceId) }); + await expect(rows).toHaveCount(1, { timeout: 30_000 }); + + const rowsTestId = await rows.getAttribute("data-testid"); + const bucket = rowsTestId?.replace("sidebar-status-group-rows-", ""); + expect(bucket).toBeTruthy(); + + await page.getByTestId(`sidebar-status-group-${bucket}`).click(); + await expect(workspaceRow(page, workspaceId)).toHaveCount(0, { timeout: 10_000 }); +} + +function readSessionMessage( + message: string | Buffer, +): { type?: unknown; requestId?: unknown } | null { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown }; + if (envelope.type !== "session" || typeof envelope.message !== "object") { + return null; + } + return envelope.message as { type?: unknown; requestId?: unknown }; + } catch { + return null; + } +} + +const PIN_REJECTION_MESSAGE = "Pin rejected by test."; + +interface PinRpcGate { + /** Pin requests the client has sent so far. */ + sentCount(): number; +} + +// Proxies everything so the app boots against the real daemon, counting pin RPCs and optionally +// rejecting the first `rejectFirst` of them. The count asserts how many pins one keypress actually +// dispatched, which the rendered pin state cannot show — a toggle that fired twice lands back +// where it started. +async function installPinRpcGate( + page: Page, + options: { rejectFirst?: number } = {}, +): Promise { + const rejectFirst = options.rejectFirst ?? 0; + let sent = 0; + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((message) => { + const sessionMessage = readSessionMessage(message); + if ( + sessionMessage?.type === "workspace.pin.set.request" && + typeof sessionMessage.requestId === "string" + ) { + sent += 1; + if (sent <= rejectFirst) { + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "rpc_error", + payload: { + requestId: sessionMessage.requestId, + requestType: "workspace.pin.set.request", + error: PIN_REJECTION_MESSAGE, + code: "transport", + }, + }, + }), + ); + return; + } + } + + try { + server.send(message); + } catch { + // server socket already closed + } + }); + + server.onMessage((message) => { + try { + ws.send(message); + } catch { + // client socket already closed + } + }); + }); + + return { sentCount: () => sent }; +} + +test.describe("Pin workspace shortcut", () => { + test("pins the active workspace while its project section is collapsed", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" }); + + try { + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + await collapseProjectSection(page, workspace); + + await page.keyboard.press(PIN_SHORTCUT); + + await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 }); + await expect( + pinnedSection(page).getByTestId( + `sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`, + ), + ).toBeVisible(); + } finally { + await workspace.cleanup(); + } + }); + + test("unpins the active workspace while the Pinned section is collapsed", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-unpin-" }); + + try { + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + + await page.keyboard.press(PIN_SHORTCUT); + await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 }); + + await page.getByTestId("sidebar-pinned-section-header").click(); + await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, { timeout: 10_000 }); + + await page.keyboard.press(PIN_SHORTCUT); + + await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 }); + } finally { + await workspace.cleanup(); + } + }); + + test("sends exactly one pin RPC per press when the row is rendered and selected", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" }); + + try { + const gate = await installPinRpcGate(page); + + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + + await page.keyboard.press(PIN_SHORTCUT); + await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 }); + // Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight + // guard, or a second dispatch path. It cannot detect a duplicate handler registration: + // `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a + // shadowed second handler is unobservable from outside by design. + expect(gate.sentCount()).toBe(1); + + await page.keyboard.press(PIN_SHORTCUT); + await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 }); + expect(gate.sentCount()).toBe(2); + await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1); + } finally { + await workspace.cleanup(); + } + }); + + test("pins the active workspace while its status group is collapsed", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" }); + + try { + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + await switchToStatusGrouping(page); + await collapseStatusGroupContaining(page, workspace.workspaceId); + + await page.keyboard.press(PIN_SHORTCUT); + + await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 }); + await expect( + pinnedSection(page).getByTestId( + `sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`, + ), + ).toBeVisible(); + } finally { + await workspace.cleanup(); + } + }); + + test("shows an error toast when the host rejects the pin, and the next press succeeds", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-failure-" }); + + try { + const gate = await installPinRpcGate(page, { rejectFirst: 1 }); + + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + + await page.keyboard.press(PIN_SHORTCUT); + + await expect(page.getByTestId("app-toast-message")).toContainText(PIN_REJECTION_MESSAGE, { + timeout: 10_000, + }); + await expect(pinnedSection(page)).toHaveCount(0); + await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1); + + // The failure must leave the action usable: the in-flight guard has to release the key so a + // retry is not swallowed. Without that release the workspace is unpinnable for the session. + await page.keyboard.press(PIN_SHORTCUT); + + await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 }); + expect(gate.sentCount()).toBe(2); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 5591949f0fa..febae56d9dd 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -32,6 +32,7 @@ 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 { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler"; import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host"; import { ProviderSettingsHost } from "@/components/provider-settings-host"; import { RootErrorBoundary } from "@/components/root-error-boundary"; @@ -551,6 +552,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 9acba693e2f..36ffc895139 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1368,17 +1368,6 @@ function WorkspaceRowWithMenu({ }, }); - useKeyboardActionHandler({ - handlerId: `workspace-pin-${workspace.workspaceKey}`, - actions: ["workspace.pin"], - enabled: selected && canPin, - priority: 0, - handle: () => { - onTogglePin?.(); - return true; - }, - }); - return ( <> { - onTogglePin?.(); - return true; - }, - }); - return ( <> ({ + id: workspace.id, + pinnedAt: workspace.pinnedAt ?? null, + })); + const canPin = useHostFeature(serverId, "workspacePinning"); + const togglePin = useSidebarWorkspacePinController(); + + const handle = useCallback(() => { + if (!serverId || !fields || !canPin) { + return false; + } + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId, + workspaceId: fields.id, + }); + if (!workspaceKey) { + return false; + } + togglePin({ + serverId, + workspaceId: fields.id, + workspaceKey, + pinnedAt: fields.pinnedAt, + }); + return true; + }, [canPin, fields, serverId, togglePin]); + + useKeyboardActionHandler({ + handlerId: "workspace-pin-global", + actions: WORKSPACE_PIN_ACTIONS, + enabled: serverId !== null && fields !== null && canPin, + priority: 0, + handle, + }); +} diff --git a/packages/app/src/hooks/use-sidebar-workspace-pin.ts b/packages/app/src/hooks/use-sidebar-workspace-pin.ts index 2019442fbc2..9edb09f8b51 100644 --- a/packages/app/src/hooks/use-sidebar-workspace-pin.ts +++ b/packages/app/src/hooks/use-sidebar-workspace-pin.ts @@ -1,22 +1,33 @@ -import { useCallback, useRef } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; import { useToast } from "@/contexts/toast-context"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; -export type ToggleSidebarWorkspacePin = (workspace: SidebarWorkspaceEntry) => void; +// Everything the pin toggle actually needs. Kept narrower than SidebarWorkspaceEntry so the +// global keyboard handler can build one from the active route selection without a sidebar row. +export type PinnableWorkspace = Pick< + SidebarWorkspaceEntry, + "serverId" | "workspaceId" | "workspaceKey" | "pinnedAt" +>; + +export type ToggleSidebarWorkspacePin = (workspace: PinnableWorkspace) => void; + +// Module scope, not a per-hook ref: the sidebar row menus and the global keyboard shortcut each +// hold their own controller instance, and a per-instance guard would let a keypress and a menu +// click fire two concurrent, opposite setWorkspacePinned calls for the same workspace. +const pendingWorkspaceKeys = new Set(); export function useSidebarWorkspacePinController(): ToggleSidebarWorkspacePin { const { t } = useTranslation(); const toast = useToast(); - const pendingWorkspaceKeysRef = useRef(new Set()); const mutation = useMutation({ mutationFn: async ({ workspace, pinned, }: { - workspace: SidebarWorkspaceEntry; + workspace: PinnableWorkspace; pinned: boolean; }) => { const client = getHostRuntimeStore().getClient(workspace.serverId); @@ -31,17 +42,17 @@ export function useSidebarWorkspacePinController(): ToggleSidebarWorkspacePin { ); }, onSettled: (_data, _error, { workspace }) => { - pendingWorkspaceKeysRef.current.delete(workspace.workspaceKey); + pendingWorkspaceKeys.delete(workspace.workspaceKey); }, }); const mutate = mutation.mutate; return useCallback( - (workspace: SidebarWorkspaceEntry) => { - if (pendingWorkspaceKeysRef.current.has(workspace.workspaceKey)) { + (workspace: PinnableWorkspace) => { + if (pendingWorkspaceKeys.has(workspace.workspaceKey)) { return; } - pendingWorkspaceKeysRef.current.add(workspace.workspaceKey); + pendingWorkspaceKeys.add(workspace.workspaceKey); mutate({ workspace, pinned: workspace.pinnedAt == null }); }, [mutate], From e22c85373c1e770fe78f04bb6d5aa490cb7ea2e7 Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Fri, 24 Jul 2026 15:17:16 +0200 Subject: [PATCH 069/420] fix(server): keep the client port in X-Forwarded-Host (#2288) * fix(server): keep the client port in X-Forwarded-Host The service proxy stripped the port when setting X-Forwarded-Host, so a workspace service that derives its public origin from forwarded headers emitted absolute URLs pointing at port 80. Opening a service at http://api--branch--repo.localhost:6767/ and letting it redirect gave back a Location without the port, which the browser followed to nothing. Host was already forwarded intact, so services reading Host were fine. Nothing rewrites Location on the way back, so the wrong URL reached the browser unchanged. The strip was not localhost-only: buildPublicServiceProxyUrl preserves an explicit port in publicBaseUrl, so a public alias on :8443 hit the same path. It was a no-op only on the default-port public case. Forward the authority verbatim instead, and add X-Forwarded-Port under a never-invent, never-clobber rule: report only a port observed in the Host header, and leave any value an upstream proxy already set alone. Deriving it from the scheme would overwrite nginx's port on a non-default listener, and the upgrade path's hardcoded "http" would turn a correct 443 into 80. An empty inbound value carries no port, so it does not count as a value to preserve; out-of-range ports are dropped rather than passed on, since the authority is client-supplied. The header block existed in four copies. Collapse them: the subsystem now delegates to createScriptProxyMiddleware and createScriptProxyUpgradeHandler (passing passthroughUnknown explicitly, since the factory defaults it to true), and both remaining call sites share one buildForwardedHeaders helper. Adds the first tests to cover any x-forwarded-* header on this path, driven over a real proxy with an upstream that echoes what it received. Co-Authored-By: Claude Opus 4.8 * fix(server): let the observed authority win over a forged port X-Forwarded-Port deferred to an inbound value, so a client connecting directly could send Host: svc.localhost:63735 with a forged X-Forwarded-Port: 443 and have services build URLs for 443. Frameworks apply X-Forwarded-Port after X-Forwarded-Host, so the forged value won. This was inconsistent with the line above it: X-Forwarded-Host is overwritten with the real authority unconditionally, and there is a test asserting that. Both headers carry the same trust, so both now follow the same rule. First-hand observation wins; an inbound value is a fallback, never an override. A port parsed from Host replaces any inbound X-Forwarded-Port. When Host carries no port there is nothing to observe, so a proxy's value survives untouched: the nginx-on-:8443 case, where $host drops the port and X-Forwarded-Port is the only source. The empty-inbound-value special case is gone; always overwriting when we have an observation covers it with one branch less. Reported by Greptile on #2288. Not a regression: before this branch an inbound X-Forwarded-Port passed through untouched, so the forged value already reached services. Co-Authored-By: Claude Opus 4.8 * docs(server): correct the forwarded-port comment and note the limit The comment claimed the parsed port was "the port the client really connected on". It is not: parseHostHeaderPort reads the Host header, which the client writes, and route lookup normalizes the port away before matching, so any value reaches the proxy. The only observed port would be req.socket.localPort, which this code does not use. Replaced it with the actual reason the Host port wins, which is keeping x-forwarded-host and x-forwarded-port from disagreeing: the former is set from Host, and frameworks apply the latter afterwards, so a mismatched inbound value would silently override the forwarded authority. Added a LIMITATION note recording that the forwarded authority is not authenticated, that inbound x-forwarded-port is not checked against trustedProxies, and that closing this needs that config threaded into the subsystem and the upgrade path, which has no Express trust context. Both predate this branch: Host has always carried a client-chosen port and an inbound x-forwarded-port has always passed straight through. Docs gain a matching section telling service authors to pin their public origin in configuration rather than derive it from request headers. No behavior change. Raised by Greptile on #2288. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- docs/service-proxy.md | 23 ++ .../server/src/server/service-proxy.test.ts | 267 +++++++++++++++++- packages/server/src/server/service-proxy.ts | 181 +++++------- 3 files changed, 357 insertions(+), 114 deletions(-) diff --git a/docs/service-proxy.md b/docs/service-proxy.md index ee8f1ceca82..3ec3144289c 100644 --- a/docs/service-proxy.md +++ b/docs/service-proxy.md @@ -107,6 +107,29 @@ server { } ``` +Nginx's `$host` drops the port. If you terminate on a non-default port, use `$http_host` instead so the port survives — that is what "forwards the `Host` header unchanged" means here. + +## Forwarded headers + +Paseo sets these when it forwards a request to a workspace service: + +| Header | Value | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `X-Forwarded-Host` | The `Host` header verbatim, including the port when the client used one | +| `X-Forwarded-Proto` | The request scheme (`http` on the WebSocket upgrade path) | +| `X-Forwarded-For` | The immediate peer address. Replaces any existing chain, so behind your own reverse proxy this is the proxy's address, not the client's | +| `X-Forwarded-Port` | The port from the `Host` header when it has one, otherwise whatever your proxy already set | + +`X-Forwarded-Port` follows the same trust rule as `X-Forwarded-Host`: the authority Paseo observed wins. When the `Host` header carries a port, that port is reported and replaces any inbound `X-Forwarded-Port`, so a client cannot forge one. When `Host` carries no port there is nothing to observe, so a value your reverse proxy set survives untouched — that is the case where nginx's `$host` drops the port and `X-Forwarded-Port` is the only source. Paseo never derives the port from the scheme. Any other `X-Forwarded-*` header your proxy sends is passed through untouched. + +Services that build absolute URLs should prefer `Host` or `X-Forwarded-Host`. + +### The forwarded authority is not authenticated + +Route lookup normalizes the port away before matching a service hostname, so a client can address the daemon with any port in `Host` and still reach the service. That port is what lands in `X-Forwarded-Host` and `X-Forwarded-Port`. Paseo also does not check whether an inbound `X-Forwarded-Port` came from a proxy in `trustedProxies` — when `Host` carries no port, a client-supplied value is passed through. + +Treat the forwarded authority as client-influenced input. A service that builds password reset links, absolute redirects, or cached URLs from it should pin its own public origin in configuration rather than deriving one from request headers. This is not specific to `X-Forwarded-Port`: the `Host` header has always carried a client-chosen port. + ## Environment variables The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`: diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts index 508bc464682..5313b01d2d3 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/service-proxy.test.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import http from "node:http"; +import net from "node:net"; import express from "express"; import { describe, expect, it } from "vitest"; import pino from "pino"; @@ -30,10 +31,16 @@ function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[ return entries; } -function httpGet(port: number, host: string, requestPath = "/api/health") { +interface HttpGetOptions { + path?: string; + headers?: Record; +} + +function httpGet(port: number, host: string, options: HttpGetOptions = {}) { + const { path: requestPath = "/api/health", headers: extraHeaders = {} } = options; return new Promise<{ status: number; body: string }>((resolve, reject) => { const req = http.get( - { hostname: "127.0.0.1", port, path: requestPath, headers: { host } }, + { hostname: "127.0.0.1", port, path: requestPath, headers: { host, ...extraHeaders } }, (res) => { let body = ""; res.on("data", (chunk: Buffer) => { @@ -294,3 +301,259 @@ describe("service proxy subsystem shape", () => { }); }); }); + +interface ForwardedFixture { + daemonPort: number; + hostname: string; + close(): Promise; +} + +/** + * Runs a real workspace service behind a real daemon listener. The upstream + * echoes the headers it received so tests can assert what actually crossed the + * proxy, not what a helper returned. + */ +async function startForwardedHeadersFixture(): Promise { + const upstreamPort = await findFreePort(); + const upstream = http.createServer((req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(req.headers)); + }); + const upgradeSockets: net.Socket[] = []; + upstream.on("upgrade", (req, socket) => { + upgradeSockets.push(socket); + // The client resets this connection once it has the echo, so the reset + // reaches us as an 'error'. A raw upgrade socket has no default handler — + // without this the event goes unhandled and takes down the test process. + socket.on("error", () => socket.destroy()); + const payload = JSON.stringify(req.headers); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`, + ); + }); + await new Promise((resolve) => upstream.listen(upstreamPort, "127.0.0.1", resolve)); + + const serviceProxy = createServiceProxySubsystem({ logger }); + const route = serviceProxy.registerWorkspaceService({ + workspaceId: "workspace-a", + projectSlug: "repo", + branchName: "feature", + scriptName: "api", + port: upstreamPort, + }); + + const daemonPort = await findFreePort(); + const app = express(); + app.set("trust proxy", true); + app.use(serviceProxy.middleware()); + app.use((_req, res) => { + res.status(404).send("404 Not Found"); + }); + const daemon = http.createServer(app); + daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false })); + await new Promise((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve)); + + return { + daemonPort, + hostname: route.hostname, + async close() { + // Upgraded sockets keep the server alive; close() alone would hang. + for (const socket of upgradeSockets) socket.destroy(); + daemon.closeAllConnections(); + await new Promise((resolve) => daemon.close(() => resolve())); + upstream.closeAllConnections(); + await new Promise((resolve) => upstream.close(() => resolve())); + }, + }; +} + +function upgradeThroughProxy( + port: number, + host: string, + extraHeaders: Record = {}, +): Promise> { + return new Promise((resolve, reject) => { + const socket = net.connect({ host: "127.0.0.1", port }, () => { + const lines = [ + "GET /ws HTTP/1.1", + `Host: ${host}`, + "Upgrade: websocket", + "Connection: Upgrade", + ...Object.entries(extraHeaders).map(([key, value]) => `${key}: ${value}`), + "", + "", + ]; + socket.write(lines.join("\r\n")); + }); + let raw = ""; + let settled = false; + function fail(reason: string) { + if (settled) return; + settled = true; + socket.destroy(); + reject(new Error(`${reason} (received ${raw.length} bytes)`)); + } + socket.on("data", (chunk: Buffer) => { + raw += chunk.toString(); + const separator = raw.indexOf("\r\n\r\n"); + if (separator === -1) return; + const body = raw.slice(separator + 4); + const lengthMatch = /x-echo-length: (\d+)/i.exec(raw.slice(0, separator)); + if (!lengthMatch || body.length < Number(lengthMatch[1])) return; + settled = true; + socket.destroy(); + resolve(JSON.parse(body) as Record); + }); + // Without these the promise stays pending forever when the proxy drops the + // upgrade, and the test reports a timeout instead of the real failure. + socket.on("close", () => fail("upgrade closed before the echo arrived")); + socket.on("error", (err) => { + if (settled) return; + settled = true; + socket.destroy(); + reject(err); + }); + }); +} + +describe("service proxy forwarded headers", () => { + it("forwards the client authority with its port so services build reachable URLs", async () => { + const fixture = await startForwardedHeadersFixture(); + try { + const response = await httpGet( + fixture.daemonPort, + `${fixture.hostname}:${fixture.daemonPort}`, + { path: "/" }, + ); + const headers = JSON.parse(response.body) as Record; + + expect(headers.host).toBe(`${fixture.hostname}:${fixture.daemonPort}`); + expect(headers["x-forwarded-host"]).toBe(`${fixture.hostname}:${fixture.daemonPort}`); + expect(headers["x-forwarded-port"]).toBe(String(fixture.daemonPort)); + expect(headers["x-forwarded-proto"]).toBe("http"); + } finally { + await fixture.close(); + } + }); + + it("does not invent a port when the client authority has none", async () => { + const fixture = await startForwardedHeadersFixture(); + try { + const response = await httpGet(fixture.daemonPort, fixture.hostname, { + path: "/", + headers: { "x-forwarded-proto": "https" }, + }); + const headers = JSON.parse(response.body) as Record; + + expect(headers["x-forwarded-host"]).toBe(fixture.hostname); + expect(headers["x-forwarded-port"]).toBeUndefined(); + expect(headers["x-forwarded-proto"]).toBe("https"); + } finally { + await fixture.close(); + } + }); + + it("keeps a port an upstream proxy already reported", async () => { + const fixture = await startForwardedHeadersFixture(); + try { + const response = await httpGet(fixture.daemonPort, fixture.hostname, { + path: "/", + headers: { "x-forwarded-proto": "https", "x-forwarded-port": "8443" }, + }); + const headers = JSON.parse(response.body) as Record; + + expect(headers["x-forwarded-port"]).toBe("8443"); + } finally { + await fixture.close(); + } + }); + + it("overrides a client-supplied forwarded port with the observed authority port", async () => { + const fixture = await startForwardedHeadersFixture(); + try { + const response = await httpGet( + fixture.daemonPort, + `${fixture.hostname}:${fixture.daemonPort}`, + { path: "/", headers: { "x-forwarded-port": "443" } }, + ); + const headers = JSON.parse(response.body) as Record; + + expect(headers["x-forwarded-port"]).toBe(String(fixture.daemonPort)); + } finally { + await fixture.close(); + } + }); + + it("reports the real port when a client sends an empty forwarded port", async () => { + const fixture = await startForwardedHeadersFixture(); + try { + const response = await httpGet( + fixture.daemonPort, + `${fixture.hostname}:${fixture.daemonPort}`, + { path: "/", headers: { "x-forwarded-port": "" } }, + ); + const headers = JSON.parse(response.body) as Record; + + expect(headers["x-forwarded-port"]).toBe(String(fixture.daemonPort)); + } finally { + await fixture.close(); + } + }); + + it("ignores an out-of-range port in the client authority", async () => { + const fixture = await startForwardedHeadersFixture(); + try { + const response = await httpGet(fixture.daemonPort, `${fixture.hostname}:99999999`, { + path: "/", + }); + const headers = JSON.parse(response.body) as Record; + + expect(headers["x-forwarded-host"]).toBe(`${fixture.hostname}:99999999`); + expect(headers["x-forwarded-port"]).toBeUndefined(); + } finally { + await fixture.close(); + } + }); + + it("overwrites a client-supplied forwarded host with the real authority", async () => { + const fixture = await startForwardedHeadersFixture(); + try { + const response = await httpGet( + fixture.daemonPort, + `${fixture.hostname}:${fixture.daemonPort}`, + { path: "/", headers: { "x-forwarded-host": "attacker.example.com" } }, + ); + const headers = JSON.parse(response.body) as Record; + + expect(headers["x-forwarded-host"]).toBe(`${fixture.hostname}:${fixture.daemonPort}`); + } finally { + await fixture.close(); + } + }); + + it("applies the same forwarded-header rules to WebSocket upgrades", async () => { + const fixture = await startForwardedHeadersFixture(); + try { + const withPort = await upgradeThroughProxy( + fixture.daemonPort, + `${fixture.hostname}:${fixture.daemonPort}`, + ); + expect(withPort["x-forwarded-host"]).toBe(`${fixture.hostname}:${fixture.daemonPort}`); + expect(withPort["x-forwarded-port"]).toBe(String(fixture.daemonPort)); + + const behindTls = await upgradeThroughProxy(fixture.daemonPort, fixture.hostname, { + "X-Forwarded-Proto": "https", + "X-Forwarded-Port": "443", + }); + expect(behindTls["x-forwarded-host"]).toBe(fixture.hostname); + expect(behindTls["x-forwarded-port"]).toBe("443"); + // Known limitation, tracked separately: the upgrade path hardcodes the + // scheme, so an HTTPS proxy's "https" is replaced with "http" even though + // its port survives. Asserted rather than hidden so the day it is fixed + // this test fails loudly instead of silently passing. + expect(behindTls["x-forwarded-proto"]).toBe("http"); + } finally { + await fixture.close(); + } + }); +}); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index 73588e028bf..525a95ac5d5 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -256,6 +256,65 @@ function stripHopByHopHeaders( return out; } +const MAX_TCP_PORT = 65535; + +function parseHostHeaderPort(hostHeader: string): string | null { + const match = /:(\d+)$/.exec(hostHeader); + if (!match) { + return null; + } + // The authority is client-supplied — route lookup strips the port before + // matching, so any digits reach us. Only pass on something that could be a + // real port rather than handing services a number they will build URLs from. + const port = Number(match[1]); + return port >= 1 && port <= MAX_TCP_PORT ? match[1] : null; +} + +function buildForwardedHeaders({ + req, + route, + protocol, +}: { + req: IncomingMessage; + route: ServiceProxyRoute; + protocol: string; +}): Record { + const hostHeader = String(req.headers.host ?? route.hostname); + const forwardedHeaders = stripHopByHopHeaders(req.headers); + forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1"; + // Forward the authority verbatim, port included. Services derive their public + // origin from this header, so a stripped port makes them emit absolute URLs + // (redirects, links) pointing at port 80. Route lookup already normalized the + // port away, which also means the port here is client-chosen: upstream apps + // must treat the forwarded authority as untrusted input. + forwardedHeaders["x-forwarded-host"] = hostHeader; + forwardedHeaders["x-forwarded-proto"] = protocol; + // Keep the two headers consistent: x-forwarded-host is set from Host just + // above, and frameworks apply x-forwarded-port afterwards, so an inbound port + // that disagrees would silently win over the authority we forwarded. The port + // in Host therefore takes precedence. When Host carries no port there is + // nothing to derive from and an upstream proxy's value survives — the + // nginx-on-:8443 case, where $host drops the port and x-forwarded-port is the + // only source. Never derive the port from the scheme: the upgrade path + // hardcodes "http", so defaulting would turn a correct 443 into 80. + // + // LIMITATION: none of this is authenticated, and the port in Host is not an + // observation of the connection — it is whatever the client wrote, since + // route lookup normalizes the port away before matching. Paseo also does not + // check whether an inbound x-forwarded-port arrived from a configured trusted + // proxy. Services must treat the forwarded authority as client-influenced + // input, not as a trusted origin. This predates the header handling here: + // Host has always carried a client-chosen port, and an inbound + // x-forwarded-port has always passed straight through. Closing it means + // threading the daemon's trustedProxies into this subsystem and into the + // upgrade path, which has no Express trust context. Tracked separately. + const port = parseHostHeaderPort(hostHeader); + if (port !== null) { + forwardedHeaders["x-forwarded-port"] = port; + } + return forwardedHeaders; +} + function proxyHttpRequest({ req, res, @@ -267,11 +326,7 @@ function proxyHttpRequest({ route: ServiceProxyRoute; logger: Logger; }): void { - const hostHeader = req.headers.host ?? route.hostname; - const forwardedHeaders = stripHopByHopHeaders(req.headers); - forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1"; - forwardedHeaders["x-forwarded-host"] = String(hostHeader).replace(/:\d+$/, ""); - forwardedHeaders["x-forwarded-proto"] = req.protocol; + const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: req.protocol }); const proxyReq = http.request( { @@ -313,12 +368,8 @@ function proxyUpgradeRequest({ route: ServiceProxyRoute; logger: Logger; }): void { - const hostHeader = req.headers.host ?? route.hostname; const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => { - const forwardedHeaders = stripHopByHopHeaders(req.headers); - forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1"; - forwardedHeaders["x-forwarded-host"] = String(hostHeader).replace(/:\d+$/, ""); - forwardedHeaders["x-forwarded-proto"] = "http"; + const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: "http" }); forwardedHeaders.connection = "Upgrade"; forwardedHeaders.upgrade = req.headers.upgrade ?? "websocket"; @@ -867,33 +918,19 @@ class NodeServiceProxySubsystem implements ServiceProxySubsystem { } middleware(): RequestHandler { - return (req, res, next) => { - const classification = this.routes.classifyHost(req.headers.host); - if (classification.type === "daemon") { - next(); - return; - } - if (classification.type === "known-service-miss") { - res.status(404).send("404 Not Found"); - return; - } - this.proxyHttpRequest(req, res, classification.route); - }; + return createScriptProxyMiddleware({ routeStore: this.routes, logger: this.logger }); } upgradeHandler(options: { passthroughUnknown: boolean; }): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void { - return (req, socket, head) => { - const classification = this.routes.classifyHost(req.headers.host); - if (classification.type !== "registered-service") { - if (!options.passthroughUnknown) { - socket.destroy(); - } - return; - } - this.proxyUpgradeRequest(req, socket, head, classification.route); - }; + // Pass passthroughUnknown explicitly: the factory defaults it to true, the + // subsystem requires callers to choose. + return createScriptProxyUpgradeHandler({ + routeStore: this.routes, + logger: this.logger, + passthroughUnknown: options.passthroughUnknown, + }); } async startStandalone(options: { @@ -938,86 +975,6 @@ class NodeServiceProxySubsystem implements ServiceProxySubsystem { unlinkSync(listenTarget.path); } } - - private proxyHttpRequest( - req: Parameters[0], - res: Parameters[1], - route: ServiceProxyRoute, - ): void { - const hostHeader = req.headers.host ?? route.hostname; - const forwardedHeaders = stripHopByHopHeaders(req.headers); - forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1"; - forwardedHeaders["x-forwarded-host"] = String(hostHeader).replace(/:\d+$/, ""); - forwardedHeaders["x-forwarded-proto"] = req.protocol; - - const proxyReq = http.request( - { - hostname: "127.0.0.1", - port: route.port, - path: req.originalUrl, - method: req.method, - headers: forwardedHeaders, - }, - (proxyRes) => { - const responseHeaders = stripHopByHopHeaders(proxyRes.headers); - res.writeHead(proxyRes.statusCode ?? 502, responseHeaders); - proxyRes.pipe(res, { end: true }); - }, - ); - proxyReq.on("error", (err) => { - this.logger.warn( - { err, hostname: route.hostname, port: route.port }, - "Service proxy: upstream unreachable", - ); - if (!res.headersSent) { - res.writeHead(502, { "content-type": "text/plain" }); - res.end("502 Bad Gateway"); - } - }); - req.pipe(proxyReq, { end: true }); - } - - private proxyUpgradeRequest( - req: IncomingMessage, - socket: net.Socket, - head: Buffer, - route: ServiceProxyRoute, - ): void { - const hostHeader = req.headers.host ?? route.hostname; - const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => { - const forwardedHeaders = stripHopByHopHeaders(req.headers); - forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1"; - forwardedHeaders["x-forwarded-host"] = String(hostHeader).replace(/:\d+$/, ""); - forwardedHeaders["x-forwarded-proto"] = "http"; - forwardedHeaders.connection = "Upgrade"; - forwardedHeaders.upgrade = req.headers.upgrade ?? "websocket"; - - const headerLines: string[] = []; - headerLines.push(`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`); - for (const [key, value] of Object.entries(forwardedHeaders)) { - if (Array.isArray(value)) { - for (const v of value) headerLines.push(`${key}: ${v}`); - } else { - headerLines.push(`${key}: ${value}`); - } - } - headerLines.push("\r\n"); - targetSocket.write(headerLines.join("\r\n")); - if (head.length > 0) targetSocket.write(head); - targetSocket.pipe(socket); - socket.pipe(targetSocket); - }); - targetSocket.on("error", (err) => { - this.logger.warn( - { err, hostname: route.hostname, port: route.port }, - "Service proxy: WebSocket upstream unreachable", - ); - socket.end(); - }); - socket.on("error", () => { - targetSocket.destroy(); - }); - } } function listen( From 73e290bb5771322ef5fc43e5afca80a4bbf99e46 Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Fri, 24 Jul 2026 15:17:55 +0200 Subject: [PATCH 070/420] fix(server): name both refs in the base ref mismatch error (#2161) The base ref mismatch error printed the same value on both sides, so it always read "expected master, got master". All four throw sites computed `baseRef = compare.baseRef ?? resolvedBaseRef`, but the guard only fires when the caller passed a ref, so `baseRef` and the "got" value were the same string by construction. The value that actually differs -- the stored `baseRefName` from the worktree metadata -- was never shown, which left the error undiagnosable from the UI alone. Build the message in one place and have the four guards throw it, naming the refs `stored` and `requested`. The wording deliberately avoids "expected": a caller's ref can be stale, but the stored ref can equally be the wrong one, so the message states both facts and leaves the diagnosis open. The guards stay where they are. Only the message moves, so the condition that fires each throw is still visible at the call site, and both refs are narrowed to plain strings by the time the message is built. Throw conditions are unchanged; only the message text differs. Co-authored-by: Claude Opus 4.8 --- packages/server/src/utils/checkout-git.test.ts | 14 ++++++++++++++ packages/server/src/utils/checkout-git.ts | 14 ++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 877e2dddeec..2720b0d0ae9 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -3002,6 +3002,20 @@ const x = 1; expect(baseDiff.diff).not.toContain("file.txt"); }); + it("names both refs when a requested base ref does not match the stored one", async () => { + const worktree = await createLegacyWorktreeForTest({ + branchName: "mismatch-feature", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "mismatch-feature", + paseoHome, + }); + + await expect( + getCheckoutDiff(worktree.worktreePath, { mode: "base", baseRef: "other" }, { paseoHome }), + ).rejects.toThrow("Base ref mismatch: stored main, requested other"); + }); + it("excludes dirty working tree changes from Paseo worktree base diffs", async () => { const worktree = await createLegacyWorktreeForTest({ branchName: "feature", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index f518adfedee..6d09eee0dfd 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1134,6 +1134,12 @@ async function resolveBaseRefForCwd( }; } +// Names both refs rather than labelling either one correct: a caller's ref can be stale, but the +// stored ref can equally be wrong, so the message states the two facts and leaves the diagnosis open. +function baseRefMismatchError(refs: { stored: string; requested: string }): Error { + return new Error(`Base ref mismatch: stored ${refs.stored}, requested ${refs.requested}`); +} + async function isWorkingTreeDirty(cwd: string, context?: CheckoutContext): Promise { const { stdout } = await runGitCommand(["status", "--porcelain"], { cwd, @@ -2795,7 +2801,7 @@ async function resolveCheckoutDiffRefs( return null; } if (storedBaseRef && compare.baseRef && compare.baseRef !== storedBaseRef) { - throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`); + throw baseRefMismatchError({ stored: storedBaseRef, requested: compare.baseRef }); } const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef); return { @@ -3009,7 +3015,7 @@ export async function mergeToBase( throw new Error("Unable to determine base branch for merge"); } if (storedBaseRef && options.baseRef && options.baseRef !== storedBaseRef) { - throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`); + throw baseRefMismatchError({ stored: storedBaseRef, requested: options.baseRef }); } if (!currentBranch) { throw new Error("Unable to determine current branch for merge"); @@ -3085,7 +3091,7 @@ export async function mergeFromBase( throw new Error("Unable to determine base branch for merge"); } if (storedBaseRef && options.baseRef && options.baseRef !== storedBaseRef) { - throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`); + throw baseRefMismatchError({ stored: storedBaseRef, requested: options.baseRef }); } const requireCleanTarget = options.requireCleanTarget ?? true; @@ -3423,7 +3429,7 @@ export async function createPullRequest( } const normalizedBase = normalizeLocalBranchRefName(base); if (storedBaseRef && options.base && options.base !== storedBaseRef) { - throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`); + throw baseRefMismatchError({ stored: storedBaseRef, requested: options.base }); } // The push deliberately happens before the adapter resolves the target From a5942ef2de4c5c838345499f566dee0897db0f74 Mon Sep 17 00:00:00 2001 From: Josh Bendavid Date: Fri, 24 Jul 2026 15:54:46 +0200 Subject: [PATCH 071/420] fix(omp): limit thinking levels to model's reported efforts (#2171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(omp): limit thinking levels to model's reported efforts The omp provider exposed all six thinking levels (off/minimal/low/ medium/high/xhigh) whenever a model had reasoning enabled, ignoring the per-model thinking config that omp reports via RPC. The OmpModelSchema didn't parse the thinking field at all, so the model's efforts subset and defaultLevel were discarded. Parse model.thinking (efforts/defaultLevel/effortMap) in OmpModelSchema and filter the thinking options in mapOmpModel to only the model's reported efforts. The default is the reported defaultLevel when it's in the filtered set, otherwise the first (lowest) effort. Older omp versions that don't report thinking.efforts keep getting the full set. Also fix createSession to not hardcode 'medium' as the thinking fallback when the client doesn't send a thinkingOptionId — pass undefined so omp uses its own model default instead of overriding it with a level that may not even be in the model's effort set. * test(omp): drop --thinking medium from provider-registry launch assertions The thinking-level filtering PR changed createSession to pass undefined instead of "medium" when no thinking option is selected, so buildOmpLaunch no longer emits --thinking. agent.test.ts was updated but provider-registry.test.ts still expected --thinking medium in two OMP launch assertions, failing server-tests on ubuntu and windows. --------- Co-authored-by: Mohamed Boudra --- .../server/agent/provider-registry.test.ts | 4 +- .../server/agent/providers/omp/agent.test.ts | 19 ++- .../src/server/agent/providers/omp/agent.ts | 50 +----- .../agent/providers/omp/map-omp-model.test.ts | 146 ++++++++++++++++++ .../agent/providers/omp/map-omp-model.ts | 91 +++++++++++ .../server/agent/providers/omp/rpc-types.ts | 11 ++ 6 files changed, 268 insertions(+), 53 deletions(-) create mode 100644 packages/server/src/server/agent/providers/omp/map-omp-model.test.ts create mode 100644 packages/server/src/server/agent/providers/omp/map-omp-model.ts diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 4596b6d91f2..262051844ee 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -541,7 +541,7 @@ test("OMP is a disabled built-in backed by the real OMP adapter", async () => { expect.objectContaining({ cwd: "/tmp/registry-omp", protocolMode: "rpc-ui", - argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "yolo", "--thinking", "medium"], + argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "yolo"], }), ]); await session.close(); @@ -598,8 +598,6 @@ test("built-in OMP override keeps the real OMP adapter enabled and launchable", "rpc-ui", "--approval-mode", "yolo", - "--thinking", - "medium", ]); await session.close(); }); diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index f0adadee74c..4e01109bea1 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -91,7 +91,7 @@ describe("OMP agent client and session", () => { cwd: "/tmp/paseo-omp-agent-test", protocolMode: "rpc-ui", modeId: "ask", - argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "always-ask", "--thinking", "medium"], + argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "always-ask"], }); expect(omp.registeredHostTools()).toEqual([ [expect.objectContaining({ name: "create_agent" })], @@ -117,10 +117,25 @@ describe("OMP agent client and session", () => { cwd: "/tmp/paseo-omp-agent-test", protocolMode: "rpc-ui", modeId: "write", - argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "write", "--thinking", "medium"], + argv: ["omp", "--mode", "rpc-ui", "--approval-mode", "write"], }); }); + test("passes --thinking when a thinking option is provided", async () => { + const omp = new OmpHarness(); + await omp.start({ modeId: "ask", thinkingOptionId: "xhigh" }, createToolCatalog()); + + expect(omp.launchConfiguration().argv).toEqual([ + "omp", + "--mode", + "rpc-ui", + "--approval-mode", + "always-ask", + "--thinking", + "xhigh", + ]); + }); + test("streams a prompt through completion", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 8337c250a45..01ad77a36cf 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -107,9 +107,9 @@ import { buildOmpRpcUiPermissionResponse, mapOmpRpcUiPermissionRequest, } from "./rpc-ui-permission-mapper.js"; +import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js"; const OMP_PROVIDER = "omp"; -const DEFAULT_OMP_THINKING_LEVEL: OmpThinkingLevel = "medium"; const OMP_CATALOG_REQUEST_TIMEOUT_MS = 120_000; const QUESTION_RESPONSE_HEADER = "Response"; const QUESTION_COMMENT_HEADER = "Comment"; @@ -129,21 +129,6 @@ const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = { supportsRewindBoth: false, }; -const OMP_THINKING_OPTIONS: ReadonlyArray<{ - id: OmpThinkingLevel; - label: string; - description: string; - isDefault?: boolean; -}> = [ - { id: "off", label: "Off", description: "No extra reasoning" }, - { id: "minimal", label: "Minimal", description: "Light reasoning" }, - { id: "low", label: "Low", description: "Faster reasoning" }, - { id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true }, - { id: "high", label: "High", description: "Deeper reasoning" }, - { id: "xhigh", label: "XHigh", description: "Extra-high reasoning" }, - { id: "max", label: "Max", description: "Maximum reasoning" }, -] as const; - export interface OmpAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; @@ -320,21 +305,6 @@ function parseAutoCompactMode(value: string | undefined): AutoCompactMode { return "unknown"; } -function mapThinkingOption(option: (typeof OMP_THINKING_OPTIONS)[number]) { - const mappedOption = { - id: option.id, - label: option.label, - description: option.description, - }; - if (option.isDefault) { - return { - ...mappedOption, - isDefault: true, - }; - } - return mappedOption; -} - function toAgentUsage(stats: OmpSessionStats): AgentUsage | undefined { const inputTokens = stats.tokens?.input ?? 0; const cachedInputTokens = stats.tokens?.cacheRead ?? 0; @@ -883,21 +853,6 @@ function buildExtensionUiResponse( return { value: answer }; } -function mapOmpModel(model: OmpModel, provider: AgentProvider): AgentModelDefinition { - return { - provider, - id: `${model.provider}/${model.id}`, - label: `${model.provider}/${model.name ?? model.id}`, - description: `${model.provider}/${model.id}`, - metadata: { - provider: model.provider, - modelId: model.id, - }, - thinkingOptions: model.reasoning ? OMP_THINKING_OPTIONS.map(mapThinkingOption) : undefined, - defaultThinkingOptionId: model.reasoning ? DEFAULT_OMP_THINKING_LEVEL : undefined, - }; -} - function createRuntime( logger: Logger, runtimeSettings: ProviderRuntimeSettings | undefined, @@ -2301,8 +2256,7 @@ export class OmpAgentClient implements AgentClient { cwd: config.cwd, protocolMode: "rpc-ui", model: config.model, - thinkingOptionId: - normalizeOmpThinkingOption(config.thinkingOptionId) ?? DEFAULT_OMP_THINKING_LEVEL, + thinkingOptionId: normalizeOmpThinkingOption(config.thinkingOptionId) ?? undefined, noSession: config.internal === true, modeId: launchMode.modeId, extraArgs: launchMode.extraArgs, diff --git a/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts b/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts new file mode 100644 index 00000000000..5a380e553d5 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "vitest"; + +import { mapOmpModel } from "./map-omp-model.js"; +import type { OmpModel } from "./rpc-types.js"; + +function baseModel(overrides: Partial = {}): OmpModel { + return { + provider: "pioneer", + id: "canada-quant/glm-5.2", + name: "GLM-5.2", + ...overrides, + }; +} + +describe("mapOmpModel thinking options", () => { + test("limits thinking options to the model's reported efforts", () => { + const model = baseModel({ + reasoning: true, + thinking: { + mode: "effort", + efforts: ["high", "xhigh"], + defaultLevel: "xhigh", + effortMap: { high: "high", xhigh: "max" }, + }, + }); + + const result = mapOmpModel(model, "omp"); + + expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["high", "xhigh"]); + expect(result.defaultThinkingOptionId).toBe("xhigh"); + expect(result.thinkingOptions?.find((option) => option.isDefault)?.id).toBe("xhigh"); + }); + + test("exposes the full set when reasoning is true but no thinking config is reported", () => { + const model = baseModel({ reasoning: true }); + + const result = mapOmpModel(model, "omp"); + + expect(result.thinkingOptions?.map((option) => option.id)).toEqual([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(result.defaultThinkingOptionId).toBe("medium"); + }); + + test("exposes the full set when efforts is empty", () => { + const model = baseModel({ + reasoning: true, + thinking: { mode: "effort", efforts: [], defaultLevel: "high" }, + }); + + const result = mapOmpModel(model, "omp"); + + expect(result.thinkingOptions?.map((option) => option.id)).toEqual([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + }); + + test("omits thinking options entirely when reasoning is false", () => { + const model = baseModel({ reasoning: false }); + + const result = mapOmpModel(model, "omp"); + + expect(result.thinkingOptions).toBeUndefined(); + expect(result.defaultThinkingOptionId).toBeUndefined(); + }); + + test("omits thinking options when reasoning is absent", () => { + const model = baseModel({}); + + const result = mapOmpModel(model, "omp"); + + expect(result.thinkingOptions).toBeUndefined(); + expect(result.defaultThinkingOptionId).toBeUndefined(); + }); + + test("falls back to the first available option when defaultLevel is not in efforts", () => { + const model = baseModel({ + reasoning: true, + thinking: { mode: "effort", efforts: ["low", "high"], defaultLevel: "xhigh" }, + }); + + const result = mapOmpModel(model, "omp"); + + expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["low", "high"]); + expect(result.defaultThinkingOptionId).toBe("low"); + expect(result.thinkingOptions?.find((option) => option.isDefault)?.id).toBe("low"); + }); + + test("uses the first option as default when defaultLevel is absent", () => { + const model = baseModel({ + reasoning: true, + thinking: { mode: "effort", efforts: ["low", "medium", "high"] }, + }); + + const result = mapOmpModel(model, "omp"); + + expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["low", "medium", "high"]); + expect(result.defaultThinkingOptionId).toBe("low"); + }); + + test("falls back to the full set when every reported effort is unknown", () => { + const model = baseModel({ + reasoning: true, + thinking: { mode: "effort", efforts: ["ultra", "turbo"], defaultLevel: "turbo" }, + }); + + const result = mapOmpModel(model, "omp"); + + expect(result.thinkingOptions?.map((option) => option.id)).toEqual([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(result.defaultThinkingOptionId).toBe("medium"); + }); + + test("preserves provider and model id in the mapped definition", () => { + const model = baseModel({ + reasoning: true, + thinking: { mode: "effort", efforts: ["high", "xhigh"], defaultLevel: "xhigh" }, + }); + + const result = mapOmpModel(model, "omp"); + + expect(result.provider).toBe("omp"); + expect(result.id).toBe("pioneer/canada-quant/glm-5.2"); + expect(result.label).toBe("pioneer/GLM-5.2"); + expect(result.metadata).toEqual({ provider: "pioneer", modelId: "canada-quant/glm-5.2" }); + }); +}); diff --git a/packages/server/src/server/agent/providers/omp/map-omp-model.ts b/packages/server/src/server/agent/providers/omp/map-omp-model.ts new file mode 100644 index 00000000000..6cffc77949b --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/map-omp-model.ts @@ -0,0 +1,91 @@ +import type { + AgentModelDefinition, + AgentProvider, + AgentSelectOption, +} from "../../agent-sdk-types.js"; +import type { OmpModel, OmpThinkingLevel } from "./rpc-types.js"; + +export const DEFAULT_OMP_THINKING_LEVEL: OmpThinkingLevel = "medium"; + +export const OMP_THINKING_OPTIONS: ReadonlyArray<{ + id: OmpThinkingLevel; + label: string; + description: string; + isDefault?: boolean; +}> = [ + { id: "off", label: "Off", description: "No extra reasoning" }, + { id: "minimal", label: "Minimal", description: "Light reasoning" }, + { id: "low", label: "Low", description: "Faster reasoning" }, + { id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true }, + { id: "high", label: "High", description: "Deeper reasoning" }, + { id: "xhigh", label: "XHigh", description: "Extra-high reasoning" }, + { id: "max", label: "Max", description: "Maximum reasoning" }, +] as const; + +function mapThinkingOption( + option: (typeof OMP_THINKING_OPTIONS)[number], + isDefault?: boolean, +): AgentSelectOption { + const mapped: AgentSelectOption = { + id: option.id, + label: option.label, + description: option.description, + }; + if (isDefault ?? option.isDefault) { + mapped.isDefault = true; + } + return mapped; +} + +export function mapOmpModel(model: OmpModel, provider: AgentProvider): AgentModelDefinition { + const { thinkingOptions, defaultThinkingOptionId } = resolveOmpThinkingConfig(model); + return { + provider, + id: `${model.provider}/${model.id}`, + label: `${model.provider}/${model.name ?? model.id}`, + description: `${model.provider}/${model.id}`, + metadata: { + provider: model.provider, + modelId: model.id, + }, + thinkingOptions, + defaultThinkingOptionId, + }; +} + +function resolveOmpThinkingConfig(model: OmpModel): { + thinkingOptions: AgentSelectOption[] | undefined; + defaultThinkingOptionId: string | undefined; +} { + if (!model.reasoning) { + return { thinkingOptions: undefined, defaultThinkingOptionId: undefined }; + } + const efforts = model.thinking?.efforts; + if (!efforts || efforts.length === 0) { + // Older omp versions don't report per-model thinking config; expose the full set. + return { + thinkingOptions: OMP_THINKING_OPTIONS.map((option) => mapThinkingOption(option)), + defaultThinkingOptionId: DEFAULT_OMP_THINKING_LEVEL, + }; + } + const effortSet = new Set(efforts); + const filtered = OMP_THINKING_OPTIONS.filter((option) => effortSet.has(option.id)); + if (filtered.length === 0) { + // All reported efforts are unrecognized; fall back to the full set with the standard default. + return { + thinkingOptions: OMP_THINKING_OPTIONS.map((option) => mapThinkingOption(option)), + defaultThinkingOptionId: DEFAULT_OMP_THINKING_LEVEL, + }; + } + const reportedDefault = model.thinking?.defaultLevel; + const defaultThinkingOptionId = + reportedDefault && filtered.some((option) => option.id === reportedDefault) + ? reportedDefault + : (filtered[0]?.id ?? DEFAULT_OMP_THINKING_LEVEL); + return { + thinkingOptions: filtered.map((option) => + mapThinkingOption(option, option.id === defaultThinkingOptionId), + ), + defaultThinkingOptionId, + }; +} diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 31a057291be..e6e53031747 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -87,12 +87,22 @@ export const OmpAgentMessageSchema = z.discriminatedUnion("role", [ OmpBashExecutionMessageSchema, ]); +export const OmpModelThinkingSchema = z + .object({ + mode: z.string().optional(), + efforts: z.array(z.string()).optional(), + defaultLevel: z.string().optional(), + effortMap: z.record(z.string(), z.string()).optional(), + }) + .passthrough(); + export const OmpModelSchema = z .object({ provider: z.string(), id: z.string(), name: z.string().optional(), reasoning: z.boolean().optional(), + thinking: OmpModelThinkingSchema.optional(), contextWindow: z.number().optional(), maxTokens: z.number().nullable().optional(), api: z.string().optional(), @@ -568,6 +578,7 @@ export type OmpToolCallContent = z.infer; export type OmpAssistantContent = z.infer; export type OmpAgentMessage = z.infer; export type OmpModel = z.infer; +export type OmpModelThinking = z.infer; export type OmpSessionState = z.infer; export type OmpSessionStats = z.infer; export type OmpRpcSlashCommand = z.infer; From bb3f5c5a2d77ad92452c739848d78231083dc862 Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Fri, 24 Jul 2026 16:08:25 +0200 Subject: [PATCH 072/420] fix: prevent Shift+Tab from changing a backgrounded agent's mode (#1848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent Shift+Tab from changing a backgrounded agent's mode The New Workspace mode-cycle shortcut (Shift+Tab) could reach a still-mounted background agent's mode control and silently change that running agent's execution mode — including into a permissive/bypass mode — with no feedback in the New Workspace UI. Root cause is in useKeyboardActionHandler: it re-registered its handler on every render (a fresh inline `actions` array plus a changing `handle` identity). The dispatcher runs the most-recently-registered matching handler first, so a frequently re-rendering control climbed ahead of the active one and consumed the key. The registered `handle`/`enabled` were also captured per registration and read stale by the native keydown listener. Fix: register once per (handlerId, priority, actions); read `handle` and `isActive` live from a ref; fold `enabled` into a fresh `isActive` so the dispatcher re-checks it at dispatch time. Registration order stays stable and callbacks stay current for all six consumers of the hook. Add an e2e regression test that opens a live agent, opens New Workspace, and presses Shift+Tab. It asserts the running agent's mode is unchanged: its daemon-committed mode, plus no set_agent_mode_request on the wire. It fails on the previous code (the mode was flipped to a more permissive mode) and passes with this change. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(app): restore workspace pin shortcut build The shortcut change landed with an import path removed by the workspace tabs reshape, breaking app typecheck and web builds. Import the canonical workspace tab model. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Mohamed Boudra --- .../new-workspace-mode-cycle-safety.spec.ts | 146 ++++++++++++++++++ .../hooks/use-global-workspace-pin-action.ts | 2 +- .../src/hooks/use-keyboard-action-handler.ts | 49 +++++- 3 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts diff --git a/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts b/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts new file mode 100644 index 00000000000..9a894866960 --- /dev/null +++ b/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts @@ -0,0 +1,146 @@ +import { expect, test, type Page } from "./fixtures"; +import { daemonWsRoutePattern } from "./helpers/daemon-port"; +import { openAgentRoute } from "./helpers/mock-agent"; +import { openGlobalNewWorkspaceComposer, selectNewWorkspaceProject } from "./helpers/new-workspace"; +import { seedWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; + +const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; + +type WebSocketMessage = string | Buffer; + +function parseWebSocketJson(message: WebSocketMessage): unknown { + const rawMessage = typeof message === "string" ? message : message.toString("utf8"); + try { + return JSON.parse(rawMessage); + } catch { + return null; + } +} + +function getSessionMessage(message: WebSocketMessage): Record | null { + const envelope = parseWebSocketJson(message); + if (!envelope || typeof envelope !== "object") { + return null; + } + const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; + if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { + return null; + } + if (typeof maybeEnvelope.message !== "object") { + return null; + } + return maybeEnvelope.message as Record; +} + +// The draft mode control in New Workspace only mutates local form state; it never sends +// set_agent_mode_request. So the only source of such a request while the New Workspace +// composer is focused is a *live* agent's mode control. Recording those, keyed by agentId, +// gives a direct signal that Shift+Tab leaked into a backgrounded agent. +async function recordSetAgentModeRequests(page: Page): Promise<{ + requestsForAgent(agentId: string): Array<{ agentId: string; modeId: string }>; +}> { + const seen: Array<{ agentId: string; modeId: string }> = []; + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if (sessionMessage?.type === "set_agent_mode_request") { + const agentId = typeof sessionMessage.agentId === "string" ? sessionMessage.agentId : ""; + const modeId = typeof sessionMessage.modeId === "string" ? sessionMessage.modeId : ""; + seen.push({ agentId, modeId }); + } + server.send(message); + }); + server.onMessage((message) => ws.send(message)); + }); + return { + requestsForAgent: (agentId: string) => seen.filter((request) => request.agentId === agentId), + }; +} + +async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise { + await page.addInitScript( + ({ preferencesKey, serverId: seededServerId }) => { + localStorage.setItem( + preferencesKey, + JSON.stringify({ + serverId: seededServerId, + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4-mini", + mode: "auto", + thinkingByModel: { "gpt-5.4-mini": "low" }, + }, + mock: { model: "ten-second-stream" }, + }, + }), + ); + }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId }, + ); +} + +// Focus the New Workspace composer and cycle the execution mode with the keyboard. +// Kept out of the test body so the test reads as intent rather than key mechanics. +async function cycleNewWorkspaceMode(page: Page, presses: number): Promise { + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await expect(composer).toBeVisible({ timeout: 30_000 }); + await composer.click(); + for (let i = 0; i < presses; i++) { + await page.keyboard.press("Shift+Tab"); + } +} + +test.describe("New Workspace mode cycle safety", () => { + test.describe.configure({ timeout: 240_000 }); + + // Regression guard for the P1 safety bug: cycling the execution mode with Shift+Tab in + // the New Workspace composer must never reach a backgrounded, still-mounted agent's mode + // control and silently change that (possibly running) agent's mode — e.g. into a + // permissive/bypass mode. See use-keyboard-action-handler.ts. + test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => { + const serverId = getServerId(); + const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" }); + await seedCodexDefaultPreferences(page, serverId); + const modeRequests = await recordSetAgentModeRequests(page); + + try { + const agent = await seeded.client.createAgent({ + provider: "codex", + cwd: seeded.repoPath, + workspaceId: seeded.workspaceId, + title: "mode cycle safety e2e", + modeId: "auto", + model: "gpt-5.4-mini", + }); + + // Mount the live agent tab: its mode control registers a mode-cycle keyboard handler. + await openAgentRoute(page, { workspaceId: seeded.workspaceId, agentId: agent.id }); + await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", { + timeout: 30_000, + }); + + // Move to the New Workspace composer. The agent tab stays mounted in the background, + // so its handler is still registered when we cycle here. + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, { + projectKey: seeded.projectId, + projectDisplayName: seeded.projectDisplayName, + }); + + await cycleNewWorkspaceMode(page, 6); + + // fetchAgents is a real daemon round-trip; once it resolves, any mode change the + // presses would have triggered has already landed. Assert the running agent is + // untouched — both its committed mode and on the wire — with no fixed sleep. + const agents = await seeded.client.fetchAgents(); + const backgroundAgent = agents.entries.find((entry) => entry.agent.id === agent.id)?.agent; + expect(backgroundAgent?.currentModeId).toBe("auto"); + expect(modeRequests.requestsForAgent(agent.id)).toEqual([]); + } finally { + await seeded.cleanup(); + } + }); +}); diff --git a/packages/app/src/hooks/use-global-workspace-pin-action.ts b/packages/app/src/hooks/use-global-workspace-pin-action.ts index 6450b325997..d07da74d6c0 100644 --- a/packages/app/src/hooks/use-global-workspace-pin-action.ts +++ b/packages/app/src/hooks/use-global-workspace-pin-action.ts @@ -5,7 +5,7 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useHostFeature } from "@/runtime/host-features"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; const WORKSPACE_PIN_ACTIONS: readonly KeyboardActionId[] = ["workspace.pin"]; diff --git a/packages/app/src/hooks/use-keyboard-action-handler.ts b/packages/app/src/hooks/use-keyboard-action-handler.ts index dda2bd85d84..79a3410c81b 100644 --- a/packages/app/src/hooks/use-keyboard-action-handler.ts +++ b/packages/app/src/hooks/use-keyboard-action-handler.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { keyboardActionDispatcher, @@ -15,15 +15,48 @@ interface UseKeyboardActionHandlerInput { handle: (action: KeyboardActionDefinition) => boolean; } +/** + * Registers a keyboard action handler with the global dispatcher. + * + * The dispatcher is driven by a native window keydown listener and calls the + * most-recently-registered matching handler first (see keyboard-action-dispatcher.ts). + * Two properties must hold or handlers bound to the same action step on each other: + * + * 1. Stable registration order. registerHandler bumps a registration counter, so + * re-registering on every render reshuffles which handler the dispatcher reaches + * first. A frequently re-rendering control (e.g. a running agent's mode control) + * would then jump ahead of another (e.g. the New Workspace draft) and consume the + * key, silently acting on the wrong surface. So we register once per + * (handlerId, priority, actions) and never re-register just because handle, + * isActive, or enabled changed. + * + * 2. Fresh callbacks. Because we do not re-register on every render, the registered + * entry must read the latest props at dispatch time. handle and isActive are read + * live from a ref, and enabled is folded into isActive so the dispatcher + * re-evaluates it fresh (the plain enabled field it filters on is captured at + * registration and would otherwise go stale). + */ export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) { + const inputRef = useRef(input); + inputRef.current = input; + + // Only these identity-affecting fields trigger a re-register. actions is compared by + // content, not array identity, so inline literals do not churn the registration. + const actionsKey = input.actions.join(" "); useEffect(() => { return keyboardActionDispatcher.registerHandler({ - handlerId: input.handlerId, - actions: input.actions, - enabled: input.enabled, - priority: input.priority, - isActive: input.isActive, - handle: input.handle, + handlerId: inputRef.current.handlerId, + actions: inputRef.current.actions, + // Always-on at the coarse filter; the real enable/active gate is re-checked fresh + // inside isActive below so it can never be stale in the registry entry. + enabled: true, + priority: inputRef.current.priority, + isActive: () => { + const current = inputRef.current; + if (!current.enabled) return false; + return current.isActive ? current.isActive() : true; + }, + handle: (action) => inputRef.current.handle(action), }); - }, [input.actions, input.enabled, input.handle, input.handlerId, input.isActive, input.priority]); + }, [input.handlerId, input.priority, actionsKey]); } From 7ef3376b62837f4051df711232e1a72c9d3e4fde Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 19:37:27 +0200 Subject: [PATCH 073/420] feat(claude): add Opus 5 model --- .../server/agent/providers/claude/agent.test.ts | 4 +++- .../agent/providers/claude/model-manifest.ts | 12 ++++++++++-- .../agent/providers/claude/models.test.ts | 17 ++++++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 50e505011ab..60c6cc16e3f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -414,6 +414,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }); expect(models.map((m) => m.id)).toEqual([ + "claude-opus-5", "claude-fable-5", "claude-opus-4-8[1m]", "claude-opus-4-8", @@ -433,7 +434,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { } const defaultModel = models.find((m) => m.isDefault); - expect(defaultModel?.id).toBe("claude-opus-4-8"); + expect(defaultModel?.id).toBe("claude-opus-5"); } finally { await fs.rm(emptyConfigDir, { recursive: true, force: true }); } @@ -456,6 +457,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id); }; + expect(getThinkingIds("claude-opus-5")).toContain("ultracode"); expect(getThinkingIds("claude-fable-5")).toContain("ultracode"); expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode"); expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode"); diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index 88c469ad372..b569f0da161 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -30,6 +30,15 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off"; export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode"; export const CLAUDE_MODEL_MANIFEST = [ + { + id: "claude-opus-5", + label: "Opus 5", + description: "Opus 5 · Latest release", + isDefault: true, + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsThinkingDisabled: true, + }, { id: "claude-fable-5", label: "Fable 5", @@ -49,8 +58,7 @@ export const CLAUDE_MODEL_MANIFEST = [ { id: "claude-opus-4-8", label: "Opus 4.8", - description: "Opus 4.8 · Latest release", - isDefault: true, + description: "Opus 4.8 · Previous release", contextWindowMaxTokens: 200_000, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, supportsThinkingDisabled: true, diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index a62784494c3..5b069dce139 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -42,6 +42,7 @@ describe("getClaudeModels", () => { it("returns all claude models", () => { const models = getClaudeModels(); expect(models.map((m) => m.id)).toEqual([ + "claude-opus-5", "claude-fable-5", "claude-opus-4-8[1m]", "claude-opus-4-8", @@ -60,7 +61,7 @@ describe("getClaudeModels", () => { const models = getClaudeModels(); const defaults = models.filter((m) => m.isDefault); expect(defaults).toHaveLength(1); - expect(defaults[0].id).toBe("claude-opus-4-8"); + expect(defaults[0].id).toBe("claude-opus-5"); }); it("defines context window sizes in the catalog", () => { @@ -70,6 +71,7 @@ describe("getClaudeModels", () => { expect(contextWindows).toEqual( new Map([ + ["claude-opus-5", 1_000_000], ["claude-fable-5", 1_000_000], ["claude-opus-4-8[1m]", 1_000_000], ["claude-opus-4-8", 200_000], @@ -88,6 +90,15 @@ describe("getClaudeModels", () => { it("derives thinking options from model effort capabilities", () => { const models = new Map(getClaudeModels().map((model) => [model.id, model])); + expect(models.get("claude-opus-5")?.thinkingOptions?.map((option) => option.id)).toEqual([ + CLAUDE_DISABLED_THINKING_OPTION_ID, + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); expect(models.get("claude-sonnet-5")?.thinkingOptions?.map((option) => option.id)).toEqual([ CLAUDE_DISABLED_THINKING_OPTION_ID, "low", @@ -128,6 +139,8 @@ describe("getClaudeModels", () => { }); it.each([ + ["claude-opus-5", true, "low"], + ["claude-opus-5-20260724", true, "low"], ["claude-sonnet-5", true, "low"], ["claude-sonnet-5-20260101", true, "low"], ["claude-fable-5", false, "low"], @@ -286,6 +299,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { describe("normalizeClaudeRuntimeModelId", () => { it("returns exact match for known model IDs", () => { + expect(normalizeClaudeRuntimeModelId("claude-opus-5")).toBe("claude-opus-5"); expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-5")).toBe("claude-sonnet-5"); expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6"); @@ -295,6 +309,7 @@ describe("normalizeClaudeRuntimeModelId", () => { }); it("normalizes dated model IDs to base model", () => { + expect(normalizeClaudeRuntimeModelId("claude-opus-5-20260724")).toBe("claude-opus-5"); expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301")).toBe("claude-fable-5"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101")).toBe("claude-sonnet-5"); expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6"); From 782b341b1ad941e11b4c9b0cea936af09ca7707f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 19:37:33 +0200 Subject: [PATCH 074/420] docs: prepare 0.2.1 changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 550ffd6c2f0..6287f6a59ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.1 - 2026-07-24 + +### Added + +- Claude Opus 5 is available + ## 0.2.0 - 2026-07-24 ### Added From 36f38245cab51bbe0b43b6ac42fd41aa757064d9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 19:40:41 +0200 Subject: [PATCH 075/420] chore(release): cut 0.2.1 --- package-lock.json | 42 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++--- packages/client/package.json | 6 ++-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 +++--- packages/website/package.json | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2bba6a57684..2d9747350ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.2.0", + "version": "0.2.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35211,7 +35211,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36236,12 +36236,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.0", - "@getpaseo/protocol": "0.2.0", - "@getpaseo/server": "0.2.0", + "@getpaseo/client": "0.2.1", + "@getpaseo/protocol": "0.2.1", + "@getpaseo/server": "0.2.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36487,10 +36487,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { - "@getpaseo/protocol": "0.2.0", - "@getpaseo/relay": "0.2.0", + "@getpaseo/protocol": "0.2.1", + "@getpaseo/relay": "0.2.1", "zod": "^4.4.3" }, "devDependencies": { @@ -36501,7 +36501,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.2.0", + "version": "0.2.1", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36744,7 +36744,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37640,7 +37640,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -37872,7 +37872,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "zod": "^4.4.3" }, @@ -37885,7 +37885,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38103,15 +38103,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.0", - "@getpaseo/highlight": "0.2.0", - "@getpaseo/protocol": "0.2.0", - "@getpaseo/relay": "0.2.0", + "@getpaseo/client": "0.2.1", + "@getpaseo/highlight": "0.2.1", + "@getpaseo/protocol": "0.2.1", + "@getpaseo/relay": "0.2.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38648,7 +38648,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 17d451895fd..812039714b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 26875c61cce..2df5e0a570e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.2.0", + "version": "0.2.1", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index bc0333db213..0ca541d9e2e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.2.0", + "version": "0.2.1", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -27,9 +27,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.0", - "@getpaseo/protocol": "0.2.0", - "@getpaseo/server": "0.2.0", + "@getpaseo/client": "0.2.1", + "@getpaseo/protocol": "0.2.1", + "@getpaseo/server": "0.2.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 769bb447f30..672adae3a80 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.2.0", + "version": "0.2.1", "description": "Paseo client SDK package", "files": [ "dist", @@ -35,8 +35,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.2.0", - "@getpaseo/relay": "0.2.0", + "@getpaseo/protocol": "0.2.1", + "@getpaseo/relay": "0.2.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d8d27190bc7..600bb8ab39f 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index d48a2f982a0..12a461c7382 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.0", + "version": "0.2.1", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 601fe88d4cb..a899c147603 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.2.0", + "version": "0.2.1", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 80275143fd0..e60ef71c0ce 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.2.0", + "version": "0.2.1", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 6af768b682e..f2cac7da2bc 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.2.0", + "version": "0.2.1", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 38eb24a37a9..61d3e3ea816 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.2.0", + "version": "0.2.1", "description": "Paseo backend server", "files": [ "dist/server", @@ -67,10 +67,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.214", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.0", - "@getpaseo/highlight": "0.2.0", - "@getpaseo/protocol": "0.2.0", - "@getpaseo/relay": "0.2.0", + "@getpaseo/client": "0.2.1", + "@getpaseo/highlight": "0.2.1", + "@getpaseo/protocol": "0.2.1", + "@getpaseo/relay": "0.2.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 8835ebb24b6..c7b3d3b8db2 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "scripts": { From 830c9b62c416607369c930614e543d51f077df42 Mon Sep 17 00:00:00 2001 From: "Jason@HND" Date: Sat, 25 Jul 2026 03:39:02 +0900 Subject: [PATCH 076/420] fix(server): retry hub test temp cleanup on Linux ENOTEMPTY (#2233) removeRoot() already retries EBUSY/ENOTEMPTY on main; also retry EPERM and document the Linux CI teardown race. Closes #2207 --- .../server/src/server/hub/test-utils/relationship-harness.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 819fb8d859e..3fdff778a23 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -1322,7 +1322,9 @@ export class HubRelationshipHarness { } private async removeRoot(): Promise { - const retryableCodes = new Set(["EBUSY", "ENOTEMPTY"]); + // Daemon may still flush into .paseo/projects during teardown; recursive rm + // can race and hit ENOTEMPTY/EBUSY/EPERM. Observed on Linux CI as well as macOS/Windows. + const retryableCodes = new Set(["ENOTEMPTY", "EBUSY", "EPERM"]); const attempts = 10; for (let attempt = 1; attempt <= attempts; attempt++) { try { From 457679d45a572c43ac399af232351fcb9f12e5be Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:46:41 +0000 Subject: [PATCH 077/420] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 4f8925dbd1a..87b94334e7a 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-kI8Qk19ztHbfI5zNP667R4JYqT9oiY1gLyTffVxSnz8= +sha256-iu8+JvLLY7XUHCfDFpputuXEIZCe37b5aqnupJhkY28= From be52347d67cb1b4829907c603c52f713772367ac Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 22:03:33 +0200 Subject: [PATCH 078/420] Let Hub finish the executions it starts (#2395) * feat(server): control Hub execution lifecycle * fix(server): archive only execution-owned worktrees * fix(server): serialize Hub execution control after create * test(server): compare Hub worktree paths canonically * fix(server): make absent Hub controls idempotent A Hub can own an execution before agent creation materializes. Treating an absent daemon-scoped record as already stopped or archived lets finality complete without revealing or affecting another daemon's execution. --- docs/hub.md | 18 +- packages/protocol/src/messages.hub.test.ts | 79 ++++++++- packages/protocol/src/messages.ts | 28 ++- .../server/src/server/agent/agent-owner.ts | 2 + packages/server/src/server/bootstrap.ts | 34 +++- .../src/server/hub/daemon-executions.test.ts | 5 +- .../src/server/hub/daemon-executions.ts | 117 ++++++++++--- .../server/hub/execution-controller.test.ts | 2 + .../src/server/hub/execution-controller.ts | 41 ++++- .../hub/execution-session.websocket.test.ts | 160 +++++++++++++++++- .../hub/test-utils/relationship-harness.ts | 97 ++++++++++- packages/server/src/server/session.ts | 10 +- 12 files changed, 541 insertions(+), 52 deletions(-) diff --git a/docs/hub.md b/docs/hub.md index 4a6d736ebd2..39dd763688c 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -45,8 +45,22 @@ replays the original prompt. A duplicate create returns the existing agent witho turn. Hub creates use the same agent creation path as trusted clients. They may select any existing -worktree target shape and may request `autoArchive`. Worktree creation and terminal auto-archive use -the shared workspace-aware lifecycle policy; Hub does not have a second launch or cleanup path. +worktree target shape. Execution completion policy remains outside the daemon: a completed agent +turn does not imply that the Hub execution is terminal. + +The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID +and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon +relationship plus that execution ID; callers cannot supply an agent ID or workspace path. Both +actions are idempotent and continue to resolve from stored ownership after daemon restart. +If no execution exists for that authenticated daemon and execution ID, interrupt and archive return +success because the requested stopped or archived state already holds. An execution owned by another +daemon is indistinguishable from a missing execution and is never exposed or affected. + +Interrupt uses the ordinary agent cancellation lifecycle. Archive first archives the owned agent. +When that agent belongs to an active Paseo-owned worktree workspace, the daemon also archives the +workspace through the shared workspace archive service, so the backing directory is removed only +after its final active workspace reference disappears. Local and shared checkouts archive only the +execution-owned agent. ## Disconnect and revocation diff --git a/packages/protocol/src/messages.hub.test.ts b/packages/protocol/src/messages.hub.test.ts index ae63b0dc607..b42ed452457 100644 --- a/packages/protocol/src/messages.hub.test.ts +++ b/packages/protocol/src/messages.hub.test.ts @@ -36,7 +36,7 @@ const agent = { labels: {}, }; -// Frozen at the Hub create request shape shipped before worktree and autoArchive. +// Frozen at the Hub create request shape shipped before worktree. const PreviousHubAgentCreateRequestSchema = z.object({ type: z.literal("hub.execution.agent.create.request"), requestId: z.string(), @@ -52,6 +52,19 @@ const PreviousHubAgentCreateRequestSchema = z.object({ env: z.record(z.string(), z.string()).optional(), }); +// Frozen at the Hub create request shape that temporarily carried turn-based auto-archive policy. +const PreviousHubAgentCreateWithAutoArchiveRequestSchema = + PreviousHubAgentCreateRequestSchema.extend({ + worktree: z + .object({ + mode: z.literal("branch-off"), + newBranch: z.string(), + base: z.string().optional(), + }) + .optional(), + autoArchive: z.boolean().optional(), + }); + describe("Hub session protocol", () => { test("accepts the Hub execution create request", () => { const message = { @@ -80,12 +93,64 @@ describe("Hub session protocol", () => { provider: "codex", cwd: "/repo", prompt: "Work in the requested target", - ...(worktree ? { worktree, autoArchive: true } : {}), + ...(worktree ? { worktree } : {}), }; expect(SessionInboundMessageSchema.parse(message)).toEqual(message); }); + test("accepts old Hub creates while ignoring their removed auto-archive policy", () => { + const oldRequest = { + type: "hub.execution.agent.create.request" as const, + requestId: "hub-old-policy", + executionId: "execution-old-policy", + provider: "codex", + cwd: "/repo", + prompt: "Work in the requested target", + worktree: { mode: "branch-off" as const, newBranch: "hub-work", base: "main" }, + autoArchive: true, + }; + + expect(PreviousHubAgentCreateWithAutoArchiveRequestSchema.parse(oldRequest)).toEqual( + oldRequest, + ); + expect(SessionInboundMessageSchema.parse(oldRequest)).toEqual({ + type: "hub.execution.agent.create.request", + requestId: "hub-old-policy", + executionId: "execution-old-policy", + provider: "codex", + cwd: "/repo", + prompt: "Work in the requested target", + worktree: { mode: "branch-off", newBranch: "hub-work", base: "main" }, + }); + }); + + test.each(["interrupt", "archive"] as const)( + "round-trips the Hub execution %s command", + (action) => { + const request = { + type: "hub.execution.control.request" as const, + requestId: `control-${action}`, + executionId: "execution-1", + action, + }; + const response = { + type: "hub.execution.control.response" as const, + payload: { + requestId: request.requestId, + executionId: request.executionId, + action, + success: true, + error: null, + }, + }; + + expect(SessionInboundMessageSchema.parse(request)).toEqual(request); + expect(SessionOutboundMessageSchema.parse(response)).toEqual(response); + expect(parseHubExecutionOutboundMessage(response)).toEqual(response); + }, + ); + test("the previous Hub create parser ignores additive worktree and auto-archive fields", () => { const newRequest = { type: "hub.execution.agent.create.request" as const, @@ -120,6 +185,16 @@ describe("Hub session protocol", () => { error: null, }, }, + { + type: "hub.execution.control.response", + payload: { + requestId: "control-1", + executionId: "execution-1", + action: "archive", + success: false, + error: "Execution not found", + }, + }, { type: "hub.execution.agent.update", payload: { executionId: "execution-1", agentId: "agent-1", agent }, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d65825fae81..31f33685dbb 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2418,13 +2418,25 @@ export const HubExecutionAgentCreateRequestSchema = z.object({ featureValues: z.record(z.string(), z.unknown()).optional(), env: z.record(z.string(), z.string()).optional(), worktree: CreateAgentWorktreeTargetSchema.optional(), - autoArchive: z.boolean().optional(), }); export type HubExecutionAgentCreateRequest = z.infer; +export const HubExecutionControlActionSchema = z.enum(["interrupt", "archive"]); +export type HubExecutionControlAction = z.infer; + +export const HubExecutionControlRequestSchema = z.object({ + type: z.literal("hub.execution.control.request"), + requestId: z.string(), + executionId: z.string(), + action: HubExecutionControlActionSchema, +}); + +export type HubExecutionControlRequest = z.infer; + export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ HubExecutionAgentCreateRequestSchema, + HubExecutionControlRequestSchema, BrowserAutomationExecuteResponseSchema, VoiceAudioChunkMessageSchema, AbortRequestMessageSchema, @@ -5079,6 +5091,17 @@ export const HubExecutionAgentCreateResponseSchema = z.object({ }), }); +export const HubExecutionControlResponseSchema = z.object({ + type: z.literal("hub.execution.control.response"), + payload: z.object({ + requestId: z.string(), + executionId: z.string(), + action: HubExecutionControlActionSchema, + success: z.boolean(), + error: z.string().nullable(), + }), +}); + export const HubExecutionAgentUpdateSchema = z.object({ type: z.literal("hub.execution.agent.update"), payload: z.object({ @@ -5098,11 +5121,13 @@ export const HubExecutionAgentStreamSchema = z.object({ }); export type HubExecutionAgentCreateResponse = z.infer; +export type HubExecutionControlResponse = z.infer; export type HubExecutionAgentUpdate = z.infer; export type HubExecutionAgentStream = z.infer; export const HubExecutionOutboundMessageSchema = z.discriminatedUnion("type", [ HubExecutionAgentCreateResponseSchema, + HubExecutionControlResponseSchema, HubExecutionAgentUpdateSchema, HubExecutionAgentStreamSchema, ]); @@ -5135,6 +5160,7 @@ export type DaemonUpdateProgressMessage = z.infer[1]) => createAgentCommand(createAgentCommandDependencies, input); + const archiveWorkspaceByIdExternal = (workspaceId: string, requestId: string) => + archiveByScope( + { + paseoHome: config.paseoHome, + paseoWorktreesBaseRoot: config.worktreesRoot, + github, + workspaceGitService, + agentManager, + agentStorage, + findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, + listActiveWorkspaces: listActiveWorkspacesExternal, + archiveWorkspaceRecord: archiveWorkspaceRecordExternal, + emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, + markWorkspaceArchiving: markWorkspaceArchivingExternal, + clearWorkspaceArchiving: clearWorkspaceArchivingExternal, + killTerminalsForWorkspace: (workspaceIdToKill) => + killTerminalsForWorkspace({ terminalManager, sessionLogger: logger }, workspaceIdToKill), + sessionLogger: logger, + }, + { scope: { kind: "workspace", workspaceId }, requestId }, + ); const hubAgentLifecycle = new CreateAgentLifecycleDispatch({ paseoHome: config.paseoHome, worktreesRoot: config.worktreesRoot, @@ -1085,12 +1106,11 @@ export async function createPaseoDaemon( agentManager, agentStorage, createAgent, - registerAutoArchive: ({ agentId, createdWorktree }) => - hubAgentLifecycle.registerAutoArchiveIfRequested({ - autoArchive: true, - agentId, - createdWorktree, - }), + interruptAgent: (agentId) => cancelAgentRunCommand({ agentManager, logger }, agentId), + archiveAgent: (agentId) => + archiveAgentCommand({ agentManager, agentStorage, logger }, agentId), + listActiveWorkspaces: listActiveWorkspacesExternal, + archiveWorkspace: archiveWorkspaceByIdExternal, cleanupFailedCreate: (input) => hubAgentLifecycle.cleanupCreatedWorktreeAfterFailedAgentCreate(input), }), diff --git a/packages/server/src/server/hub/daemon-executions.test.ts b/packages/server/src/server/hub/daemon-executions.test.ts index 363f341d03d..0b52c89c821 100644 --- a/packages/server/src/server/hub/daemon-executions.test.ts +++ b/packages/server/src/server/hub/daemon-executions.test.ts @@ -53,13 +53,12 @@ test("a failed Hub create removes its auto-created worktree", async () => { expect(await hub.durableOwnedAgentIds()).toEqual([]); }); -test("failed Hub auto-archive creates release their lifecycle subscriptions", async () => { +test("failed Hub creates release their lifecycle subscriptions", async () => { const hub = await launchRelationship(); const subscriptionBaseline = hub.agentSubscriptionCount(); hub.failProviderPromptStart(); hub.beginOwnedCreate("failed-prompt-create-1", "failed-prompt-execution-1", { - autoArchive: true, worktree: { mode: "branch-off", newBranch: "failed-prompt-1" }, }); const first = await hub.ownedCreateResult("failed-prompt-create-1"); @@ -75,7 +74,6 @@ test("failed Hub auto-archive creates release their lifecycle subscriptions", as hub.failProviderPromptStart(); hub.beginOwnedCreate("failed-prompt-create-2", "failed-prompt-execution-2", { - autoArchive: true, worktree: { mode: "branch-off", newBranch: "failed-prompt-2" }, }); const second = await hub.ownedCreateResult("failed-prompt-create-2"); @@ -138,7 +136,6 @@ test("failed create never archives a reused worktree", async () => { const failedPrompt = "Fail the reused worktree create"; hub.failProviderPromptStart(failedPrompt); hub.beginOwnedCreate("reused-create", "reused-execution", { - autoArchive: true, prompt: failedPrompt, worktree: { mode: "branch-off", newBranch: "shared-hub-worktree" }, }); diff --git a/packages/server/src/server/hub/daemon-executions.ts b/packages/server/src/server/hub/daemon-executions.ts index 712a7ecc2f1..5ee0f3ca773 100644 --- a/packages/server/src/server/hub/daemon-executions.ts +++ b/packages/server/src/server/hub/daemon-executions.ts @@ -2,13 +2,14 @@ import type { AgentSnapshotPayload, AgentStreamEventPayload, CreateAgentWorktreeTarget, + HubExecutionControlAction, } from "@getpaseo/protocol/messages"; import type { AgentManager, AgentManagerEvent, ManagedAgent } from "../agent/agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "../agent/agent-storage.js"; -import type { LifecycleRegistration } from "../agent/create-agent-lifecycle-dispatch.js"; import type { BoundCreateAgentCommand } from "../agent/create-agent/create.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js"; +import type { ActiveWorkspaceRef } from "../workspace-archive-service.js"; import { buildStoredAgentPayload } from "../agent/agent-projections.js"; import { serializeAgentSnapshot, serializeAgentStreamEvent } from "../messages.js"; import { daemonExecutionKey, type DaemonAgentOwner } from "../agent/agent-owner.js"; @@ -25,7 +26,12 @@ export interface HubExecutionAgentCreateInput { featureValues?: Record; env?: Record; worktree?: CreateAgentWorktreeTarget; - autoArchive?: boolean; +} + +export interface HubExecutionControlInput { + requestId: string; + executionId: string; + action: HubExecutionControlAction; } export interface OwnedAgentSnapshot { @@ -47,10 +53,10 @@ interface DaemonExecutionsOptions { agentManager: AgentManager; agentStorage: AgentStorage; createAgent: BoundCreateAgentCommand; - registerAutoArchive?: (input: { - agentId: string; - createdWorktree: CreatePaseoWorktreeWorkflowResult | null; - }) => LifecycleRegistration; + interruptAgent: (agentId: string) => Promise; + archiveAgent: (agentId: string) => Promise; + listActiveWorkspaces: () => Promise; + archiveWorkspace: (workspaceId: string, requestId: string) => Promise; cleanupFailedCreate?: (input: { createdWorktree: CreatePaseoWorktreeWorkflowResult | null; createdAgentId: string | null; @@ -59,6 +65,7 @@ interface DaemonExecutionsOptions { export interface HubExecutionAgents { create(input: HubExecutionAgentCreateInput): Promise; + control(input: HubExecutionControlInput): Promise; subscribe(listener: (event: OwnedAgentEvent) => void): () => void; invalidateAuthority(): Promise; } @@ -69,21 +76,17 @@ export class DaemonExecutions implements HubExecutionAgents { private readonly agentStorage: AgentStorage; private readonly createAgentCommand: BoundCreateAgentCommand; private readonly pendingCreates = new Map>(); + private readonly pendingControlActions = new Map>(); + private readonly controlTails = new Map>(); private authorityGeneration = 0; private authorityActive = true; - private readonly registerAutoArchive: (input: { - agentId: string; - createdWorktree: CreatePaseoWorktreeWorkflowResult | null; - }) => LifecycleRegistration; private readonly cleanupFailedCreate: NonNullable; - constructor(options: DaemonExecutionsOptions) { + constructor(private readonly options: DaemonExecutionsOptions) { this.daemonId = options.daemonId; this.agentManager = options.agentManager; this.agentStorage = options.agentStorage; this.createAgentCommand = options.createAgent; - this.registerAutoArchive = - options.registerAutoArchive ?? (() => ({ cancel: async () => undefined })); this.cleanupFailedCreate = options.cleanupFailedCreate ?? (async () => undefined); } @@ -108,10 +111,45 @@ export class DaemonExecutions implements HubExecutionAgents { return create; } + control(input: HubExecutionControlInput): Promise { + if (!this.authorityActive) { + return Promise.reject(new Error("Hub relationship authority is no longer active")); + } + const owner = this.owner(input.executionId); + const executionKey = daemonExecutionKey(owner); + const actionKey = `${executionKey}\0${input.action}`; + const pending = this.pendingControlActions.get(actionKey); + if (pending) return pending; + + const previous = + this.controlTails.get(executionKey) ?? + this.pendingCreates.get(executionKey)?.then(() => undefined) ?? + Promise.resolve(); + const authorityGeneration = this.authorityGeneration; + const control = previous + .catch(() => undefined) + .then(() => this.controlOwnedExecution(owner, input, authorityGeneration)); + this.pendingControlActions.set(actionKey, control); + this.controlTails.set(executionKey, control); + const release = () => { + if (this.pendingControlActions.get(actionKey) === control) { + this.pendingControlActions.delete(actionKey); + } + if (this.controlTails.get(executionKey) === control) { + this.controlTails.delete(executionKey); + } + }; + void control.then(release, release); + return control; + } + async invalidateAuthority(): Promise { this.authorityActive = false; this.authorityGeneration++; - await Promise.allSettled(this.pendingCreates.values()); + await Promise.allSettled([ + ...this.pendingCreates.values(), + ...this.pendingControlActions.values(), + ]); } subscribe(listener: (event: OwnedAgentEvent) => void): () => void { @@ -140,7 +178,6 @@ export class DaemonExecutions implements HubExecutionAgents { let createdWorktree: CreatePaseoWorktreeWorkflowResult | null = null; let createdAgentId: string | null = null; - let autoArchiveRegistration: LifecycleRegistration = { cancel: async () => undefined }; let result: Awaited>; try { result = await this.createAgentCommand({ @@ -161,21 +198,17 @@ export class DaemonExecutions implements HubExecutionAgents { owner, onWorktreeCreated: (worktree) => { createdWorktree = worktree; + if (worktree.created) { + owner.createdWorkspaceId = worktree.workspace.workspaceId; + } }, onCreated: (created) => { createdAgentId = created.agentId; - if (input.autoArchive === true) { - autoArchiveRegistration = this.registerAutoArchive({ - ...created, - createdWorktree: ownedCreatedWorktree(created.createdWorktree), - }); - } }, }); this.requireAuthority(authorityGeneration); } catch (error) { try { - await autoArchiveRegistration.cancel(); if (createdAgentId && this.agentManager.getAgent(createdAgentId)) { try { await this.agentManager.closeAgent(createdAgentId); @@ -204,13 +237,49 @@ export class DaemonExecutions implements HubExecutionAgents { }; } + private async controlOwnedExecution( + owner: DaemonAgentOwner, + input: HubExecutionControlInput, + authorityGeneration: number, + ): Promise { + this.requireAuthority(authorityGeneration, "execution control"); + const record = await this.agentStorage.findByDaemonExecution(owner); + this.requireAuthority(authorityGeneration, "execution control"); + if (!record) { + return; + } + const storedOwner = this.requireOwner(record); + + if (input.action === "interrupt") { + if (!record.archivedAt && this.agentManager.getAgent(record.id)) { + await this.options.interruptAgent(record.id); + } + return; + } + + const workspace = storedOwner.createdWorkspaceId + ? (await this.options.listActiveWorkspaces()).find( + (candidate) => candidate.workspaceId === storedOwner.createdWorkspaceId, + ) + : undefined; + + if (!record.archivedAt) { + this.requireAuthority(authorityGeneration, "execution control"); + await this.options.archiveAgent(record.id); + } + if (workspace?.isPaseoOwnedWorktree) { + this.requireAuthority(authorityGeneration, "execution control"); + await this.options.archiveWorkspace(workspace.workspaceId, input.requestId); + } + } + private resolveRecord(record: StoredAgentRecord): OwnedAgentSnapshot { return this.projectRecord(record); } - private requireAuthority(authorityGeneration: number): void { + private requireAuthority(authorityGeneration: number, operation = "agent creation"): void { if (!this.authorityActive || authorityGeneration !== this.authorityGeneration) { - throw new Error("Hub relationship authority ended during agent creation"); + throw new Error(`Hub relationship authority ended during ${operation}`); } } diff --git a/packages/server/src/server/hub/execution-controller.test.ts b/packages/server/src/server/hub/execution-controller.test.ts index 585b63d516a..44192fe1af9 100644 --- a/packages/server/src/server/hub/execution-controller.test.ts +++ b/packages/server/src/server/hub/execution-controller.test.ts @@ -37,6 +37,8 @@ class ControlledHubExecutionAgents implements HubExecutionAgents { return this.createGate.promise; } + async control(): Promise {} + subscribe(_listener: (event: OwnedAgentEvent) => void): () => void { return () => undefined; } diff --git a/packages/server/src/server/hub/execution-controller.ts b/packages/server/src/server/hub/execution-controller.ts index c9b93155e83..0c1b40e5d56 100644 --- a/packages/server/src/server/hub/execution-controller.ts +++ b/packages/server/src/server/hub/execution-controller.ts @@ -1,6 +1,7 @@ import { isAbsolute } from "node:path"; import type { HubExecutionAgentCreateRequest, + HubExecutionControlRequest, SessionOutboundMessage, } from "@getpaseo/protocol/messages"; @@ -16,6 +17,7 @@ export class HubExecutionController { private readonly send: (message: SessionOutboundMessage) => void; private readonly unsubscribe: () => void; private readonly pendingCreates = new Set>(); + private readonly pendingControls = new Set>(); private cleanupPromise: Promise | null = null; private closed = false; @@ -33,7 +35,43 @@ export class HubExecutionController { private async cleanupOnce(): Promise { this.closed = true; this.unsubscribe(); - await Promise.allSettled(this.pendingCreates); + await Promise.allSettled([...this.pendingCreates, ...this.pendingControls]); + } + + async controlExecution(message: HubExecutionControlRequest): Promise { + if (this.closed) return; + const control = this.controlExecutionWithResponse(message); + this.pendingControls.add(control); + try { + await control; + } finally { + this.pendingControls.delete(control); + } + } + + private async controlExecutionWithResponse(message: HubExecutionControlRequest): Promise { + let error: string | null = null; + try { + requireNonBlankHubAgentField("executionId", message.executionId); + await this.agents.control({ + requestId: message.requestId, + executionId: message.executionId, + action: message.action, + }); + } catch (controlError) { + error = controlError instanceof Error ? controlError.message : String(controlError); + } + if (this.closed) return; + this.send({ + type: "hub.execution.control.response", + payload: { + requestId: message.requestId, + executionId: message.executionId, + action: message.action, + success: error === null, + error, + }, + }); } async createAgent(message: HubExecutionAgentCreateRequest): Promise { @@ -65,7 +103,6 @@ export class HubExecutionController { featureValues: message.featureValues, env: message.env, worktree: message.worktree, - autoArchive: message.autoArchive, }); if (this.closed) return; this.send({ diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts index caeccd6c607..cce35ba8a2c 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -97,21 +97,78 @@ test("Hub reconnects without retaining trusted session state", async () => { expect(hub.observedTrustedLifecycleMessages()).toEqual([]); }); -test("Hub create forwards worktree and auto-archive through the existing create path", async () => { +test("Hub interrupts an owned running execution idempotently", async () => { + const hub = await launchRelationship(); + hub.beginOwnedCreate("interrupt-create", "execution-interrupt", { prompt: "sleep 30" }); + const created = await hub.ownedCreateResult("interrupt-create"); + await hub.ownedRunningUpdate(created.payload.agentId!); + + const interrupted = await hub.interruptExecution("execution-interrupt", "interrupt-first"); + const duplicate = await hub.interruptExecution("execution-interrupt", "interrupt-duplicate"); + + expect(interrupted).toEqual({ + requestId: "interrupt-first", + executionId: "execution-interrupt", + action: "interrupt", + success: true, + error: null, + }); + expect(duplicate).toEqual({ + requestId: "interrupt-duplicate", + executionId: "execution-interrupt", + action: "interrupt", + success: true, + error: null, + }); + expect(hub.ownedAgentIsRunning(created.payload.agentId!)).toBe(false); +}); + +test("Hub control waits for an in-flight create of the same execution", async () => { + const hub = await launchRelationship(); + hub.holdAgentCreation(); + hub.beginOwnedCreate("pending-control-create", "execution-pending-control", { + prompt: "sleep 30", + }); + await hub.agentCreationAttempts(1); + + hub.beginExecutionControl("pending-control-archive", "execution-pending-control", "archive"); + hub.finishAgentCreation(); + const created = await hub.ownedCreateResult("pending-control-create"); + const archived = await hub.executionControlResult("pending-control-archive"); + + expect(created).toMatchObject({ payload: { success: true, agentId: expect.any(String) } }); + expect(archived).toMatchObject({ success: true, error: null, action: "archive" }); + expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String)); +}, 20_000); + +test("Hub archives only the owned agent in a shared local checkout", async () => { + const hub = await launchRelationship(); + hub.beginOwnedCreate("local-create", "execution-local", { prompt: "sleep 30" }); + const created = await hub.ownedCreateResult("local-create"); + await hub.ownedRunningUpdate(created.payload.agentId!); + + const archived = await hub.archiveExecution("execution-local", "archive-local"); + const duplicate = await hub.archiveExecution("execution-local", "archive-local-duplicate"); + + expect(archived).toMatchObject({ success: true, error: null, action: "archive" }); + expect(duplicate).toMatchObject({ success: true, error: null, action: "archive" }); + expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String)); + expect(hub.ownedAgentIsRunning(created.payload.agentId!)).toBe(false); + expect(hub.repoExists()).toBe(true); +}); + +test("Hub archives a running execution's Paseo-created worktree", async () => { const hub = await launchRelationship(); hub.beginOwnedCreate("worktree-create", "execution-worktree", { worktree: { mode: "branch-off", newBranch: "hub-created-worktree", base: "main" }, - autoArchive: true, prompt: "sleep 30", - modeId: "always-ask", }); const worktreeCreated = await hub.ownedCreateResult("worktree-create"); const worktreeCwd = hub.latestCreatedCwd(); - const permission = await hub.ownedPermissionRequest(worktreeCreated.payload.agentId!); + await hub.ownedRunningUpdate(worktreeCreated.payload.agentId!); const duringRun = await hub.worktreeState(worktreeCwd!); const archiveCompletion = hub.waitForOwnedArchiveCompletion(worktreeCreated.payload.agentId!); - await hub.allowOwnedPermission(worktreeCreated.payload.agentId!, permission.id); - await hub.ownedTurnCompletion(worktreeCreated.payload.agentId!); + const response = await hub.archiveExecution("execution-worktree", "archive-worktree"); const archive = await archiveCompletion; const afterArchive = await hub.worktreeState(worktreeCwd!); @@ -121,6 +178,7 @@ test("Hub create forwards worktree and auto-archive through the existing create }); expect(worktreeCwd).not.toBe(hub.repoRoot()); expect(duringRun).toEqual({ exists: true, listed: true }); + expect(response).toMatchObject({ success: true, error: null, action: "archive" }); expect(afterArchive).toEqual({ exists: false, listed: false }); expect(archive).toEqual({ agentArchivedAt: expect.any(String), @@ -128,6 +186,96 @@ test("Hub create forwards worktree and auto-archive through the existing create }); }, 20_000); +test("a sibling workspace keeps an archived execution's worktree directory alive", async () => { + const hub = await launchRelationship(); + hub.beginOwnedCreate("sibling-create", "execution-sibling", { + worktree: { mode: "branch-off", newBranch: "hub-sibling-worktree", base: "main" }, + prompt: "sleep 30", + }); + const created = await hub.ownedCreateResult("sibling-create"); + const worktreeCwd = hub.latestCreatedCwd()!; + await hub.ownedRunningUpdate(created.payload.agentId!); + await hub.createSiblingWorkspace(worktreeCwd); + + const response = await hub.archiveExecution("execution-sibling", "archive-sibling"); + + expect(response).toMatchObject({ success: true, error: null }); + expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: true, listed: true }); + expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String)); +}, 20_000); + +test("archiving an execution in a reused worktree leaves the existing workspace intact", async () => { + const hub = await launchRelationship(); + const worktree = { + mode: "branch-off" as const, + newBranch: "hub-reused-worktree", + base: "main", + }; + hub.beginOwnedCreate("original-worktree-create", "execution-original-worktree", { + worktree, + prompt: "respond with exactly: original complete", + }); + const original = await hub.ownedCreateResult("original-worktree-create"); + const worktreeCwd = hub.latestCreatedCwd()!; + await hub.ownedTurnCompletion(original.payload.agentId!); + + hub.beginOwnedCreate("reused-worktree-create", "execution-reused-worktree", { + worktree, + prompt: "sleep 30", + }); + const reused = await hub.ownedCreateResult("reused-worktree-create"); + await hub.ownedRunningUpdate(reused.payload.agentId!); + + const response = await hub.archiveExecution( + "execution-reused-worktree", + "archive-reused-worktree", + ); + + expect(response).toMatchObject({ success: true, error: null }); + expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, worktreeCwd)).toBe(true); + expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: true, listed: true }); + expect(await hub.agentRemainsAvailable(original.payload.agentId!)).toBe(true); + expect(await hub.ownedAgentArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String)); +}, 20_000); + +test("Hub resolves persisted execution ownership after daemon restart", async () => { + const hub = await launchRelationship(); + hub.beginOwnedCreate("restart-create", "execution-restart", { + worktree: { mode: "branch-off", newBranch: "hub-restart-worktree", base: "main" }, + prompt: "sleep 30", + }); + const created = await hub.ownedCreateResult("restart-create"); + const worktreeCwd = hub.latestCreatedCwd()!; + await hub.ownedRunningUpdate(created.payload.agentId!); + + await hub.restartDaemon(); + await hub.socketDialed(); + hub.connectLatestSocket(); + const response = await hub.archiveExecution("execution-restart", "archive-after-restart"); + + expect(response).toMatchObject({ success: true, error: null }); + expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String)); + expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: false, listed: false }); +}, 20_000); + +test("Hub treats missing and foreign executions as already controlled without exposing ownership", async () => { + const hub = await launchRelationship(); + const foreignAgentId = await hub.createForeignExecution("execution-foreign"); + + const missingInterrupt = await hub.interruptExecution("execution-missing", "interrupt-missing"); + const missingArchive = await hub.archiveExecution("execution-missing", "archive-missing"); + const foreignInterrupt = await hub.interruptExecution("execution-foreign", "interrupt-foreign"); + const foreignArchive = await hub.archiveExecution("execution-foreign", "archive-foreign"); + + expect([missingInterrupt, missingArchive, foreignInterrupt, foreignArchive]).toEqual([ + expect.objectContaining({ success: true, error: null }), + expect.objectContaining({ success: true, error: null }), + expect.objectContaining({ success: true, error: null }), + expect.objectContaining({ success: true, error: null }), + ]); + expect(await hub.agentRemainsAvailable(foreignAgentId)).toBe(true); +}); + test("archive observation closes its first watcher when the second watcher cannot start", async () => { const watchFiles = new SetupFailingArchiveWatchFiles(2); const hub = await HubRelationshipHarness.start(watchFiles); diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 3fdff778a23..228b76ad9b4 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -12,6 +12,8 @@ import { WebSocket } from "ws"; import type { AgentSnapshotPayload, HubExecutionAgentCreateResponse, + HubExecutionControlAction, + HubExecutionControlResponse, HubExecutionAgentStream, HubExecutionAgentUpdate, RpcErrorMessage, @@ -671,7 +673,6 @@ export class HubRelationshipHarness { executionId = "execution-race", options: { worktree?: CreateAgentWorktreeTarget; - autoArchive?: boolean; prompt?: string; modeId?: string; } = {}, @@ -701,6 +702,49 @@ export class HubRelationshipHarness { return this.latestSocket().socket.messageFor(requestId); } + async controlExecution( + executionId: string, + action: HubExecutionControlAction, + requestId = `${action}-${executionId}`, + ): Promise { + this.beginExecutionControl(requestId, executionId, action); + return this.executionControlResult(requestId); + } + + beginExecutionControl( + requestId: string, + executionId: string, + action: HubExecutionControlAction, + ): void { + this.latestSocket().socket.receive({ + type: "hub.execution.control.request", + requestId, + executionId, + action, + }); + } + + async executionControlResult(requestId: string): Promise { + const response = (await this.latestSocket().socket.messageFor( + requestId, + )) as HubExecutionControlResponse; + return response.payload; + } + + interruptExecution( + executionId: string, + requestId?: string, + ): Promise { + return this.controlExecution(executionId, "interrupt", requestId); + } + + archiveExecution( + executionId: string, + requestId?: string, + ): Promise { + return this.controlExecution(executionId, "archive", requestId); + } + async durableOwnedAgentIds(): Promise { return (await this.daemon!.agentStorage.list()) .filter((record) => record.owner?.kind === "daemon") @@ -723,6 +767,31 @@ export class HubRelationshipHarness { .map((agent) => agent.id); } + ownedAgentIsRunning(agentId: string): boolean { + return this.daemon!.agentManager.hasInFlightRun(agentId); + } + + async ownedAgentArchivedAt(agentId: string): Promise { + return (await this.daemon!.agentStorage.get(agentId))?.archivedAt ?? null; + } + + async createForeignExecution(executionId: string): Promise { + const agent = await this.daemon!.agentManager.createAgent( + { provider: "codex", cwd: this.root }, + undefined, + { + workspaceId: "foreign-workspace", + owner: { kind: "daemon", daemonId: "another-daemon", executionId }, + }, + ); + return agent.id; + } + + async agentRemainsAvailable(agentId: string): Promise { + const record = await this.daemon!.agentStorage.get(agentId); + return this.daemon!.agentManager.getAgent(agentId) !== null && !record?.archivedAt; + } + agentSubscriptionCount(): number { return this.daemon!.agentManager.subscriptionCount(); } @@ -765,6 +834,10 @@ export class HubRelationshipHarness { return this.root; } + repoExists(): boolean { + return existsSync(this.root); + } + async waitForOwnedArchiveCompletion( agentId: string, ): Promise<{ agentArchivedAt: string; workspaceArchivedAt: string }> { @@ -849,6 +922,10 @@ export class HubRelationshipHarness { }; } + pathsReferToSameLocation(left: string, right: string): boolean { + return this.comparablePath(left) === this.comparablePath(right); + } + async createBranch(branch: string): Promise { await execFileAsync("git", ["-C", this.root, "branch", branch]); } @@ -889,6 +966,20 @@ export class HubRelationshipHarness { .map((line) => line.slice("worktree ".length)); } + async createSiblingWorkspace(cwd: string): Promise { + const client = await this.trustedClient(); + try { + const result = await client.createWorkspace({ + source: { kind: "directory", path: cwd }, + title: "sibling", + }); + if (!result.workspace) throw new Error(result.error ?? "Failed to create sibling workspace"); + return result.workspace.id; + } finally { + await client.close(); + } + } + async createOwnedConcurrently(executionId = "execution-1"): Promise<{ first: AcceptedCreate; duplicate: AcceptedCreate; @@ -1388,6 +1479,10 @@ export class HubRelationshipHarness { }, input, ), + interruptAgent: (agentId) => manager.cancelAgentRun(agentId), + archiveAgent: (agentId) => manager.archiveAgent(agentId), + listActiveWorkspaces: async () => [], + archiveWorkspace: async () => undefined, }); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2119218dcb9..04f04fe6f28 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1898,9 +1898,13 @@ export class Session { } private dispatchHubExecutionMessage(msg: SessionInboundMessage): Promise | undefined { - return msg.type === "hub.execution.agent.create.request" - ? this.hubExecutionController?.createAgent(msg) - : undefined; + if (msg.type === "hub.execution.agent.create.request") { + return this.hubExecutionController?.createAgent(msg); + } + if (msg.type === "hub.execution.control.request") { + return this.hubExecutionController?.controlExecution(msg); + } + return undefined; } private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { From 65633004b23d6eeeda9321e04f096ca647694b2b Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:29:27 +0000 Subject: [PATCH 079/420] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 87b94334e7a..e6d055300fe 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-iu8+JvLLY7XUHCfDFpputuXEIZCe37b5aqnupJhkY28= +sha256-ZXSVfMuLZDB+AXI4kCqkQJoGa7xFh7AEmx+1agvSXYk= From 51ab86bab6c43186181e0ca97261c8406a1e9625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=B4=E5=A5=B9=E5=91=BD=40?= <908280968@qq.com> Date: Sun, 26 Jul 2026 02:45:55 +0800 Subject: [PATCH 080/420] test(cli): include Opus 5 in provider expectations (#2425) --- packages/cli/tests/15-provider.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index f194ff1d488..8cb3b47302c 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -45,6 +45,11 @@ interface ProviderListRow { } const EXPECTED_CLAUDE_MODELS = [ + { + id: "claude-opus-5", + model: "Opus 5", + descriptionFragment: "Latest release", + }, { id: "claude-fable-5", model: "Fable 5", @@ -58,7 +63,7 @@ const EXPECTED_CLAUDE_MODELS = [ { id: "claude-opus-4-8", model: "Opus 4.8", - descriptionFragment: "Latest release", + descriptionFragment: "Previous release", }, { id: "claude-sonnet-5", From c9fb31f709a771a18cae279c8506e24c5b6fb7ab Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Jul 2026 10:03:44 +0200 Subject: [PATCH 081/420] Fix Claude 5 context window selection (#2433) * fix(claude): select the correct Opus 5 context window Bare Opus 5 IDs resolve to 200K behind third-party gateways. Expose explicit context variants and require a Claude Code version that recognizes the model. * fix(claude): add Fable and Sonnet context variants * test(cli): expect Claude context variants * test(cli): compose Claude catalog expectations * fix(claude): preserve catalog on version probe failure * fix(claude): preserve parsed context variants * chore(release): cut 0.2.2 --- CHANGELOG.md | 6 + package-lock.json | 114 ++++++++--------- package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 +- packages/cli/tests/15-provider.test.ts | 42 +++++- packages/client/package.json | 6 +- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 +- .../agent/providers/claude/agent.test.ts | 39 +++++- .../server/agent/providers/claude/agent.ts | 40 +++++- .../agent/providers/claude/model-manifest.ts | 121 ++++++++++++++---- .../agent/providers/claude/models.test.ts | 83 ++++++++++-- .../server/agent/providers/claude/models.ts | 7 +- packages/website/package.json | 2 +- 19 files changed, 370 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6287f6a59ec..dc541a62018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.2 - 2026-07-25 + +### Fixed + +- Claude 5 models now use the correct context windows. + ## 0.2.1 - 2026-07-24 ### Added diff --git a/package-lock.json b/package-lock.json index 3ef7ac8bc8e..458b1be374b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.2.1", + "version": "0.2.2", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35211,7 +35211,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36236,12 +36236,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.1", - "@getpaseo/protocol": "0.2.1", - "@getpaseo/server": "0.2.1", + "@getpaseo/client": "0.2.2", + "@getpaseo/protocol": "0.2.2", + "@getpaseo/server": "0.2.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36488,10 +36488,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { - "@getpaseo/protocol": "0.2.1", - "@getpaseo/relay": "0.2.1", + "@getpaseo/protocol": "0.2.2", + "@getpaseo/relay": "0.2.2", "zod": "^4.4.3" }, "devDependencies": { @@ -36502,7 +36502,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.2.1", + "version": "0.2.2", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36745,7 +36745,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37641,7 +37641,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -37873,7 +37873,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "zod": "^4.4.3" }, @@ -37886,7 +37886,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38104,15 +38104,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.214", + "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.1", - "@getpaseo/highlight": "0.2.1", - "@getpaseo/protocol": "0.2.1", - "@getpaseo/relay": "0.2.1", + "@getpaseo/client": "0.2.2", + "@getpaseo/highlight": "0.2.2", + "@getpaseo/protocol": "0.2.2", + "@getpaseo/relay": "0.2.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38156,22 +38156,22 @@ } }, "packages/server/node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.214", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.214.tgz", - "integrity": "sha512-wt5ImwhU+p259Zt4K/Q9v5xVi6ruxYO5+KFICyJxnjs/QFEClAeSRqhcXx1J8jgGfatPW1faw09hkm66680UjA==", + "version": "0.3.220", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz", + "integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.214", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.214", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.214", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.214", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.214", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.214", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.214", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.214" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -38180,9 +38180,9 @@ } }, "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.214", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.214.tgz", - "integrity": "sha512-vAAOeVtlXs3p7MpFVfvfu9ja32eaKtB+MDZnPxCbdzxJk5nofCHlNsHa1UJwXHOsP9lOnFYNIccYztsZ4DNaJA==", + "version": "0.3.220", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz", + "integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==", "cpu": [ "arm64" ], @@ -38193,9 +38193,9 @@ ] }, "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.214", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.214.tgz", - "integrity": "sha512-NTbV8U2yucxCWqEiDC7L0MUehNmd8x3Op8OGzLZRhMF3xmQBy2DxpXiNZgSwwKWNDC3HdgKmJM5ZBbT7XrF/0g==", + "version": "0.3.220", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz", + "integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==", "cpu": [ "x64" ], @@ -38206,9 +38206,9 @@ ] }, "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.214", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.214.tgz", - "integrity": "sha512-KBCf+BlusG0ZcgvpjjHwv1kh+6WiR8vJbPjpR2udwkrtcQgKLN+24l+FeaQEzO2TL+ExCmM1KC4tNPvnPpy+tw==", + "version": "0.3.220", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz", + "integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==", "cpu": [ "arm64" ], @@ -38219,9 +38219,9 @@ ] }, "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.214", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.214.tgz", - "integrity": "sha512-i06wQRsmevE7spY1ryYfs+NP+xdZ1FwAyTjDaF0k/xG+cgtzZYghTFUemdYF3GVwgWgpcava9GiCFDT3DoHI6w==", + "version": "0.3.220", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz", + "integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==", "cpu": [ "arm64" ], @@ -38232,9 +38232,9 @@ ] }, "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.214", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.214.tgz", - "integrity": "sha512-vqadSceJkBKHaTUszxI35uTuECGWtsHWK/mOwIJ4b9DUtYYySz6EJEk4gHg4ccutisJ/oRVCpFXHIKFb+osrKw==", + "version": "0.3.220", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz", + "integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==", "cpu": [ "x64" ], @@ -38245,9 +38245,9 @@ ] }, "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.214", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.214.tgz", - "integrity": "sha512-948RstHDhs0E69h+2dKYWIAL1kcwEme4zvtgNUwP8XpKXzWOhrTKmd6MAokHn25zwfuSx5d+HE0XHfFH6R4hLg==", + "version": "0.3.220", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz", + "integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==", "cpu": [ "x64" ], @@ -38258,9 +38258,9 @@ ] }, "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.214", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.214.tgz", - "integrity": "sha512-CEKlPFCPv+ee79utQMEDSsgeo2f27ulhNHpjrKIt1jXz5G04J7lAWN/QwvPDv9XaLBkWpyfqZWiPXlRcwuaO/w==", + "version": "0.3.220", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz", + "integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==", "cpu": [ "arm64" ], @@ -38271,9 +38271,9 @@ ] }, "packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.214", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.214.tgz", - "integrity": "sha512-cJMJfFoR9IWBZWnTtt9PnEm89hGOsZjoDNjOCEOF3+x+g/ANIXAjMJTcaQYv2HKh6MylWZ0AkxBASI+twkukaQ==", + "version": "0.3.220", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz", + "integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==", "cpu": [ "x64" ], @@ -38649,7 +38649,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 812039714b0..2dfe2723d6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.2.1", + "version": "0.2.2", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 2df5e0a570e..ffa7d475dd1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.2.1", + "version": "0.2.2", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 96ba2e6cdf6..c3ab5b8db72 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.2.1", + "version": "0.2.2", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.1", - "@getpaseo/protocol": "0.2.1", - "@getpaseo/server": "0.2.1", + "@getpaseo/client": "0.2.2", + "@getpaseo/protocol": "0.2.2", + "@getpaseo/server": "0.2.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index 8cb3b47302c..ec07fe8629e 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -107,6 +107,38 @@ const EXPECTED_CLAUDE_MODELS = [ }, ] as const; +const EXPECTED_CLAUDE_CONTEXT_MODELS = [ + { + id: "claude-opus-5[1m]", + model: "Opus 5 1M", + descriptionFragment: "1M context window", + }, + { + id: "claude-opus-5", + model: "Opus 5", + descriptionFragment: "200K context window", + }, + { + id: "claude-fable-5[1m]", + model: "Fable 5 1M", + descriptionFragment: "1M context window", + }, + { + id: "claude-sonnet-5[1m]", + model: "Sonnet 5 1M", + descriptionFragment: "1M context window", + }, +] as const; + +const EXPECTED_CLAUDE_CATALOG_MODELS = [ + ...new Map( + [...EXPECTED_CLAUDE_MODELS, ...EXPECTED_CLAUDE_CONTEXT_MODELS].map((model) => [ + model.id, + model, + ]), + ).values(), +]; + let claudeModelIdsFromJson: string[] = []; let claudeModelsFromJson: ProviderModel[] = []; @@ -139,18 +171,18 @@ async function runProviderModelsJson(provider: string): Promise function assertClaudeModels(data: ProviderModel[]): void { assert.strictEqual( data.length, - EXPECTED_CLAUDE_MODELS.length, + EXPECTED_CLAUDE_CATALOG_MODELS.length, "claude output should match the current catalog size", ); const byId = new Map(data.map((model) => [model.id, model])); const ids = [...byId.keys()].sort(); - const expectedIds = EXPECTED_CLAUDE_MODELS.map((model) => model.id).sort(); + const expectedIds = EXPECTED_CLAUDE_CATALOG_MODELS.map((model) => model.id).sort(); assert.strictEqual(byId.size, data.length, "claude model IDs should be unique"); assert.deepStrictEqual(ids, expectedIds, "claude IDs should match the current catalog"); - for (const expectedModel of EXPECTED_CLAUDE_MODELS) { + for (const expectedModel of EXPECTED_CLAUDE_CATALOG_MODELS) { const actualModel = byId.get(expectedModel.id); assert(actualModel, `claude output should include ${expectedModel.id}`); assert.strictEqual( @@ -388,7 +420,7 @@ try { const lines = result.stdout.trim().split("\n").filter(Boolean); assert.strictEqual( lines.length, - EXPECTED_CLAUDE_MODELS.length, + EXPECTED_CLAUDE_CATALOG_MODELS.length, "should have one line per Claude catalog model", ); assert.deepStrictEqual( @@ -398,7 +430,7 @@ try { ); assert.deepStrictEqual( [...lines].sort(), - EXPECTED_CLAUDE_MODELS.map((model) => model.id).sort(), + EXPECTED_CLAUDE_CATALOG_MODELS.map((model) => model.id).sort(), "--quiet should print the current Claude catalog IDs", ); assert( diff --git a/packages/client/package.json b/packages/client/package.json index 672adae3a80..4988a509b85 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.2.1", + "version": "0.2.2", "description": "Paseo client SDK package", "files": [ "dist", @@ -35,8 +35,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.2.1", - "@getpaseo/relay": "0.2.1", + "@getpaseo/protocol": "0.2.2", + "@getpaseo/relay": "0.2.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 600bb8ab39f..8fd412ef813 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.2.1", + "version": "0.2.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 12a461c7382..2974d5db00d 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.1", + "version": "0.2.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index a899c147603..cf8eab5508d 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.2.1", + "version": "0.2.2", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index e60ef71c0ce..ba5e2323293 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.2.1", + "version": "0.2.2", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index f2cac7da2bc..a156443d51e 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.2.1", + "version": "0.2.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 61d3e3ea816..9e5f9186c6f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.2.1", + "version": "0.2.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -65,12 +65,12 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.214", + "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.1", - "@getpaseo/highlight": "0.2.1", - "@getpaseo/protocol": "0.2.1", - "@getpaseo/relay": "0.2.1", + "@getpaseo/client": "0.2.2", + "@getpaseo/highlight": "0.2.2", + "@getpaseo/protocol": "0.2.2", + "@getpaseo/relay": "0.2.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 60c6cc16e3f..e773cd73590 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -11,6 +11,7 @@ import { convertClaudeHistoryEntry, normalizeClaudeAskUserQuestionRequestInput, normalizeClaudeAskUserQuestionUpdatedInput, + resolveClaudeCodeVersion, toClaudeSdkMcpConfig, } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; @@ -405,6 +406,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin", + resolveVersion: async () => "2.1.219", configDir: emptyConfigDir, }); const { models } = await client.fetchCatalog({ @@ -414,11 +416,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }); expect(models.map((m) => m.id)).toEqual([ + "claude-opus-5[1m]", "claude-opus-5", + "claude-fable-5[1m]", "claude-fable-5", "claude-opus-4-8[1m]", "claude-opus-4-8", "claude-sonnet-5", + "claude-sonnet-5[1m]", "claude-opus-4-7[1m]", "claude-opus-4-7", "claude-opus-4-6[1m]", @@ -434,7 +439,30 @@ describe("ClaudeAgentClient.fetchCatalog", () => { } const defaultModel = models.find((m) => m.isDefault); - expect(defaultModel?.id).toBe("claude-opus-5"); + expect(defaultModel?.id).toBe("claude-opus-5[1m]"); + } finally { + await fs.rm(emptyConfigDir, { recursive: true, force: true }); + } + }); + + test("preserves the catalog when Claude Code version detection fails", async () => { + const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-")); + try { + const client = new ClaudeAgentClient({ + logger, + resolveVersion: async () => { + throw new Error("unrecognized version output"); + }, + configDir: emptyConfigDir, + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/claude-models", + force: false, + }); + + expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5[1m]"); + expect(models.map((model) => model.id)).toContain("claude-fable-5[1m]"); } finally { await fs.rm(emptyConfigDir, { recursive: true, force: true }); } @@ -446,6 +474,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin", + resolveVersion: async () => "2.1.219", configDir: emptyConfigDir, }); const { models } = await client.fetchCatalog({ @@ -475,6 +504,10 @@ describe("ClaudeAgentClient.fetchCatalog", () => { describe("ClaudeAgentClient binary resolution", () => { const logger = createTestLogger(); + test("resolves the installed Claude Code version", async () => { + await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/); + }); + test("loads user, project, and local Claude settings", async () => { const queryReturn = vi.fn(); queryReturn.mockResolvedValue(undefined); @@ -2083,10 +2116,10 @@ describe("ClaudeAgentSession context window usage", () => { } }); - test("native 1M Claude models seed active context window usage from the catalog", async () => { + test("selected 1M Claude models seed active context window usage from the catalog", async () => { const session = await createSessionForTurns( [[createInitMessage(), createMessageStartEvent(), createSuccessResult()]], - { model: "claude-sonnet-5" }, + { model: "claude-sonnet-5[1m]" }, ); try { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 9b0429fffd5..463540825d6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -37,6 +37,7 @@ import { import { CLAUDE_DISABLED_THINKING_OPTION_ID, CLAUDE_ULTRACODE_THINKING_OPTION_ID, + parseClaudeCodeVersion, resolveClaudeDisabledThinkingForModel, } from "./model-manifest.js"; import { parsePartialJsonObject } from "./partial-json.js"; @@ -363,6 +364,7 @@ interface ClaudeAgentClientOptions { runtimeSettings?: ProviderRuntimeSettings; queryFactory?: ClaudeQueryFactory; resolveBinary?: () => Promise; + resolveVersion?: () => Promise; configDir?: string; } @@ -1439,6 +1441,7 @@ export class ClaudeAgentClient implements AgentClient { private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; + private readonly resolveVersion: () => Promise; private readonly configDir?: string; constructor(options: ClaudeAgentClientOptions) { @@ -1447,6 +1450,8 @@ export class ClaudeAgentClient implements AgentClient { this.runtimeSettings = options.runtimeSettings; this.queryFactory = options.queryFactory; this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings)); + this.resolveVersion = + options.resolveVersion ?? (() => resolveClaudeCodeVersion(this.runtimeSettings)); this.configDir = options.configDir; } @@ -1498,7 +1503,17 @@ export class ClaudeAgentClient implements AgentClient { async fetchCatalog(_options: FetchCatalogOptions): Promise { // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. - const models = await getClaudeModelsWithSettings(this.logger, this.configDir); + let claudeCodeVersion: string | undefined; + try { + claudeCodeVersion = await this.resolveVersion(); + } catch (error) { + this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog"); + } + const models = await getClaudeModelsWithSettings( + this.logger, + this.configDir, + claudeCodeVersion, + ); const modes = detectIneligibleAutoModeTransport( createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), ) @@ -1620,6 +1635,29 @@ async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): P ); } +export async function resolveClaudeCodeVersion( + runtimeSettings?: ProviderRuntimeSettings, +): Promise { + const launch = await resolveProviderLaunch({ + commandConfig: runtimeSettings?.command, + defaultBinary: "claude", + }); + const availability = await checkProviderLaunchAvailable(launch); + if (!availability.available) { + throw new Error("Claude binary not found while resolving Claude Code version"); + } + const executable = availability.resolvedPath ?? launch.command; + const { stdout, stderr } = await execCommand(executable, [...launch.args, "--version"], { + ...createProviderEnvSpec({ runtimeSettings }), + timeout: 5_000, + }); + const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`); + if (!version) { + throw new Error("Unable to parse Claude Code version from --version output"); + } + return version.join("."); +} + async function resolveClaudeAuth( launch: ResolvedProviderLaunch, availability: { resolvedPath: string | null }, diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index b569f0da161..ec9efdb1f69 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -6,7 +6,8 @@ interface ClaudeModelManifestEntry { id: string; label: string; description: string; - isDefault?: boolean; + defaultPriority?: number; + minimumClaudeCodeVersion?: string; contextWindowMaxTokens?: number; effortLevels?: readonly ClaudeEffortLevel[]; supportsThinkingDisabled?: boolean; @@ -30,20 +31,39 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off"; export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode"; export const CLAUDE_MODEL_MANIFEST = [ + { + id: "claude-opus-5[1m]", + label: "Opus 5 1M", + description: "Opus 5 with 1M context window", + defaultPriority: 2, + minimumClaudeCodeVersion: "2.1.219", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsThinkingDisabled: true, + }, { id: "claude-opus-5", label: "Opus 5", - description: "Opus 5 · Latest release", - isDefault: true, - contextWindowMaxTokens: 1_000_000, + description: "Opus 5 · 200K context window", + minimumClaudeCodeVersion: "2.1.219", + contextWindowMaxTokens: 200_000, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, supportsThinkingDisabled: true, }, + { + id: "claude-fable-5[1m]", + label: "Fable 5 1M", + description: "Fable 5 with 1M context window", + minimumClaudeCodeVersion: "2.1.169", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + }, { id: "claude-fable-5", label: "Fable 5", description: "Fable 5 · Most powerful model", - contextWindowMaxTokens: 1_000_000, + minimumClaudeCodeVersion: "2.1.169", + contextWindowMaxTokens: 200_000, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, }, { @@ -59,6 +79,7 @@ export const CLAUDE_MODEL_MANIFEST = [ id: "claude-opus-4-8", label: "Opus 4.8", description: "Opus 4.8 · Previous release", + defaultPriority: 1, contextWindowMaxTokens: 200_000, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, supportsThinkingDisabled: true, @@ -68,6 +89,14 @@ export const CLAUDE_MODEL_MANIFEST = [ id: "claude-sonnet-5", label: "Sonnet 5", description: "Sonnet 5 · Best for everyday tasks", + contextWindowMaxTokens: 200_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsThinkingDisabled: true, + }, + { + id: "claude-sonnet-5[1m]", + label: "Sonnet 5 1M", + description: "Sonnet 5 with 1M context window", contextWindowMaxTokens: 1_000_000, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, supportsThinkingDisabled: true, @@ -155,31 +184,73 @@ function buildThinkingOptions( return options; } -export function getClaudeManifestModels(): AgentModelDefinition[] { - return CLAUDE_MODEL_MANIFEST.map((model) => { +export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelDefinition[] { + const availableModels: readonly ClaudeModelManifestEntry[] = CLAUDE_MODEL_MANIFEST.filter( + (model) => isModelAvailableInClaudeCode(model, claudeCodeVersion), + ); + const defaultModel = availableModels.reduce( + (selected, candidate) => + (candidate.defaultPriority ?? 0) > (selected?.defaultPriority ?? 0) ? candidate : selected, + undefined, + ); + + return availableModels.map((model) => { const thinkingOptions = buildThinkingOptions( - "effortLevels" in model ? model.effortLevels : undefined, - "supportsThinkingDisabled" in model && model.supportsThinkingDisabled, + model.effortLevels, + model.supportsThinkingDisabled === true, ); - return { + const definition: AgentModelDefinition = { provider: "claude", id: model.id, label: model.label, description: model.description, - ...("isDefault" in model && model.isDefault ? { isDefault: true } : {}), - ...(model.contextWindowMaxTokens !== undefined - ? { contextWindowMaxTokens: model.contextWindowMaxTokens } - : {}), - ...(thinkingOptions - ? { - thinkingOptions, - defaultThinkingOptionId: "effortLevels" in model ? model.effortLevels?.[0] : undefined, - } - : {}), }; + if (model === defaultModel) { + definition.isDefault = true; + } + if (model.contextWindowMaxTokens !== undefined) { + definition.contextWindowMaxTokens = model.contextWindowMaxTokens; + } + if (thinkingOptions) { + definition.thinkingOptions = thinkingOptions; + definition.defaultThinkingOptionId = model.effortLevels?.[0]; + } + return definition; }); } +function isModelAvailableInClaudeCode( + model: ClaudeModelManifestEntry, + claudeCodeVersion: string | undefined, +): boolean { + if (!model.minimumClaudeCodeVersion || claudeCodeVersion === undefined) { + return true; + } + return compareVersions(claudeCodeVersion, model.minimumClaudeCodeVersion) >= 0; +} + +function compareVersions(left: string, right: string): number { + const leftParts = parseClaudeCodeVersion(left); + const rightParts = parseClaudeCodeVersion(right); + if (!leftParts || !rightParts) { + return -1; + } + for (let index = 0; index < leftParts.length; index += 1) { + const difference = leftParts[index] - rightParts[index]; + if (difference !== 0) { + return difference; + } + } + return 0; +} + +export function parseClaudeCodeVersion(value: string): [number, number, number] | null { + const match = + value.match(/\b(\d+)\.(\d+)\.(\d+)\s+\(Claude Code\)/i) ?? + value.match(/\b(\d+)\.(\d+)\.(\d+)\b/); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +} + export interface ClaudeDisabledThinkingResolution { supported: boolean; fallbackThinkingOptionId: string | undefined; @@ -236,7 +307,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) } const singleSegmentMatch = trimmed.match( - /^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?(?:[-_ ]+\d{8})?$/i, + /^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, ); if (singleSegmentMatch) { return normalizeSingleSegmentClaudeModelId( @@ -247,7 +318,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) } const runtimeMatch = trimmed.match( - /^(?:claude[-_ ])?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?(?:[-_ ]+\d{8})?$/i, + /^(?:claude[-_ ])?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, ); if (!runtimeMatch) { return null; @@ -257,7 +328,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) runtimeMatch[1], runtimeMatch[2], runtimeMatch[3], - Boolean(runtimeMatch[4]), + trimmed.toLowerCase().includes("[1m]"), ); } @@ -284,7 +355,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): const normalizedModelId = normalizeSingleSegmentClaudeModelId( singleSegmentMatch[1], singleSegmentMatch[2], - Boolean(singleSegmentMatch[3]), + trimmed.toLowerCase().includes("[1m]"), ); if (normalizedModelId) { return normalizedModelId; @@ -302,7 +373,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): runtimeMatch[1], runtimeMatch[2], runtimeMatch[3], - Boolean(runtimeMatch[4]), + trimmed.toLowerCase().includes("[1m]"), ); } diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 5b069dce139..c3e62b1dc33 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -10,6 +10,7 @@ import { CLAUDE_ULTRACODE_THINKING_OPTION_ID, claudeManifestModelSupportsFastMode, normalizeClaudeManifestModelId, + parseClaudeCodeVersion, resolveClaudeDisabledThinkingForModel, } from "./model-manifest.js"; import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js"; @@ -38,15 +39,25 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise claudeCodeVersion, + }); +} + describe("getClaudeModels", () => { it("returns all claude models", () => { const models = getClaudeModels(); expect(models.map((m) => m.id)).toEqual([ + "claude-opus-5[1m]", "claude-opus-5", + "claude-fable-5[1m]", "claude-fable-5", "claude-opus-4-8[1m]", "claude-opus-4-8", "claude-sonnet-5", + "claude-sonnet-5[1m]", "claude-opus-4-7[1m]", "claude-opus-4-7", "claude-opus-4-6[1m]", @@ -61,7 +72,7 @@ describe("getClaudeModels", () => { const models = getClaudeModels(); const defaults = models.filter((m) => m.isDefault); expect(defaults).toHaveLength(1); - expect(defaults[0].id).toBe("claude-opus-5"); + expect(defaults[0].id).toBe("claude-opus-5[1m]"); }); it("defines context window sizes in the catalog", () => { @@ -71,11 +82,14 @@ describe("getClaudeModels", () => { expect(contextWindows).toEqual( new Map([ - ["claude-opus-5", 1_000_000], - ["claude-fable-5", 1_000_000], + ["claude-opus-5[1m]", 1_000_000], + ["claude-opus-5", 200_000], + ["claude-fable-5[1m]", 1_000_000], + ["claude-fable-5", 200_000], ["claude-opus-4-8[1m]", 1_000_000], ["claude-opus-4-8", 200_000], - ["claude-sonnet-5", 1_000_000], + ["claude-sonnet-5", 200_000], + ["claude-sonnet-5[1m]", 1_000_000], ["claude-opus-4-7[1m]", 1_000_000], ["claude-opus-4-7", 200_000], ["claude-opus-4-6[1m]", 1_000_000], @@ -87,6 +101,20 @@ describe("getClaudeModels", () => { ); }); + it("filters models by their minimum Claude Code version", () => { + const oldVersionModels = getClaudeModels("2.1.218"); + expect(oldVersionModels.map((model) => model.id)).not.toContain("claude-opus-5[1m]"); + expect(oldVersionModels.map((model) => model.id)).not.toContain("claude-opus-5"); + expect(oldVersionModels.find((model) => model.isDefault)?.id).toBe("claude-opus-4-8"); + expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5[1m]"); + expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5"); + + expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5[1m]"); + expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5"); + expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5[1m]"); + expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5"); + }); + it("derives thinking options from model effort capabilities", () => { const models = new Map(getClaudeModels().map((model) => [model.id, model])); @@ -115,6 +143,9 @@ describe("getClaudeModels", () => { ?.label, ).toBe("Ultra Code"); expect(models.get("claude-sonnet-5")?.defaultThinkingOptionId).toBe("low"); + expect(models.get("claude-sonnet-5[1m]")?.thinkingOptions).toEqual( + models.get("claude-sonnet-5")?.thinkingOptions, + ); expect(models.get("claude-opus-4-7")?.thinkingOptions?.map((option) => option.id)).toEqual([ CLAUDE_DISABLED_THINKING_OPTION_ID, @@ -135,6 +166,9 @@ describe("getClaudeModels", () => { expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain( CLAUDE_DISABLED_THINKING_OPTION_ID, ); + expect(models.get("claude-fable-5[1m]")?.thinkingOptions).toEqual( + models.get("claude-fable-5")?.thinkingOptions, + ); expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined(); }); @@ -142,6 +176,7 @@ describe("getClaudeModels", () => { ["claude-opus-5", true, "low"], ["claude-opus-5-20260724", true, "low"], ["claude-sonnet-5", true, "low"], + ["claude-sonnet-5[1m]", true, "low"], ["claude-sonnet-5-20260101", true, "low"], ["claude-fable-5", false, "low"], ["claude-haiku-4-5", false, undefined], @@ -175,7 +210,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }, }); vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); - const client = new ClaudeAgentClient({ logger: createTestLogger() }); + const client = createCatalogClient(); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -228,7 +263,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-")); createdClaudeConfigDirs.push(configDir); vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); - const client = new ClaudeAgentClient({ logger: createTestLogger() }); + const client = createCatalogClient(); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -242,7 +277,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { it("falls back to hardcoded models when settings.json is malformed", async () => { const configDir = await createClaudeConfigDirWithRawSettings("{ nope"); vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); - const client = new ClaudeAgentClient({ logger: createTestLogger() }); + const client = createCatalogClient(); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -262,7 +297,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }, }); vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); - const client = new ClaudeAgentClient({ logger: createTestLogger() }); + const client = createCatalogClient(); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -282,7 +317,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }, }); vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); - const client = new ClaudeAgentClient({ logger: createTestLogger() }); + const client = createCatalogClient(); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -295,13 +330,29 @@ describe("ClaudeAgentClient.fetchCatalog", () => { "glm-5.1", ]); }); + + it("omits models that require a newer Claude Code version", async () => { + const client = createCatalogClient("2.1.218"); + + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: os.tmpdir(), + force: true, + }); + + expect(models.map((model) => model.id)).not.toContain("claude-opus-5[1m]"); + expect(models.map((model) => model.id)).not.toContain("claude-opus-5"); + }); }); describe("normalizeClaudeRuntimeModelId", () => { it("returns exact match for known model IDs", () => { + expect(normalizeClaudeRuntimeModelId("claude-opus-5[1m]")).toBe("claude-opus-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-opus-5")).toBe("claude-opus-5"); expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5"); + expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-5")).toBe("claude-sonnet-5"); + expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6"); expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6")).toBe("claude-sonnet-4-6"); @@ -315,9 +366,17 @@ describe("normalizeClaudeRuntimeModelId", () => { expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6"); expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5"); + expect(normalizeClaudeRuntimeModelId("claude-opus-5-20260724[1m]")).toBe("claude-opus-5[1m]"); + expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5[1m]"); + expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101[1m]")).toBe( + "claude-sonnet-5[1m]", + ); }); it("preserves [1m] suffix from runtime model strings", () => { + expect(normalizeClaudeRuntimeModelId("claude-opus-5[1m]")).toBe("claude-opus-5[1m]"); + expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]"); + expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]"); }); @@ -346,6 +405,12 @@ describe("normalizeClaudeRuntimeModelId", () => { }); }); +describe("parseClaudeCodeVersion", () => { + it("prefers the Claude Code version over a wrapper banner", () => { + expect(parseClaudeCodeVersion("wrapper 1.0.0\n2.1.219 (Claude Code)")).toEqual([2, 1, 219]); + }); +}); + describe("findClaudeModel", () => { it("resolves runtime model IDs to catalog entries", () => { expect(findClaudeModel("claude-sonnet-5-20260101")?.id).toBe("claude-sonnet-5"); diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 0e82cb01877..48bf900884b 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -17,8 +17,8 @@ const CLAUDE_SETTINGS_MODEL_ENV_KEYS = [ "ANTHROPIC_DEFAULT_HAIKU_MODEL", ] as const; -export function getClaudeModels(): AgentModelDefinition[] { - return getClaudeManifestModels(); +export function getClaudeModels(claudeCodeVersion?: string): AgentModelDefinition[] { + return getClaudeManifestModels(claudeCodeVersion); } export function findClaudeModel( @@ -34,8 +34,9 @@ export function findClaudeModel( export async function getClaudeModelsWithSettings( logger: Logger, configDir?: string, + claudeCodeVersion?: string, ): Promise { - const hardcodedModels = getClaudeModels(); + const hardcodedModels = getClaudeModels(claudeCodeVersion); const settingsModels = await readClaudeSettingsModels(logger, configDir); if (settingsModels.length === 0) { return hardcodedModels; diff --git a/packages/website/package.json b/packages/website/package.json index c7b3d3b8db2..7574ece0f3c 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.2.1", + "version": "0.2.2", "private": true, "type": "module", "scripts": { From e859d2df12eeeda1ee254431f2e922b236918278 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:10:45 +0000 Subject: [PATCH 082/420] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index e6d055300fe..cd0ec1a6ffb 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-ZXSVfMuLZDB+AXI4kCqkQJoGa7xFh7AEmx+1agvSXYk= +sha256-DaVBk1PxsNr1AQu/mTG1Inp67pLLDmc+Ed0IZ5GyzgY= From 72c7d3fe3ed5308810e418151112be21de2f9492 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Jul 2026 20:37:01 +0200 Subject: [PATCH 083/420] fix(app): keep streamed chat position stable on Android Ignore synthetic momentum-end events emitted after native anchor corrections. Only active user momentum may settle sticky-bottom intent. --- packages/app/src/agent-stream/strategy-native.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index a28c611e5a9..629f63fa086 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -359,6 +359,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const handleMomentumScrollEnd = useStableEvent( (event: NativeSyntheticEvent) => { + // Android can emit momentum-end after a programmatic anchor correction. + // Only momentum that still owns the user gesture may settle scroll intent. + if (!isUserScrollActiveRef.current) { + return; + } const isNearBottom = isScrollEventNearBottom(event); clearPendingUserScrollEnd(); isUserScrollActiveRef.current = false; From fe28850fad140f88243e4cdcc74d89a8ea2d62e7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Jul 2026 20:46:06 +0200 Subject: [PATCH 084/420] docs(relay): point readers to the official service Remove the maintainer callout and star history chart from the README while keeping translated copies aligned. --- README.ja.md | 16 ++-------------- README.md | 24 +++--------------------- README.zh-CN.md | 16 ++-------------- docs/architecture.md | 6 ++++-- docs/docker.md | 5 +++-- docs/release.md | 2 +- public-docs/community.md | 18 +++++++++++------- public-docs/security.md | 2 +- 8 files changed, 27 insertions(+), 62 deletions(-) diff --git a/README.ja.md b/README.ja.md index 224885c6bc8..d5881563ad0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -151,23 +151,11 @@ npm run build:server npm run typecheck ``` -## コミュニティ +## 関連プロジェクト -- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー +- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能 ---- - -

- - - - - getpaseo/paseo のスター履歴チャート - - -

- ## ライセンス AGPL-3.0 diff --git a/README.md b/README.md index d3071915831..86f7ab10452 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,6 @@ Paseo mobile app

-> [!NOTE] -> I'm a solo maintainer and don't always keep up with GitHub Issues daily. -> If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me. - ---- - Run agents in parallel on your own machines. Ship from your phone or your desk. - **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. @@ -144,7 +138,7 @@ Quick monorepo package map: - `packages/app`: Expo client (iOS, Android, web) - `packages/cli`: `paseo` CLI for daemon and agent workflows - `packages/desktop`: Electron desktop app -- `packages/relay`: Relay package for remote connectivity +- `packages/relay`: Relay transport and encryption used by the daemon and clients - `packages/website`: Marketing site and documentation (`paseo.sh`) Common commands: @@ -166,23 +160,11 @@ npm run build:server npm run typecheck ``` -## Community +## Related projects -- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go +- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension ---- - -

- - - - - Star history chart for getpaseo/paseo - - -

- ## License AGPL-3.0 diff --git a/README.zh-CN.md b/README.zh-CN.md index e6baeb8e5af..35e3569c32f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -151,9 +151,9 @@ npm run build:server npm run typecheck ``` -## 社区 +## 相关项目 -- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay +- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写 - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展 ### 自托管 relay TLS @@ -202,18 +202,6 @@ server { } ``` ---- - -

- - - - - Star history chart for getpaseo/paseo - - -

- ## License AGPL-3.0 diff --git a/docs/architecture.md b/docs/architecture.md index c46e463c784..354e0a77ca1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -133,16 +133,18 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex Communicates with the daemon via the same WebSocket protocol as the app. -### `packages/relay` — E2E encrypted relay +### `packages/relay` — Relay transport and E2E encryption Enables remote access when the daemon is behind a firewall. - Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption -- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content +- The relay is zero-knowledge — it routes encrypted bytes and cannot read content - Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) - Pairing via QR code transfers the daemon's public key to the client - Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS` +The production relay server lives in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed. + See [SECURITY.md](../SECURITY.md) for the full threat model. ### Paseo Hub diff --git a/docs/docker.md b/docs/docker.md index 26833130342..15e22081bb7 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -176,8 +176,9 @@ IPs and `localhost` are allowed by default. - Set `PASEO_PASSWORD` for any published port or network-reachable deployment. - Prefer HTTPS at the reverse proxy for direct browser access. -- Use the Paseo relay for untrusted networks or mobile access when you do not - want to expose the daemon port directly. +- Use the [official Paseo relay](https://github.com/getpaseo/paseo-relay) for + untrusted networks or mobile access when you do not want to expose the daemon + port directly. - The container is the isolation boundary for agents. Agents can read and write whatever you mount into `/workspace` and whatever credentials you place in `/home/paseo`. diff --git a/docs/release.md b/docs/release.md index ed40fa278fb..8b53f9547bd 100644 --- a/docs/release.md +++ b/docs/release.md @@ -78,7 +78,7 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. -Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change. +The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production. **Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta". diff --git a/public-docs/community.md b/public-docs/community.md index 8d835e01a9a..feeff3baad0 100644 --- a/public-docs/community.md +++ b/public-docs/community.md @@ -1,21 +1,25 @@ --- -title: Community projects -description: Community-built tools and integrations for Paseo, including self-hosted Docker builds and an alternative relay. -nav: Community +title: Related projects +description: The official Paseo relay and community-built tools and integrations. +nav: Related projects order: 7 category: Getting started --- -# Community projects +# Related projects -Projects built by the Paseo community. These **aren't official Paseo projects** and aren't covered by Paseo's support, but they're useful starting points, especially for self-hosting. Review the code before running anything that touches your machine or your agents. +## Official relay + +**[getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay)** is Paseo's official distributed relay server, written in Elixir. It powers hosted remote access and can also be self-hosted. For how the relay fits into Paseo's connection model, see [Security](/docs/security). + +## Community projects + +The projects below are built by the Paseo community. They **aren't official Paseo projects** and aren't covered by Paseo's support, but they're useful starting points, especially for self-hosting. Review the code before running anything that touches your machine or your agents. ## Self-hosting - **[blockfeed/paseo-selfhosted](https://github.com/blockfeed/paseo-selfhosted)**, a Docker build that runs the Paseo web UI connected to a self-hosted local daemon. A good reference if you want a containerized setup. For the built-in way to serve the UI from the daemon, see [Self-hosting the web UI](/docs/web-ui). -- **[zenghongtu/paseo-relay](https://github.com/zenghongtu/paseo-relay)**, a lightweight self-hosted relay server for Paseo, written in Go. Run your own relay instead of the hosted one for fully self-hosted remote access. For how the relay fits into Paseo's connection model, see [Security](/docs/security). - - **[paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode)**, a VS Code extension. ## Add your project diff --git a/public-docs/security.md b/public-docs/security.md index 981b82362c5..56c27099e74 100644 --- a/public-docs/security.md +++ b/public-docs/security.md @@ -23,7 +23,7 @@ Clients connect to the daemon over WebSocket. There are two ways to establish th ## Relay connections (recommended) -The relay is the simplest way to connect from your phone. It requires no VPN setup, no port forwarding, and no firewall configuration. The daemon can stay bound to localhost or a socket file, it connects _outbound_ to the relay, and your phone meets it there. +The relay is the simplest way to connect from your phone. It requires no VPN setup, no port forwarding, and no firewall configuration. The daemon can stay bound to localhost or a socket file, it connects _outbound_ to the relay, and your phone meets it there. The official relay server is the open-source Elixir service at [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). > **The relay is designed to be untrusted.** All traffic between your phone and daemon is end-to-end encrypted. The relay server cannot read your messages, see your code, or modify traffic without detection. Even if the relay is compromised, your data remains protected. From 392095c1b2cbad05fba86031b0521d80d747663b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Jul 2026 21:55:37 +0200 Subject: [PATCH 085/420] feat(desktop): stop the daemon when you quit the app (#2454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quitting the desktop app now shuts down the daemon it started, so "restart the app" is a complete reset users can act on without first learning what a daemon is. The old default kept it alive; the toggle under Settings > Host still opts back in. Existing installs already persisted `keepRunningAfterQuit: true` from the old default, so a new default alone would only reach fresh installs. A one-time migration resets it, and an explicit toggle afterwards persists the migration flag and is never overridden again. Only a desktop-managed daemon is stopped — one started with `paseo daemon start` is left alone. --- docs/architecture.md | 2 +- docs/product.md | 3 +- .../src/desktop/settings/desktop-settings.ts | 2 +- packages/app/src/hooks/use-settings/fakes.ts | 2 +- .../desktop/src/daemon/quit-lifecycle.test.ts | 8 ++-- .../src/settings/desktop-settings.test.ts | 44 +++++++++++++++++++ .../desktop/src/settings/desktop-settings.ts | 15 ++++++- 7 files changed, 67 insertions(+), 9 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 354e0a77ca1..012e4708377 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -382,5 +382,5 @@ $PASEO_HOME/ ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` -2. **Managed desktop**: Electron app spawns daemon as subprocess +2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`). 3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption diff --git a/docs/product.md b/docs/product.md index 2eeeb0b8451..e8a7a553b51 100644 --- a/docs/product.md +++ b/docs/product.md @@ -43,7 +43,8 @@ This architecture means: - The daemon can run on any machine: laptop, VM, remote server - Multiple clients can connect simultaneously -- Agents keep running when you close the app +- Agents keep running when a client disconnects — the daemon owns them, not the client +- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected ## Target user diff --git a/packages/app/src/desktop/settings/desktop-settings.ts b/packages/app/src/desktop/settings/desktop-settings.ts index cbda11e02d3..3ff8576528f 100644 --- a/packages/app/src/desktop/settings/desktop-settings.ts +++ b/packages/app/src/desktop/settings/desktop-settings.ts @@ -28,7 +28,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { releaseChannel: "stable", daemon: { manageBuiltInDaemon: true, - keepRunningAfterQuit: true, + keepRunningAfterQuit: false, }, }; diff --git a/packages/app/src/hooks/use-settings/fakes.ts b/packages/app/src/hooks/use-settings/fakes.ts index 95d46ad9483..b13163e6d6f 100644 --- a/packages/app/src/hooks/use-settings/fakes.ts +++ b/packages/app/src/hooks/use-settings/fakes.ts @@ -31,7 +31,7 @@ const DEFAULT_DESKTOP: DesktopSettings = { releaseChannel: "stable", daemon: { manageBuiltInDaemon: true, - keepRunningAfterQuit: true, + keepRunningAfterQuit: false, }, }; diff --git a/packages/desktop/src/daemon/quit-lifecycle.test.ts b/packages/desktop/src/daemon/quit-lifecycle.test.ts index 1d636bd9502..5dd1e66806c 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.test.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.test.ts @@ -7,12 +7,12 @@ import { stopDesktopManagedDaemonOnQuitIfNeeded, } from "./quit-lifecycle"; -const SETTINGS_KEEP_RUNNING = DEFAULT_DESKTOP_SETTINGS; -const SETTINGS_STOP_ON_QUIT = { +const SETTINGS_STOP_ON_QUIT = DEFAULT_DESKTOP_SETTINGS; +const SETTINGS_KEEP_RUNNING = { ...DEFAULT_DESKTOP_SETTINGS, daemon: { ...DEFAULT_DESKTOP_SETTINGS.daemon, - keepRunningAfterQuit: false, + keepRunningAfterQuit: true, }, }; @@ -29,7 +29,7 @@ function waitForQuitLifecycle(): Promise { } describe("quit-lifecycle", () => { - it("only stops when keepRunningAfterQuit is explicitly disabled", () => { + it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => { expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true); expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false); }); diff --git a/packages/desktop/src/settings/desktop-settings.test.ts b/packages/desktop/src/settings/desktop-settings.test.ts index 0291f220ed7..445f5b08924 100644 --- a/packages/desktop/src/settings/desktop-settings.test.ts +++ b/packages/desktop/src/settings/desktop-settings.test.ts @@ -162,6 +162,50 @@ describe("desktop-settings", () => { expect(persisted).toBe(raw); }); + it("resets the pre-existing keep-running default so the daemon stops with the app", async () => { + const userDataPath = await createTempUserDataDir(); + directories.add(userDataPath); + await writeFile( + settingsFilePath(userDataPath), + JSON.stringify({ + version: 1, + settings: { + releaseChannel: "stable", + daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true }, + }, + migrations: { legacyRendererSettingsImported: true }, + }), + ); + const store = createDesktopSettingsStore({ userDataPath }); + + const settings = await store.get(); + + expect(settings.daemon.keepRunningAfterQuit).toBe(false); + }); + + it("keeps an explicit keep-running choice across restarts", async () => { + const userDataPath = await createTempUserDataDir(); + directories.add(userDataPath); + await writeFile( + settingsFilePath(userDataPath), + JSON.stringify({ + version: 1, + settings: { + releaseChannel: "stable", + daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true }, + }, + migrations: { legacyRendererSettingsImported: true }, + }), + ); + await createDesktopSettingsStore({ userDataPath }).patch({ + daemon: { keepRunningAfterQuit: true }, + }); + + const settings = await createDesktopSettingsStore({ userDataPath }).get(); + + expect(settings.daemon.keepRunningAfterQuit).toBe(true); + }); + it("migrates desktop-owned values from legacy renderer settings once", async () => { const userDataPath = await createTempUserDataDir(); directories.add(userDataPath); diff --git a/packages/desktop/src/settings/desktop-settings.ts b/packages/desktop/src/settings/desktop-settings.ts index cfadc1f6f3f..c8fe23d3e3b 100644 --- a/packages/desktop/src/settings/desktop-settings.ts +++ b/packages/desktop/src/settings/desktop-settings.ts @@ -22,6 +22,11 @@ interface PersistedDesktopSettingsDocument { settings: DesktopSettings; migrations: { legacyRendererSettingsImported: boolean; + // Installs created before the stop-on-quit default persisted the old + // `keepRunningAfterQuit: true` default to disk, so the new default alone + // would only reach fresh installs. Reset it once; a later explicit toggle + // persists this flag and is never overridden again. + daemonStopOnQuitDefaultApplied: boolean; }; } @@ -35,7 +40,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { releaseChannel: "stable", daemon: { manageBuiltInDaemon: true, - keepRunningAfterQuit: true, + keepRunningAfterQuit: false, }, }; @@ -72,6 +77,7 @@ function buildDefaultDocument(): PersistedDesktopSettingsDocument { }, migrations: { legacyRendererSettingsImported: false, + daemonStopOnQuitDefaultApplied: true, }, }; } @@ -180,11 +186,18 @@ function coerceDocument(input: unknown): PersistedDesktopSettingsDocument { const migrations = isRecord(input.migrations) ? { legacyRendererSettingsImported: input.migrations.legacyRendererSettingsImported === true, + daemonStopOnQuitDefaultApplied: input.migrations.daemonStopOnQuitDefaultApplied === true, } : { legacyRendererSettingsImported: false, + daemonStopOnQuitDefaultApplied: false, }; + if (!migrations.daemonStopOnQuitDefaultApplied) { + settings.daemon.keepRunningAfterQuit = DEFAULT_DESKTOP_SETTINGS.daemon.keepRunningAfterQuit; + migrations.daemonStopOnQuitDefaultApplied = true; + } + return { version: 1, settings, From 07aa48cd6e957957d47f2151fb5960acae2e5656 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Jul 2026 22:48:47 +0200 Subject: [PATCH 086/420] docs(claude): explain reauthentication (#2455) --- public-docs/claude-code.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public-docs/claude-code.md b/public-docs/claude-code.md index e98081b29db..826d516b769 100644 --- a/public-docs/claude-code.md +++ b/public-docs/claude-code.md @@ -20,6 +20,8 @@ You still need a Claude plan that includes Claude Code, and your plan's usual us Install and sign in to the Claude Code CLI on the machine running Paseo. Paseo uses that existing installation and account when you start a Claude Code agent. +If your Claude login expires, re-authenticate with the Claude Code CLI, then start a new Claude Code session in Paseo. Existing Paseo sessions keep the authentication they started with, so re-authenticating does not update a session that is already running. + ## Use Claude Code in the Paseo terminal Claude Code also works great inside the Paseo terminal. If you prefer the standard CLI experience, open a terminal in your workspace and run `claude` as usual. From bb6231d55649ab336857a8cc717c1bb4582b61e9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Jul 2026 23:14:37 +0200 Subject: [PATCH 087/420] fix(app): focus file pane when editing beside an agent (#2457) CodeMirror uses a contenteditable surface, which the split-pane focus filter treated as exempt. Let editor interactions claim pane focus just like composer text inputs do. --- packages/app/e2e/file-editing.spec.ts | 28 +++++++++++++++++++ .../components/split-container-pane-focus.ts | 1 - 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/file-editing.spec.ts b/packages/app/e2e/file-editing.spec.ts index fe8a3d0f25b..2a536c30e1d 100644 --- a/packages/app/e2e/file-editing.spec.ts +++ b/packages/app/e2e/file-editing.spec.ts @@ -95,6 +95,34 @@ test.describe("CodeMirror workspace file editing", () => { } }); + test("clicking the editor focuses its pane beside an agent", async ({ page }) => { + const target = "target.ts:42"; + const session = await seedAgentWithFileLink(target); + + try { + await page.setViewportSize({ width: 1280, height: 900 }); + await openAgentRoute(page, session); + + await page.getByRole("button", { name: "Split pane right" }).first().click(); + await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2); + await openWorkspaceFile(page, "target.ts"); + + await page + .getByTestId(`workspace-tab-agent_${session.agentId}`) + .filter({ visible: true }) + .click(); + await editor(page).click(); + await page.keyboard.press("Alt+Shift+W"); + + await expect(page.getByTestId("workspace-tab-file_target.ts")).not.toBeVisible(); + await expect( + page.getByTestId(`workspace-tab-agent_${session.agentId}`).filter({ visible: true }), + ).toBeVisible(); + } finally { + await session.cleanup(); + } + }); + test("shows the full file path and keeps editor controls stable", async ({ page, withWorkspace, diff --git a/packages/app/src/components/split-container-pane-focus.ts b/packages/app/src/components/split-container-pane-focus.ts index a63823f1f37..0f1ed1d8c52 100644 --- a/packages/app/src/components/split-container-pane-focus.ts +++ b/packages/app/src/components/split-container-pane-focus.ts @@ -4,7 +4,6 @@ const INTERACTIVE_TARGET_SELECTOR = [ "select", "[role='button']", "[role='link']", - "[contenteditable='true']", "[data-paseo-pane-focus-exempt='true']", ].join(", "); From 1a1ff8828f002fce08e239bae3d46aff75e22f52 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 00:20:39 +0200 Subject: [PATCH 088/420] feat(editor): wrap long Markdown lines (#2459) --- packages/app/e2e/file-editing.spec.ts | 38 +++++++++++++++++++ .../app/src/file-pane/editor/view.web.tsx | 12 +++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/file-editing.spec.ts b/packages/app/e2e/file-editing.spec.ts index 2a536c30e1d..8045b15e96d 100644 --- a/packages/app/e2e/file-editing.spec.ts +++ b/packages/app/e2e/file-editing.spec.ts @@ -18,6 +18,14 @@ function editor(page: Page) { return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"); } +function hasHorizontalOverflow(element: HTMLElement): boolean { + return element.scrollWidth > element.clientWidth; +} + +function fitsViewportWidth(element: HTMLElement): boolean { + return element.scrollWidth === element.clientWidth; +} + async function replaceEditorText(page: Page, content: string): Promise { const contentElement = editor(page); await contentElement.click(); @@ -202,6 +210,36 @@ test.describe("CodeMirror workspace file editing", () => { ).toHaveCSS("font-family", "monospace"); }); + test("wraps Markdown while source code remains horizontally scrollable", async ({ + page, + withWorkspace, + }) => { + const workspace = await withWorkspace({ prefix: "file-editing-wrap-" }); + const longLine = "word ".repeat(300); + await writeFile(path.join(workspace.repoPath, "notes.md"), `${longLine}\n`, "utf8"); + await writeFile( + path.join(workspace.repoPath, "source.ts"), + `const value = "${longLine}";\n`, + "utf8", + ); + await workspace.navigateTo(); + await openWorkspaceFile(page, "notes.md"); + await page.getByTestId("file-mode-source").click(); + + const markdownScroller = page + .getByTestId("file-source-editor") + .filter({ visible: true }) + .locator(".cm-scroller"); + await expect.poll(() => markdownScroller.evaluate(fitsViewportWidth)).toBe(true); + + await openWorkspaceFile(page, "source.ts"); + const sourceScroller = page + .getByTestId("file-source-editor") + .filter({ visible: true }) + .locator(".cm-scroller"); + await expect.poll(() => sourceScroller.evaluate(hasHorizontalOverflow)).toBe(true); + }); + test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({ page, withWorkspace, diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index abf79f4ed76..03ab27a7785 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -3,6 +3,7 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; import { getCM, vim } from "@replit/codemirror-vim"; +import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; import type { FileEditorModel } from "./model"; import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web"; @@ -19,9 +20,14 @@ interface FileEditorViewProps { } const languageCompartment = new Compartment(); +const wrappingCompartment = new Compartment(); const themeCompartment = new Compartment(); const vimCompartment = new Compartment(); +function wrappingForFile(filename: string) { + return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : []; +} + export function FileEditorView({ model, filename, @@ -50,6 +56,7 @@ export function FileEditorView({ vimCompartment.of(values.vimEnabled ? vim() : []), ...editorBaseExtensions(() => void values.model.save()), languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []), + wrappingCompartment.of(wrappingForFile(values.filename)), themeCompartment.of(editorTheme(values.theme)), EditorView.updateListener.of((update) => { if ( @@ -104,7 +111,10 @@ export function FileEditorView({ useEffect(() => { viewRef.current?.dispatch({ - effects: languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []), + effects: [ + languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []), + wrappingCompartment.reconfigure(wrappingForFile(filename)), + ], }); }, [filename]); From 1d1132de9c513c2febc447755ae9720792b44ab1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 12:12:02 +0200 Subject: [PATCH 089/420] perf(relay): reduce encrypted binary traffic overhead (#2480) Preserve application frame identity through encryption so negotiated binary traffic avoids base64 expansion while mixed-version peers remain compatible. --- SECURITY.md | 4 +- docs/architecture.md | 1 + docs/terminal-performance.md | 2 +- .../daemon/desktop-daemon-transport.ts | 10 +- .../src/daemon-client-relay-e2ee-transport.ts | 13 +- .../src/daemon-client-transport-types.ts | 2 +- .../src/daemon-client-transport-utils.ts | 18 +- .../src/daemon-client-transport.test.ts | 17 +- .../client/src/daemon-client-transport.ts | 2 +- .../src/daemon-client-websocket-transport.ts | 17 +- packages/client/src/index.test.ts | 2 +- packages/relay/src/crypto.test.ts | 16 +- packages/relay/src/crypto.ts | 13 +- packages/relay/src/e2e.test.ts | 8 +- packages/relay/src/e2ee.ts | 2 +- packages/relay/src/encrypted-channel.test.ts | 159 ++++++++++++++++- packages/relay/src/encrypted-channel.ts | 163 ++++++++++++++---- packages/relay/src/live-relay.e2e.test.ts | 4 +- .../daemon-e2e/relay-transport.e2e.test.ts | 23 ++- packages/server/src/server/relay-transport.ts | 3 +- .../websocket/encrypted-relay-socket.test.ts | 16 +- .../websocket/encrypted-relay-socket.ts | 13 +- 22 files changed, 396 insertions(+), 112 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index b09425b6886..dacb14c41a1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,9 @@ The relay is designed to be untrusted. All traffic between your phone and daemon 1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600` 2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key. 3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes. -4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame. +4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. + +The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations. The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake. diff --git a/docs/architecture.md b/docs/architecture.md index 012e4708377..5b492eb0194 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -141,6 +141,7 @@ Enables remote access when the daemon is behind a firewall. - The relay is zero-knowledge — it routes encrypted bytes and cannot read content - Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) - Pairing via QR code transfers the daemon's public key to the client +- Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only - Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS` The production relay server lives in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). It is a distributed Elixir service. The Cloudflare relay implementation in this monorepo is retained as legacy code and is not deployed. diff --git a/docs/terminal-performance.md b/docs/terminal-performance.md index 15108e951e8..1a5f4f9bd80 100644 --- a/docs/terminal-performance.md +++ b/docs/terminal-performance.md @@ -41,5 +41,5 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev ## Known remaining contention (follow-up candidates) - A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread. -- Relay-attached clients pay pure-JS tweetnacl encryption + base64 per frame on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). +- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames. - `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections. diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts index 1d789482129..32f5ed6cf94 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts @@ -63,7 +63,7 @@ export function createDesktopLocalDaemonTransportFactory( const openHandlers = new Set<() => void>(); const closeHandlers = new Set<(event?: unknown) => void>(); const errorHandlers = new Set<(event?: unknown) => void>(); - const messageHandlers = new Set<(data: unknown) => void>(); + const messageHandlers = new Set<(data: unknown, isBinary: boolean) => void>(); const emitOpen = () => { if (didEmitOpen || disposed) { @@ -84,9 +84,9 @@ export function createDesktopLocalDaemonTransportFactory( handler(event); } }; - const emitMessage = (data: unknown) => { + const emitMessage = (data: unknown, isBinary: boolean) => { for (const handler of messageHandlers) { - handler(data); + handler(data, isBinary); } }; @@ -101,11 +101,11 @@ export function createDesktopLocalDaemonTransportFactory( } if (payload.kind === "message") { if (payload.text) { - emitMessage({ data: payload.text }); + emitMessage(payload.text, false); return; } if (payload.binaryBase64) { - emitMessage({ data: decodeBase64ToBytes(payload.binaryBase64) }); + emitMessage(decodeBase64ToBytes(payload.binaryBase64), true); } return; } diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 265a99767a0..88f359f3ca9 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -8,15 +8,12 @@ import type { DaemonTransportFactory, TransportLogger, } from "./daemon-client-transport-types.js"; -import { - extractRelayMessageData, - normalizeTransportPayload, -} from "./daemon-client-transport-utils.js"; +import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js"; type OpenHandler = () => void; type CloseHandler = (event?: unknown) => void; type ErrorHandler = (event?: unknown) => void; -type MessageHandler = (data: unknown) => void; +type MessageHandler = (data: unknown, isBinary: boolean) => void; export function createRelayE2eeTransportFactory(args: { baseFactory: DaemonTransportFactory; @@ -70,7 +67,7 @@ export function createEncryptedTransport( if (closed) { return; } - emitHandlers(messageHandlers, data); + emitHandlers(messageHandlers, data, data instanceof ArrayBuffer); }; const relayTransport: RelayTransport = { @@ -115,8 +112,8 @@ export function createEncryptedTransport( base.onOpen(() => { void startHandshake(); }); - base.onMessage((event) => { - relayTransport.onmessage?.(extractRelayMessageData(event)); + base.onMessage((data, isBinary) => { + relayTransport.onmessage?.(extractRelayMessage(data, isBinary)); }); base.onClose((event) => { const record = event as { code?: number; reason?: string } | undefined; diff --git a/packages/client/src/daemon-client-transport-types.ts b/packages/client/src/daemon-client-transport-types.ts index 6531ee2057e..ddcb57246b2 100644 --- a/packages/client/src/daemon-client-transport-types.ts +++ b/packages/client/src/daemon-client-transport-types.ts @@ -1,7 +1,7 @@ export interface DaemonTransport { send: (data: string | Uint8Array | ArrayBuffer) => void; close: (code?: number, reason?: string) => void; - onMessage: (handler: (data: unknown) => void) => () => void; + onMessage: (handler: (data: unknown, isBinary: boolean) => void) => () => void; onOpen: (handler: () => void) => () => void; onClose: (handler: (event?: unknown) => void) => () => void; onError: (handler: (event?: unknown) => void) => () => void; diff --git a/packages/client/src/daemon-client-transport-utils.ts b/packages/client/src/daemon-client-transport-utils.ts index 400ef2d7a38..3b39cf5114f 100644 --- a/packages/client/src/daemon-client-transport-utils.ts +++ b/packages/client/src/daemon-client-transport-utils.ts @@ -14,17 +14,25 @@ export function normalizeTransportPayload( return copyArrayBufferViewToBuffer(data); } -export function extractRelayMessageData(event: unknown): string | ArrayBuffer { +export interface RelayTransportMessage { + data: string | ArrayBuffer; + isBinary: boolean; +} + +export function extractRelayMessage(event: unknown, nodeIsBinary?: boolean): RelayTransportMessage { const raw = event && typeof event === "object" && "data" in event ? (event as { data: unknown }).data : event; - if (typeof raw === "string") return raw; - if (raw instanceof ArrayBuffer) return raw; + const isBinary = nodeIsBinary ?? typeof raw !== "string"; + if (!isBinary) { + return { data: decodeMessageData(raw) ?? String(raw ?? ""), isBinary: false }; + } + if (raw instanceof ArrayBuffer) return { data: raw, isBinary: true }; if (ArrayBuffer.isView(raw)) { - return copyArrayBufferViewToBuffer(raw); + return { data: copyArrayBufferViewToBuffer(raw), isBinary: true }; } - return String(raw ?? ""); + return { data: String(raw ?? ""), isBinary: true }; } export function describeTransportClose(event?: unknown): string { diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index f70d09eec3d..378275c6738 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -7,7 +7,7 @@ import { describeTransportClose, describeTransportError, encodeUtf8String, - extractRelayMessageData, + extractRelayMessage, } from "./daemon-client-transport.js"; const createClientChannelMock = vi.hoisted(() => vi.fn()); @@ -130,7 +130,7 @@ describe("daemon-client transport helpers", () => { const message = { data: "payload" }; listeners.get("message")?.(message); - expect(onMessage).toHaveBeenCalledWith(message); + expect(onMessage).toHaveBeenCalledWith("payload", false); unsubscribe(); expect(ws.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function)); @@ -165,13 +165,16 @@ describe("daemon-client transport helpers", () => { expect(describeTransportError()).toBe("Transport error"); }); - test("extractRelayMessageData returns strings and array buffers", () => { - expect(extractRelayMessageData({ data: "hello" })).toBe("hello"); + test("extractRelayMessage preserves browser and Node WebSocket frame kind", () => { + expect(extractRelayMessage({ data: "hello" })).toEqual({ data: "hello", isBinary: false }); const view = new Uint8Array([1, 2, 3]); - const extracted = extractRelayMessageData({ data: view }); - expect(extracted).toBeInstanceOf(ArrayBuffer); - expect(Array.from(new Uint8Array(extracted as ArrayBuffer))).toEqual([1, 2, 3]); + const binary = extractRelayMessage(view, true); + expect(binary.isBinary).toBe(true); + expect(Array.from(new Uint8Array(binary.data as ArrayBuffer))).toEqual([1, 2, 3]); + + const text = extractRelayMessage(view, false); + expect(text).toEqual({ data: "\u0001\u0002\u0003", isBinary: false }); }); test("decodeMessageData decodes strings, array buffers, and typed arrays", () => { diff --git a/packages/client/src/daemon-client-transport.ts b/packages/client/src/daemon-client-transport.ts index d07d2774c73..05bd08bbc68 100644 --- a/packages/client/src/daemon-client-transport.ts +++ b/packages/client/src/daemon-client-transport.ts @@ -10,7 +10,7 @@ export { describeTransportClose, describeTransportError, encodeUtf8String, - extractRelayMessageData, + extractRelayMessage, normalizeTransportPayload, safeRandomId, } from "./daemon-client-transport-utils.js"; diff --git a/packages/client/src/daemon-client-websocket-transport.ts b/packages/client/src/daemon-client-websocket-transport.ts index a4c785da4a4..27307ef3d2e 100644 --- a/packages/client/src/daemon-client-websocket-transport.ts +++ b/packages/client/src/daemon-client-websocket-transport.ts @@ -3,6 +3,7 @@ import type { WebSocketFactory, WebSocketLike, } from "./daemon-client-transport-types.js"; +import { extractRelayMessage } from "./daemon-client-transport-utils.js"; export function defaultWebSocketFactory( url: string, @@ -52,11 +53,25 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem onOpen: (handler) => bindWsHandler(ws, "open", handler), onClose: (handler) => bindWsHandler(ws, "close", handler), onError: (handler) => bindWsHandler(ws, "error", handler), - onMessage: (handler) => bindWsHandler(ws, "message", handler), + onMessage: (handler) => bindWsMessageHandler(ws, handler), }; }; } +function bindWsMessageHandler( + ws: WebSocketLike, + handler: (data: unknown, isBinary: boolean) => void, +): () => void { + const listener = (...args: unknown[]) => { + const message = extractRelayMessage( + args[0], + typeof args[1] === "boolean" ? args[1] : undefined, + ); + handler(message.data, message.isBinary); + }; + return bindWsHandler(ws, "message", listener); +} + function bindTemporaryEarlyCloseErrorHandler(ws: WebSocketLike): () => void { const noop = () => {}; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 5def7dc470e..dedf7ce2e1d 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -34,7 +34,7 @@ class FakeWebSocket { } message(data: string): void { - this.onmessage?.(data); + this.onmessage?.({ data }); } } diff --git a/packages/relay/src/crypto.test.ts b/packages/relay/src/crypto.test.ts index a35533e269b..8db9a15f71f 100644 --- a/packages/relay/src/crypto.test.ts +++ b/packages/relay/src/crypto.test.ts @@ -8,6 +8,10 @@ import { decrypt, } from "./crypto.js"; +function decryptText(sharedKey: Uint8Array, ciphertext: ArrayBuffer): string { + return new TextDecoder().decode(decrypt(sharedKey, ciphertext)); +} + describe("crypto", () => { describe("generateKeyPair", () => { it("generates a valid keypair", () => { @@ -55,7 +59,7 @@ describe("crypto", () => { // Both should derive the same key - test by encrypting with one, decrypting with other const testMessage = "Hello, encrypted world!"; const encrypted = encrypt(daemonSharedKey, testMessage); - const decrypted = decrypt(clientSharedKey, encrypted); + const decrypted = decryptText(clientSharedKey, encrypted); expect(decrypted).toBe(testMessage); }); @@ -73,7 +77,7 @@ describe("crypto", () => { expect(ciphertext).toBeInstanceOf(ArrayBuffer); expect(ciphertext.byteLength).toBeGreaterThan(plaintext.length); - const decrypted = decrypt(sharedKey, ciphertext); + const decrypted = decryptText(sharedKey, ciphertext); expect(decrypted).toBe(plaintext); }); @@ -118,8 +122,8 @@ describe("crypto", () => { expect(arr1).not.toEqual(arr2); // But both should decrypt to same plaintext - expect(decrypt(sharedKey, ciphertext1)).toBe(plaintext); - expect(decrypt(sharedKey, ciphertext2)).toBe(plaintext); + expect(decryptText(sharedKey, ciphertext1)).toBe(plaintext); + expect(decryptText(sharedKey, ciphertext2)).toBe(plaintext); }); }); @@ -156,11 +160,11 @@ describe("crypto", () => { // Daemon encrypts, client decrypts const encryptedFromDaemon = encrypt(daemonSharedKey, testFromDaemon); - expect(decrypt(clientSharedKey, encryptedFromDaemon)).toBe(testFromDaemon); + expect(decryptText(clientSharedKey, encryptedFromDaemon)).toBe(testFromDaemon); // Client encrypts, daemon decrypts const encryptedFromClient = encrypt(clientSharedKey, testFromClient); - expect(decrypt(daemonSharedKey, encryptedFromClient)).toBe(testFromClient); + expect(decryptText(daemonSharedKey, encryptedFromClient)).toBe(testFromClient); }); }); }); diff --git a/packages/relay/src/crypto.ts b/packages/relay/src/crypto.ts index 3a605b9bbfe..298f8dea461 100644 --- a/packages/relay/src/crypto.ts +++ b/packages/relay/src/crypto.ts @@ -8,8 +8,8 @@ * Bundle format (binary): * [nonce (24 bytes)] [ciphertext...] * - * Transport format: - * The encrypted-channel sends the bundle as base64 text over WebSocket. + * The encrypted channel chooses the WebSocket representation. Crypto remains + * byte-oriented so frame kind is never inferred from plaintext contents. */ import nacl from "tweetnacl"; @@ -139,7 +139,7 @@ export function encrypt(sharedKey: SharedKey, data: string | ArrayBuffer): Array return toArrayBuffer(out); } -export function decrypt(sharedKey: SharedKey, data: ArrayBuffer): string | ArrayBuffer { +export function decrypt(sharedKey: SharedKey, data: ArrayBuffer): ArrayBuffer { const bytes = new Uint8Array(data); if (bytes.byteLength < NONCE_LENGTH) { throw new Error("Ciphertext bundle too short"); @@ -152,10 +152,5 @@ export function decrypt(sharedKey: SharedKey, data: ArrayBuffer): string | Array throw new Error("Decryption failed"); } - const plaintext = toArrayBuffer(opened); - try { - return new TextDecoder("utf-8", { fatal: true }).decode(plaintext); - } catch { - return plaintext; - } + return toArrayBuffer(opened); } diff --git a/packages/relay/src/e2e.test.ts b/packages/relay/src/e2e.test.ts index 663abf2e42e..cbc19f57b65 100644 --- a/packages/relay/src/e2e.test.ts +++ b/packages/relay/src/e2e.test.ts @@ -348,7 +348,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise { clientReceivedReady.byteOffset + clientReceivedReady.byteLength, ), ); - expect(JSON.parse(decryptedReady as string)).toEqual({ type: "ready" }); + expect(JSON.parse(new TextDecoder().decode(decryptedReady))).toEqual({ type: "ready" }); // Client sends encrypted message const clientMessage = "Hello from client!"; @@ -366,7 +366,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise { daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength, ), ); - expect(decryptedClientMsg).toBe(clientMessage); + expect(new TextDecoder().decode(decryptedClientMsg)).toBe(clientMessage); // Daemon sends encrypted response const daemonMessage = "Hello from daemon!"; @@ -384,7 +384,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise { clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength, ), ); - expect(decryptedDaemonMsg).toBe(daemonMessage); + expect(new TextDecoder().decode(decryptedDaemonMsg)).toBe(daemonMessage); // Cleanup daemonWs.close(); @@ -478,7 +478,7 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise { daemonSharedKey, received.buffer.slice(received.byteOffset, received.byteOffset + received.byteLength), ); - expect(decrypted).toBe(secret); + expect(new TextDecoder().decode(decrypted)).toBe(secret); daemonControlWs.close(); daemonWs.close(); diff --git a/packages/relay/src/e2ee.ts b/packages/relay/src/e2ee.ts index f538ba60de3..c3a3b852fb8 100644 --- a/packages/relay/src/e2ee.ts +++ b/packages/relay/src/e2ee.ts @@ -1,5 +1,5 @@ export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js"; -export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js"; +export type { Transport, TransportMessage, EncryptedChannelEvents } from "./encrypted-channel.js"; export { generateKeyPair, diff --git a/packages/relay/src/encrypted-channel.test.ts b/packages/relay/src/encrypted-channel.test.ts index ea897011533..90ef5e0e305 100644 --- a/packages/relay/src/encrypted-channel.test.ts +++ b/packages/relay/src/encrypted-channel.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi } from "vitest"; import { createClientChannel, createDaemonChannel, Transport } from "./encrypted-channel.js"; -import { generateKeyPair, exportPublicKey } from "./crypto.js"; +import { + deriveSharedKey, + encrypt, + exportPublicKey, + generateKeyPair, + importPublicKey, +} from "./crypto.js"; +import { arrayBufferToBase64 } from "./base64.js"; /** * Creates a pair of connected mock transports. @@ -25,11 +32,11 @@ function createMockTransportPair(): [Transport, Transport] { // Wire them together (transportA.send as ReturnType).mockImplementation((data: string | ArrayBuffer) => { - setTimeout(() => transportB.onmessage?.(data), 0); + setTimeout(() => transportB.onmessage?.({ data, isBinary: data instanceof ArrayBuffer }), 0); }); (transportB.send as ReturnType).mockImplementation((data: string | ArrayBuffer) => { - setTimeout(() => transportA.onmessage?.(data), 0); + setTimeout(() => transportA.onmessage?.({ data, isBinary: data instanceof ArrayBuffer }), 0); }); return [transportA, transportB]; @@ -193,7 +200,7 @@ describe("EncryptedChannel", () => { // Send invalid hello setTimeout(() => { - daemonTransport.onmessage?.('{"type":"invalid"}'); + daemonTransport.onmessage?.({ data: '{"type":"invalid"}', isBinary: false }); }, 0); await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message"); @@ -227,7 +234,7 @@ describe("EncryptedChannel", () => { )?.[0]; expect(typeof firstHello).toBe("string"); - daemonTransport.onmessage?.(firstHello as string); + daemonTransport.onmessage?.({ data: firstHello as string, isBinary: false }); await waitForAsyncDelivery(); expect(daemonTransport.close).not.toHaveBeenCalled(); @@ -264,9 +271,149 @@ describe("EncryptedChannel", () => { key: exportPublicKey(attackerKeyPair.publicKey), }); - daemonTransport.onmessage?.(attackerHello); + daemonTransport.onmessage?.({ data: attackerHello, isBinary: false }); await waitForAsyncDelivery(); expect(daemonTransport.close).toHaveBeenCalledWith(1008, "E2EE re-handshake key mismatch"); }); + + it("preserves ASCII-only binary frames in negotiated mode", async () => { + const [daemonTransport, clientTransport] = createMockTransportPair(); + const daemonKeyPair = generateKeyPair(); + const daemonMessages: (string | ArrayBuffer)[] = []; + let resolveOpen: (() => void) | null = null; + const opened = new Promise((resolve) => { + resolveOpen = resolve; + }); + + const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, { + onmessage: (data) => daemonMessages.push(data), + }); + const clientChannel = await createClientChannel( + clientTransport, + exportPublicKey(daemonKeyPair.publicKey), + { onopen: () => resolveOpen?.() }, + ); + await daemonChannelPromise; + await opened; + + const binary = new TextEncoder().encode("ASCII terminal output").buffer; + (clientTransport.send as ReturnType).mockClear(); + await clientChannel.send(binary); + await waitForAsyncDelivery(); + + const ciphertext = (clientTransport.send as ReturnType).mock.calls[0]?.[0]; + expect(ciphertext).toBeInstanceOf(ArrayBuffer); + expect((ciphertext as ArrayBuffer).byteLength).toBe(binary.byteLength + 40); + expect(daemonMessages[0]).toBeInstanceOf(ArrayBuffer); + expect(new Uint8Array(daemonMessages[0] as ArrayBuffer)).toEqual(new Uint8Array(binary)); + }); + + it("keeps negotiated text as base64 text frames", async () => { + const [daemonTransport, clientTransport] = createMockTransportPair(); + const daemonKeyPair = generateKeyPair(); + const daemonMessages: (string | ArrayBuffer)[] = []; + let resolveOpen: (() => void) | null = null; + const opened = new Promise((resolve) => { + resolveOpen = resolve; + }); + const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, { + onmessage: (data) => daemonMessages.push(data), + }); + const clientChannel = await createClientChannel( + clientTransport, + exportPublicKey(daemonKeyPair.publicKey), + { onopen: () => resolveOpen?.() }, + ); + await daemonChannelPromise; + await opened; + + (clientTransport.send as ReturnType).mockClear(); + await clientChannel.send("text payload"); + await waitForAsyncDelivery(); + + expect(typeof (clientTransport.send as ReturnType).mock.calls[0]?.[0]).toBe( + "string", + ); + expect(daemonMessages).toEqual(["text payload"]); + }); + + it("new client stays base64-only when an old daemon does not accept the capability", async () => { + const daemonKeyPair = generateKeyPair(); + const clientTransport: Transport = { + send: vi.fn(), + close: vi.fn(), + onmessage: null, + onclose: null, + onerror: null, + }; + let resolveOpen: (() => void) | null = null; + const opened = new Promise((resolve) => { + resolveOpen = resolve; + }); + const clientChannel = await createClientChannel( + clientTransport, + exportPublicKey(daemonKeyPair.publicKey), + { onopen: () => resolveOpen?.() }, + ); + + const hello = JSON.parse( + (clientTransport.send as ReturnType).mock.calls[0]?.[0] as string, + ) as { key: string }; + expect(hello).toMatchObject({ capabilities: { binaryCiphertext: true } }); + clientTransport.onmessage?.({ data: JSON.stringify({ type: "e2ee_ready" }), isBinary: false }); + await opened; + + (clientTransport.send as ReturnType).mockClear(); + await clientChannel.send(new TextEncoder().encode("legacy binary").buffer); + expect(typeof (clientTransport.send as ReturnType).mock.calls[0]?.[0]).toBe( + "string", + ); + }); + + it("new daemon stays base64-only for an old client and drains pipelined traffic", async () => { + const daemonKeyPair = generateKeyPair(); + const clientKeyPair = generateKeyPair(); + const daemonMessages: (string | ArrayBuffer)[] = []; + const daemonTransport: Transport = { + send: vi.fn(), + close: vi.fn(), + onmessage: null, + onclose: null, + onerror: null, + }; + const channelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, { + onmessage: (data) => daemonMessages.push(data), + }); + const sharedKey = deriveSharedKey( + clientKeyPair.secretKey, + importPublicKey(exportPublicKey(daemonKeyPair.publicKey)), + ); + daemonTransport.onmessage?.({ + data: JSON.stringify({ + type: "e2ee_hello", + key: exportPublicKey(clientKeyPair.publicKey), + }), + isBinary: false, + }); + daemonTransport.onmessage?.({ + data: arrayBufferToBase64(encrypt(sharedKey, "pipelined legacy text")), + isBinary: false, + }); + + const daemonChannel = await channelPromise; + await waitForAsyncDelivery(); + expect(daemonMessages).toEqual(["pipelined legacy text"]); + expect( + JSON.parse((daemonTransport.send as ReturnType).mock.calls[0]?.[0]), + ).toEqual({ + type: "e2ee_ready", + }); + + (daemonTransport.send as ReturnType).mockClear(); + await daemonChannel.send(new Uint8Array([1, 2, 3]).buffer); + expect(typeof (daemonTransport.send as ReturnType).mock.calls[0]?.[0]).toBe( + "string", + ); + }); }); diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index c9baa07e247..f25dc3d0509 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -21,11 +21,16 @@ import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; export interface Transport { send(data: string | ArrayBuffer): void; close(code?: number, reason?: string): void; - onmessage: ((data: string | ArrayBuffer) => void) | null; + onmessage: ((message: TransportMessage) => void) | null; onclose: ((code: number, reason: string) => void) | null; onerror: ((error: Error) => void) | null; } +export interface TransportMessage { + data: string | ArrayBuffer; + isBinary: boolean; +} + export interface EncryptedChannelEvents { onopen?: () => void; onmessage?: (data: string | ArrayBuffer) => void; @@ -45,32 +50,52 @@ interface EncryptedChannelOptions { * the daemon should re-send `{type:"e2ee_ready"}` without changing keys. */ daemonKeyPair?: KeyPair; + binaryCiphertext?: boolean; } interface E2EEHelloMessage { type: "e2ee_hello"; key: string; + capabilities?: E2EECapabilities; } interface E2EEReadyMessage { type: "e2ee_ready"; + capabilities?: E2EECapabilities; +} + +interface E2EECapabilities { + binaryCiphertext?: boolean; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isE2EECapabilities(value: unknown): value is E2EECapabilities { + return ( + value === undefined || + (isRecord(value) && + (value.binaryCiphertext === undefined || typeof value.binaryCiphertext === "boolean")) + ); +} + function isE2EEHelloMessage(value: unknown): value is E2EEHelloMessage { return ( isRecord(value) && value.type === "e2ee_hello" && typeof value.key === "string" && - value.key.trim().length > 0 + value.key.trim().length > 0 && + isE2EECapabilities(value.capabilities) ); } function isE2EEReadyMessage(value: unknown): value is E2EEReadyMessage { - return isRecord(value) && value.type === "e2ee_ready"; + return isRecord(value) && value.type === "e2ee_ready" && isE2EECapabilities(value.capabilities); +} + +function supportsBinaryCiphertext(message: E2EEHelloMessage | E2EEReadyMessage): boolean { + return message.capabilities?.binaryCiphertext === true; } function buildInvalidHelloError(rawText: string, parsed?: unknown): Error { @@ -130,7 +155,11 @@ export async function createClientChannel( // Send e2ee_hello with our public key const ourPublicKeyB64 = exportPublicKey(keyPair.publicKey); - const hello: E2EEHelloMessage = { type: "e2ee_hello", key: ourPublicKeyB64 }; + const hello: E2EEHelloMessage = { + type: "e2ee_hello", + key: ourPublicKeyB64, + capabilities: { binaryCiphertext: true }, + }; const helloText = JSON.stringify(hello); let retry: ReturnType | null = null; @@ -189,10 +218,11 @@ export async function createDaemonChannel( events: EncryptedChannelEvents = {}, ): Promise { return new Promise((resolve, reject) => { - const bufferedMessages: Array = []; - const shouldIgnorePostHelloPlaintext = (data: string | ArrayBuffer): boolean => { + const bufferedMessages: TransportMessage[] = []; + const shouldIgnorePostHelloPlaintext = (message: TransportMessage): boolean => { try { - const text = typeof data === "string" ? data : new TextDecoder().decode(data); + if (message.isBinary) return false; + const text = decodeTransportText(message.data); const parsed: unknown = JSON.parse(text); return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed); } catch { @@ -200,9 +230,12 @@ export async function createDaemonChannel( } }; - const handleHello = async (data: string | ArrayBuffer): Promise => { + const handleHello = async (message: TransportMessage): Promise => { try { - const helloText = typeof data === "string" ? data : new TextDecoder().decode(data); + if (message.isBinary) { + throw buildInvalidHelloError(""); + } + const helloText = decodeTransportText(message.data); let parsed: unknown; try { @@ -221,7 +254,7 @@ export async function createDaemonChannel( // WebCrypto work to derive the shared key. Without this, it's possible // for the next message (already encrypted) to be misinterpreted as a // second hello, causing the handshake to fail. - const bufferNext = (next: string | ArrayBuffer): void => { + const bufferNext = (next: TransportMessage): void => { bufferedMessages.push(next); }; Object.assign(transport, { onmessage: bufferNext }); @@ -229,8 +262,19 @@ export async function createDaemonChannel( const clientPublicKey = importPublicKey(msg.key); const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey); - const channel = new EncryptedChannel(transport, sharedKey, events, { daemonKeyPair }); - transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage)); + const binaryCiphertext = supportsBinaryCiphertext(msg); + const channel = new EncryptedChannel(transport, sharedKey, events, { + daemonKeyPair, + binaryCiphertext, + }); + transport.send( + JSON.stringify({ + type: "e2ee_ready", + ...(binaryCiphertext + ? { capabilities: { binaryCiphertext: true } satisfies E2EECapabilities } + : {}), + } satisfies E2EEReadyMessage), + ); channel.setState("open"); events.onopen?.(); @@ -283,7 +327,7 @@ export class EncryptedChannel { this.options = options; Object.assign(transport, { - onmessage: (data: string | ArrayBuffer) => this.handleMessage(data), + onmessage: (message: TransportMessage) => this.handleMessage(message), onclose: (code: number, reason: string) => { this.state = "closed"; this.events.onclose?.(code, reason); @@ -299,12 +343,14 @@ export class EncryptedChannel { this.state = state; } - private async handleMessage(data: string | ArrayBuffer): Promise { + private async handleMessage(message: TransportMessage): Promise { if (this.state === "handshaking") { try { - const text = typeof data === "string" ? data : new TextDecoder().decode(data); + if (message.isBinary) return; + const text = decodeTransportText(message.data); const parsed: unknown = JSON.parse(text); if (isE2EEReadyMessage(parsed)) { + this.options.binaryCiphertext = supportsBinaryCiphertext(parsed); this.state = "open"; this.events.onopen?.(); for (const cb of this.onOpenCallbacks) cb(); @@ -322,13 +368,14 @@ export class EncryptedChannel { const ciphertext = await (async () => { // Handle (or ignore) any stray plaintext handshake traffic. try { - const text = typeof data === "string" ? data : new TextDecoder().decode(data); + if (message.isBinary) throw new Error("not plaintext handshake traffic"); + const text = decodeTransportText(message.data); if (text.trim().startsWith("{")) { const parsed: unknown = JSON.parse(text); if (isE2EEHelloMessage(parsed)) { if (this.options.daemonKeyPair) { - await this.handleDaemonRehello(parsed.key); + await this.handleDaemonRehello(parsed); } return null; } @@ -350,22 +397,33 @@ export class EncryptedChannel { // decoding ciphertext below. } - if (typeof data === "string") { - return base64ToArrayBuffer(data); + if (this.options.binaryCiphertext) { + return message.isBinary + ? { data: requireArrayBuffer(message.data), isBinary: true as const } + : { + data: base64ToArrayBuffer(decodeTransportText(message.data)), + isBinary: false as const, + }; } - // Some WebSocket implementations deliver text frames as ArrayBuffer. - // Our protocol always transmits ciphertext as base64 text. + // COMPAT(binaryCiphertext): added in v0.2.3, remove legacy base64-only + // receive mode after 2027-01-27. + if (!message.isBinary) { + return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null }; + } + + // Older transport adapters could lose the opcode. Retain the former + // base64-first behavior only in the legacy path. try { - const decoded = new TextDecoder().decode(data); - return base64ToArrayBuffer(decoded); + return { data: base64ToArrayBuffer(decodeTransportText(message.data)), isBinary: null }; } catch { - return data; + return { data: requireArrayBuffer(message.data), isBinary: null }; } })(); if (ciphertext) { - const plaintext = decrypt(this.sharedKey, ciphertext); + const plaintextBytes = decrypt(this.sharedKey, ciphertext.data); + const plaintext = decodePlaintext(plaintextBytes, ciphertext.isBinary); this.events.onmessage?.(plaintext); } } catch (error) { @@ -396,10 +454,23 @@ export class EncryptedChannel { } const ciphertext = encrypt(this.sharedKey, data); - // Send as base64 for WebSocket text compatibility + if (this.options.binaryCiphertext && data instanceof ArrayBuffer) { + this.transport.send(ciphertext); + return; + } + // COMPAT(binaryCiphertext): added in v0.2.3, remove base64 binary sends + // after 2027-01-27 once the supported peer floor includes negotiation. this.transport.send(arrayBufferToBase64(ciphertext)); } + outboundWireByteLength(data: string | ArrayBuffer): number { + const encryptedBytes = utf8ByteLength(data) + 40; + if (this.options.binaryCiphertext && data instanceof ArrayBuffer) { + return encryptedBytes; + } + return 4 * Math.ceil(encryptedBytes / 3); + } + private async flushPendingSends(): Promise { if (this.state !== "open") return; const pending = this.pendingSends; @@ -409,16 +480,23 @@ export class EncryptedChannel { } } - private async handleDaemonRehello(clientKeyB64: string): Promise { + private async handleDaemonRehello(message: E2EEHelloMessage): Promise { if (!this.options.daemonKeyPair) return; - const clientPublicKey = importPublicKey(clientKeyB64); + const clientPublicKey = importPublicKey(message.key); const nextSharedKey = deriveSharedKey(this.options.daemonKeyPair.secretKey, clientPublicKey); // If it's the same client key (handshake retry), re-send // "ready" but do not re-key. Re-keying here would desync // the channel and cause decrypt failures. if (keysEqual(nextSharedKey, this.sharedKey)) { - this.transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage)); + this.transport.send( + JSON.stringify({ + type: "e2ee_ready", + ...(this.options.binaryCiphertext + ? { capabilities: { binaryCiphertext: true } satisfies E2EECapabilities } + : {}), + } satisfies E2EEReadyMessage), + ); return; } @@ -450,6 +528,33 @@ export class EncryptedChannel { } } +function decodeTransportText(data: string | ArrayBuffer): string { + return typeof data === "string" ? data : new TextDecoder().decode(data); +} + +function requireArrayBuffer(data: string | ArrayBuffer): ArrayBuffer { + if (data instanceof ArrayBuffer) return data; + throw new Error("Binary WebSocket frame did not contain bytes"); +} + +function decodeLegacyPlaintext(data: ArrayBuffer): string | ArrayBuffer { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(data); + } catch { + return data; + } +} + +function decodePlaintext(data: ArrayBuffer, isBinary: boolean | null): string | ArrayBuffer { + if (isBinary === true) return data; + if (isBinary === false) return new TextDecoder("utf-8", { fatal: true }).decode(data); + return decodeLegacyPlaintext(data); +} + +function utf8ByteLength(data: string | ArrayBuffer): number { + return typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength; +} + function keysEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.byteLength !== b.byteLength) return false; let difference = 0; diff --git a/packages/relay/src/live-relay.e2e.test.ts b/packages/relay/src/live-relay.e2e.test.ts index 411e720913c..c88a2199f2f 100644 --- a/packages/relay/src/live-relay.e2e.test.ts +++ b/packages/relay/src/live-relay.e2e.test.ts @@ -171,7 +171,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => { daemonReceivedCiphertext.byteOffset + daemonReceivedCiphertext.byteLength, ), ); - expect(decryptedOnDaemon).toBe(plaintextFromClient); + expect(new TextDecoder().decode(decryptedOnDaemon)).toBe(plaintextFromClient); const plaintextFromDaemon = "hello-from-daemon"; const ciphertextFromDaemon = encrypt(daemonSharedKey, plaintextFromDaemon); @@ -190,7 +190,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => { clientReceivedCiphertext.byteOffset + clientReceivedCiphertext.byteLength, ), ); - expect(decryptedOnClient).toBe(plaintextFromDaemon); + expect(new TextDecoder().decode(decryptedOnClient)).toBe(plaintextFromDaemon); } finally { daemonControlWs.close(); daemonWs?.close(); diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 7b5a1ab5431..327098f2dcc 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -83,10 +83,7 @@ function decodeCiphertext(text: string): ArrayBuffer { } function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown { - const plaintext = decrypt(sharedKey, decodeCiphertext(text)); - if (typeof plaintext !== "string") { - throw new Error("Expected encrypted relay frame to contain UTF-8 JSON"); - } + const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text))); return JSON.parse(plaintext); } @@ -291,8 +288,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis onerror: null, }; - ws.on("message", (data) => { - transport.onmessage?.(typeof data === "string" ? data : data.toString()); + ws.on("message", (data, isBinary) => { + transport.onmessage?.({ + data: isBinary + ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + : data.toString(), + isBinary, + }); }); ws.on("close", (code, reason) => { transport.onclose?.(code, reason.toString()); @@ -434,8 +436,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis onerror: null, }; - ws.on("message", (data) => { - transport.onmessage?.(typeof data === "string" ? data : data.toString()); + ws.on("message", (data, isBinary) => { + transport.onmessage?.({ + data: isBinary + ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + : data.toString(), + isBinary, + }); }); ws.on("close", (code, reason) => { transport.onclose?.(code, reason.toString()); diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 9eb8cc85956..b39fef1a212 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -484,7 +484,8 @@ function createRelayTransportAdapter( }; socket.on("message", (data, isBinary) => { - relayTransport.onmessage?.(normalizeMessageData(data, isBinary === true)); + const binary = isBinary === true; + relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary }); }); socket.on("close", (code, reason) => { const closeCode = typeof code === "number" ? code : 1006; diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts index 3add059fc6e..53b230c569e 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts @@ -26,12 +26,18 @@ class BlockingChannel implements EncryptedRelayChannel { this.closes.push({ code, reason }); } + outboundWireByteLength(data: string | ArrayBuffer): number { + const plaintextBytes = + typeof data === "string" ? new TextEncoder().encode(data).byteLength : data.byteLength; + return plaintextBytes + 40; + } + drain(): void { this.resolveSend?.(); } } -test("the encrypted send queue terminates its physical transport at the hard bound", async () => { +test("negotiated binary ciphertext accepts the exact hard bound and rejects one byte over", async () => { const channel = new BlockingChannel(); let terminations = 0; const socket = createEncryptedRelaySocket({ @@ -43,11 +49,11 @@ test("the encrypted send queue terminates its physical transport at the hard bou }, }); - socket.send(new Uint8Array(5 * 1024 * 1024)); + socket.send(new Uint8Array(MAX_PHYSICAL_SOCKET_BUFFERED_BYTES - 40)); expect(channel.sent).toHaveLength(1); - expect(socket.bufferedAmount).toBeGreaterThan(5 * 1024 * 1024); + expect(socket.bufferedAmount).toBe(MAX_PHYSICAL_SOCKET_BUFFERED_BYTES); - socket.send(new Uint8Array(2 * 1024 * 1024)); + socket.send(new Uint8Array(1)); expect(channel.sent).toHaveLength(1); expect(terminations).toBe(1); @@ -93,7 +99,7 @@ test("pending encryption and underlying relay backpressure share one hard bound" socket.send(new Uint8Array(3 * 1024 * 1024)); expect(channel.sent).toHaveLength(1); - transportBufferedAmount = 4 * 1024 * 1024; + transportBufferedAmount = 6 * 1024 * 1024; socket.send(new Uint8Array(1)); expect(channel.sent).toHaveLength(1); diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.ts b/packages/server/src/server/websocket/encrypted-relay-socket.ts index 4b00f8200ab..c225ed3ece4 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.ts @@ -1,12 +1,10 @@ import { EventEmitter } from "node:events"; -import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, outboundFrameByteLength } from "./physical-socket.js"; - -// NaCl adds a 24-byte nonce and 16-byte authenticator before base64 encoding. -const ENCRYPTED_FRAME_OVERHEAD_BYTES = 40; +import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES } from "./physical-socket.js"; export interface EncryptedRelayChannel { setState: (state: "open") => void; send: (data: string | ArrayBuffer) => Promise; + outboundWireByteLength: (data: string | ArrayBuffer) => number; close: (code?: number, reason?: string) => void; } @@ -58,7 +56,7 @@ export function createEncryptedRelaySocket(params: { send: (data) => { if (readyState !== 1) return; const outbound = normalizeRelaySendPayload(data); - const outboundBytes = encryptedRelayFrameByteLength(outbound); + const outboundBytes = channel.outboundWireByteLength(outbound); const queuedBytes = pendingEncryptedBytes + (getTransportBufferedAmount() ?? 0); if (queuedBytes + outboundBytes > MAX_PHYSICAL_SOCKET_BUFFERED_BYTES) { terminate(); @@ -93,8 +91,3 @@ function normalizeRelaySendPayload(data: string | Uint8Array | ArrayBuffer): str out.set(view); return out.buffer; } - -function encryptedRelayFrameByteLength(data: string | ArrayBuffer): number { - const encryptedBytes = outboundFrameByteLength(data) + ENCRYPTED_FRAME_OVERHEAD_BYTES; - return 4 * Math.ceil(encryptedBytes / 3); -} From 80c8a0839391c585275279095080ed1aa7c2c4d5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 12:12:22 +0200 Subject: [PATCH 090/420] Keep parent agents alive while child work runs (#2458) * fix(server): keep active agent trees resident Idle collection treated an inactive parent turn as process quiescence even when descendant work was still running. Protect managed and provider-owned subagent trees, carry descendant activity into the idle window, and use a conservative 30-minute fallback. * fix(server): close idle collection races Retain managed ancestry across closed intermediates, refresh descendant state after each awaited close, and invalidate running provider subagents when their runtime is terminated. * fix(server): serialize agent tree collection Keep ancestors resident until managed descendants are collected, serialize descendant registration with idle collection, and preserve provider subagent state across hot reloads. * fix(server): retain closed descendant activity Carry persisted descendant activity through runtime closure, avoid error children pinning parents, and cancel stale provider children when reload replacement aborts. * test(server): make descendant expiry deterministic * fix(server): scope idle child protection * fix(server): tighten idle tree coordination * fix(server): reduce aggressive idle cleanup Keep the mitigation conservative: extend the idle timeout without inferring tree liveness from incomplete lifecycle signals. * fix(server): keep parents alive during child work Idle cleanup now respects running managed and provider-native children while retaining a conservative 30-minute fallback. * fix(server): clear running child state on close * fix(server): drain child events before close --- docs/agent-lifecycle.md | 7 +- .../src/server/agent/agent-manager.test.ts | 174 +++++++++++++++++- .../server/src/server/agent/agent-manager.ts | 33 +++- packages/server/src/server/bootstrap.ts | 4 +- 4 files changed, 203 insertions(+), 15 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index bf8e2694893..e58f0d5c10b 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -21,11 +21,12 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider same Paseo agent ID. Provider history is not appended again when the canonical timeline is already primed. -The daemon collects an eligible idle runtime after two minutes and sweeps every 15 seconds. Only +The daemon collects an eligible idle runtime after 30 minutes and sweeps every minute. Only unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement, or permission, and have not been activated during the idle window are eligible. `running`, -`initializing`, and `error` agents stay resident. Subagents are considered independently; collection -does not cascade or change parentage. +`initializing`, and `error` agents stay resident. An idle parent also stays resident while current +in-memory state shows a running managed child or provider subagent. Otherwise agents are evaluated +independently; collection does not cascade or change parentage. Active schedules targeting an existing agent protect that agent from collection. Paused, completed, and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index fa1d77f5c1d..10dcea8f994 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -168,6 +168,16 @@ class TestAgentClient implements AgentClient { } } +class SessionRecordingAgentClient extends TestAgentClient { + readonly sessions: TestAgentSession[] = []; + + override async createSession(config: AgentSessionConfig): Promise { + const session = new TestAgentSession(config); + this.sessions.push(session); + return session; + } +} + class HeldAgentCreationClient extends TestAgentClient { private readonly creationStarted = deferred(); private readonly creationAllowed = deferred(); @@ -7590,15 +7600,7 @@ test("a shared agent load upgrades provider history hydration to broadcast", asy test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-")); - const client = new (class extends TestAgentClient { - readonly sessions: TestAgentSession[] = []; - - override async createSession(config: AgentSessionConfig): Promise { - const session = new TestAgentSession(config); - this.sessions.push(session); - return session; - } - })(); + const client = new SessionRecordingAgentClient(); const ids = [ "00000000-0000-4000-8000-000000000211", "00000000-0000-4000-8000-000000000212", @@ -7669,6 +7671,160 @@ test("collectIdleAgents leaves recent, protected, internal, running, and error a } }); +test("collectIdleAgents protects an idle parent with a running managed child", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-running-child-")); + const client = new SessionRecordingAgentClient(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + + try { + const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const child = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, + workspaceId: undefined, + }); + const independent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + client.sessions[1]!.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: "managed-child-running", + }); + await manager.flush(); + + const collection = await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + + expect(collection).toEqual({ + collected: [expect.objectContaining({ agentId: independent.id })], + failures: [], + }); + expect(manager.getAgent(parent.id)?.lifecycle).toBe("idle"); + expect(manager.getAgent(child.id)?.lifecycle).toBe("running"); + expect(manager.getAgent(independent.id)).toBeNull(); + } finally { + await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch( + () => undefined, + ); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("collectIdleAgents protects an idle parent with a running provider subagent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-running-provider-child-")); + const client = new SessionRecordingAgentClient(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + + try { + const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const independent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + client.sessions[0]!.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { + type: "upsert", + id: "provider-child-running", + title: "Provider child", + status: "running", + }, + }); + await manager.flush(); + + const collection = await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + + expect(collection).toEqual({ + collected: [expect.objectContaining({ agentId: independent.id })], + failures: [], + }); + expect(manager.getAgent(parent.id)?.lifecycle).toBe("idle"); + expect(manager.getAgent(independent.id)).toBeNull(); + } finally { + await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch( + () => undefined, + ); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("closed provider subagents do not block collection after resume", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-provider-child-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new SessionRecordingAgentClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + + try { + const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + client.sessions[0]!.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { + type: "upsert", + id: "provider-child-running", + title: "Provider child", + status: "running", + }, + }); + client.sessions[0]!.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { + type: "upsert", + id: "provider-child-finishing", + title: "Finishing provider child", + status: "running", + }, + }); + await manager.flush(); + + client.sessions[0]!.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { + type: "upsert", + id: "provider-child-finishing", + status: "completed", + }, + }); + await manager.closeAgent(parent.id); + await ensureAgentLoaded(parent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + + expect(manager.getProviderSubagent(parent.id, "provider-child-running")?.status).toBe( + "canceled", + ); + expect(manager.getProviderSubagent(parent.id, "provider-child-finishing")?.status).toBe( + "completed", + ); + const collection = await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + expect(collection.collected).toEqual([expect.objectContaining({ agentId: parent.id })]); + } finally { + await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch( + () => undefined, + ); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("load waits for an in-flight collection close and creates only one resumed runtime", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-close-race-")); const storage = new AgentStorage(join(workdir, "agents"), logger); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 8ad7b77a5b2..dc87d31969f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1388,6 +1388,8 @@ export class AgentManager { }, "agent.manager.close.start", ); + await this.drainSessionEvents(agentId); + this.cancelRunningProviderSubagents(agentId); const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); let closeError: unknown; try { @@ -1420,6 +1422,20 @@ export class AgentManager { } } + private cancelRunningProviderSubagents(parentAgentId: string): void { + for (const subagent of this.providerSubagents.list(parentAgentId)) { + if (subagent.status !== "running") { + continue; + } + const event = this.providerSubagents.apply(parentAgentId, subagent.provider, { + type: "upsert", + id: subagent.id, + status: "canceled", + }); + this.dispatch({ type: "provider_subagent", event }); + } + } + async collectIdleAgents(options: { cutoff: Date; protectedAgentIds: ReadonlySet; @@ -1461,10 +1477,25 @@ export class AgentManager { !this.runs.hasRun(agent.id) && !agent.pendingReplacement && agent.pendingPermissions.size === 0 && - agent.inFlightPermissionResponses.size === 0 + agent.inFlightPermissionResponses.size === 0 && + !this.hasRunningChild(agent.id) ); } + private hasRunningChild(parentAgentId: string): boolean { + for (const agent of this.agents.values()) { + if ( + agent.lifecycle === "running" && + getParentAgentIdFromLabels(agent.labels) === parentAgentId + ) { + return true; + } + } + return this.providerSubagents + .list(parentAgentId) + .some((subagent) => subagent.status === "running"); + } + async archiveAgent(agentId: string): Promise<{ archivedAt: string }> { const agent = this.requireAgent(agentId); if (!this.registry) { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3cd68ece951..31fcd54005e 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -215,8 +215,8 @@ import { DaemonExecutions } from "./hub/daemon-executions.js"; const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const REDACTED_LOG_VALUE = "[redacted]"; -const IDLE_AGENT_RUNTIME_TTL_MS = 2 * 60 * 1000; -const IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS = 15 * 1000; +const IDLE_AGENT_RUNTIME_TTL_MS = 30 * 60 * 1000; +const IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000; const DOWNLOAD_OPEN_FLAGS = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW; From b97d6d13f3ff78dc93b7f8458cd1c74e00f294c6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 12:21:07 +0200 Subject: [PATCH 091/420] Load complete chat history when reaching the top (#2481) * fix(chat): load older timeline history consistently Pagination depended on scroll events, so short or compacted initial pages could never request older history. Reevaluate edge visibility as layout and history change, and standardize loading indicators on the canonical app spinner. * fix(chat): keep older history loading reliably Use authoritative timeline cursor progress so merged rows cannot stall pagination. Wait for bottom anchoring before evaluating the history edge, and re-arm retries on a fresh upward gesture without automatic failure loops. --- .../app/e2e/agent-timeline-pagination.spec.ts | 20 ++++ .../app/e2e/helpers/agent-timeline-gate.ts | 55 +++++++++ .../app/e2e/helpers/timeline-pagination.ts | 37 ++++++ .../history-start-pagination.test.ts | 73 ++++++++++++ .../agent-stream/history-start-pagination.ts | 45 ++++++++ .../app/src/agent-stream/strategy-native.tsx | 83 +++++++++++--- .../src/agent-stream/strategy-web.test.tsx | 108 +++++++++++++++++- .../app/src/agent-stream/strategy-web.tsx | 90 ++++++++++++--- packages/app/src/agent-stream/strategy.ts | 1 + packages/app/src/agent-stream/view.tsx | 12 +- .../app/src/components/dictation-controls.tsx | 7 +- .../app/src/components/download-toast.tsx | 5 +- .../app/src/components/file-explorer-pane.tsx | 15 ++- packages/app/src/components/message.tsx | 11 +- .../components/provider-diagnostic-sheet.tsx | 12 +- .../app/src/components/question-form-card.tsx | 14 +-- .../src/components/realtime-voice-overlay.tsx | 5 +- .../src/components/sidebar-workspace-list.tsx | 8 +- .../sidebar/sidebar-workspace-row-content.tsx | 14 +-- packages/app/src/components/terminal-pane.tsx | 11 +- packages/app/src/components/ui/button.tsx | 5 +- .../app/src/components/ui/context-menu.tsx | 4 +- .../app/src/components/ui/dropdown-menu.tsx | 4 +- .../app/src/components/ui/loading-spinner.tsx | 5 +- packages/app/src/composer/index.tsx | 6 +- packages/app/src/composer/input/input.tsx | 6 +- .../components/desktop-updates-section.tsx | 5 +- .../components/pair-device-section.tsx | 15 ++- packages/app/src/file-pane/pane.tsx | 21 ++-- packages/app/src/git/actions-split-button.tsx | 11 +- packages/app/src/git/diff-pane.tsx | 8 +- .../src/hooks/use-load-older-agent-history.ts | 5 + packages/app/src/panels/agent-panel.tsx | 15 +-- .../src/panels/provider-subagent-panel.tsx | 6 +- packages/app/src/panels/setup-panel.tsx | 16 +-- .../workspace/workspace-desktop-tabs-row.tsx | 6 +- .../workspace-open-in-editor-button.tsx | 13 +-- .../screens/workspace/workspace-screen.tsx | 7 +- 38 files changed, 608 insertions(+), 176 deletions(-) create mode 100644 packages/app/src/agent-stream/history-start-pagination.test.ts create mode 100644 packages/app/src/agent-stream/history-start-pagination.ts diff --git a/packages/app/e2e/agent-timeline-pagination.spec.ts b/packages/app/e2e/agent-timeline-pagination.spec.ts index ebe7815aac1..30c0bc3793c 100644 --- a/packages/app/e2e/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/agent-timeline-pagination.spec.ts @@ -1,7 +1,10 @@ import { test } from "./fixtures"; import { + expectLoadedTimelineDoesNotScroll, expectTimelinePromptNotMounted, expectTimelinePromptVisible, + holdNextOlderTimelinePage, + makeLoadedTimelineFitViewport, openAgentTimeline, scrollTimelineUntilOlderHistoryIsReachable, seedLongMockAgentTimeline, @@ -25,4 +28,21 @@ test.describe("Agent timeline pagination", () => { await agent.cleanup(); } }); + + test("loads older history when the initial page does not fill the viewport", async ({ page }) => { + test.setTimeout(120_000); + const agent = await seedLongMockAgentTimeline({ turns: 30 }); + try { + await makeLoadedTimelineFitViewport(page); + const olderPage = await holdNextOlderTimelinePage(page, agent); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + await expectLoadedTimelineDoesNotScroll(page); + await olderPage.expectLoading(); + olderPage.release(); + await expectTimelinePromptVisible(page, agent.oldestPrompt); + } finally { + await agent.cleanup(); + } + }); }); diff --git a/packages/app/e2e/helpers/agent-timeline-gate.ts b/packages/app/e2e/helpers/agent-timeline-gate.ts index 5d8cc015034..511d303db22 100644 --- a/packages/app/e2e/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/helpers/agent-timeline-gate.ts @@ -10,6 +10,11 @@ interface CreatedAgentTimelineGate { waitForForwardedResponse(): Promise; } +export interface AgentTimelineResponseGate { + release(): void; + waitForDelayedResponse(): Promise; +} + function parseWebSocketJson(message: WebSocketMessage): unknown { const rawMessage = typeof message === "string" ? message : message.toString("utf8"); try { @@ -118,3 +123,53 @@ export async function delayCreatedAgentInitialTailResponse( waitForForwardedResponse: () => forwardedResponse, }; } + +export async function delayAgentOlderTimelineResponse( + page: Page, + agentId: string, +): Promise { + let releaseRequested = false; + let delayedResponseSeen = false; + const delayedForwards: Array<() => void> = []; + let resolveDelayedResponse: (() => void) | null = null; + const delayedResponse = new Promise((resolve) => { + resolveDelayedResponse = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + server.send(message); + }); + server.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + const payload = sessionMessage ? getPayload(sessionMessage) : null; + if ( + !delayedResponseSeen && + sessionMessage?.type === "fetch_agent_timeline_response" && + payload?.agentId === agentId && + payload.direction === "before" + ) { + delayedResponseSeen = true; + resolveDelayedResponse?.(); + if (releaseRequested) { + ws.send(message); + return; + } + delayedForwards.push(() => ws.send(message)); + return; + } + ws.send(message); + }); + }); + + return { + release() { + releaseRequested = true; + for (const forward of delayedForwards.splice(0)) { + forward(); + } + }, + waitForDelayedResponse: () => delayedResponse, + }; +} diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/helpers/timeline-pagination.ts index f675eff3c00..bf30233091b 100644 --- a/packages/app/e2e/helpers/timeline-pagination.ts +++ b/packages/app/e2e/helpers/timeline-pagination.ts @@ -1,5 +1,9 @@ import { expect, type Page } from "@playwright/test"; import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; +import { + delayAgentOlderTimelineResponse, + type AgentTimelineResponseGate, +} from "./agent-timeline-gate"; interface LongTimelineAgentOptions { turns: number; @@ -53,6 +57,39 @@ export async function expectTimelinePromptNotMounted(page: Page, prompt: string) await expect(page.getByText(prompt, { exact: true })).toHaveCount(0); } +export async function makeLoadedTimelineFitViewport(page: Page): Promise { + await page.setViewportSize({ width: 1280, height: 8_000 }); +} + +export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise { + const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + await expect + .poll(async () => + scroll.evaluate((element) => { + if (!(element instanceof HTMLElement)) { + throw new Error("Agent chat scroll element is not an HTMLElement"); + } + return element.scrollHeight <= element.clientHeight; + }), + ) + .toBe(true); +} + +export async function holdNextOlderTimelinePage( + page: Page, + agent: LongTimelineAgent, +): Promise }> { + const gate = await delayAgentOlderTimelineResponse(page, agent.agentId); + return { + ...gate, + async expectLoading() { + await gate.waitForDelayedResponse(); + await expect(page.getByTestId("load-older-history-spinner")).toBeVisible(); + await expectTimelinePromptNotMounted(page, agent.oldestPrompt); + }, + }; +} + export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise { const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); await scroll.hover(); diff --git a/packages/app/src/agent-stream/history-start-pagination.test.ts b/packages/app/src/agent-stream/history-start-pagination.test.ts new file mode 100644 index 00000000000..2801f479f48 --- /dev/null +++ b/packages/app/src/agent-stream/history-start-pagination.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + createHistoryStartPaginationState, + evaluateHistoryStartPagination, + rearmHistoryStartPagination, +} from "./history-start-pagination"; + +const visibleHistoryStart = { + distanceFromHistoryStart: 0, + hasOlderHistory: true, + isLoadingOlderHistory: false, + isReady: true, + progressKey: "epoch-1:20", +}; + +describe("history start pagination", () => { + it("loads once for each authoritative history cursor", () => { + const initial = createHistoryStartPaginationState(); + const first = evaluateHistoryStartPagination(initial, visibleHistoryStart); + const duplicate = evaluateHistoryStartPagination(first.state, visibleHistoryStart); + const nextPage = evaluateHistoryStartPagination(first.state, { + ...visibleHistoryStart, + progressKey: "epoch-1:10", + }); + + expect([first.shouldLoad, duplicate.shouldLoad, nextPage.shouldLoad]).toEqual([ + true, + false, + true, + ]); + }); + + it("allows the same revision again after the user leaves the history edge", () => { + const first = evaluateHistoryStartPagination( + createHistoryStartPaginationState(), + visibleHistoryStart, + ); + const away = evaluateHistoryStartPagination(first.state, { + ...visibleHistoryStart, + distanceFromHistoryStart: 200, + }); + const returned = evaluateHistoryStartPagination(away.state, visibleHistoryStart); + + expect([first.shouldLoad, away.shouldLoad, returned.shouldLoad]).toEqual([true, false, true]); + }); + + it("re-arms the same cursor when the user makes another upward edge gesture", () => { + const first = evaluateHistoryStartPagination( + createHistoryStartPaginationState(), + visibleHistoryStart, + ); + const retried = evaluateHistoryStartPagination( + rearmHistoryStartPagination(first.state), + visibleHistoryStart, + ); + + expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]); + }); + + it("waits while history loading is unavailable or already active", () => { + const state = createHistoryStartPaginationState(); + + expect([ + evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad, + evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false }) + .shouldLoad, + evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true }) + .shouldLoad, + evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null }) + .shouldLoad, + ]).toEqual([false, false, false, false]); + }); +}); diff --git a/packages/app/src/agent-stream/history-start-pagination.ts b/packages/app/src/agent-stream/history-start-pagination.ts new file mode 100644 index 00000000000..180ea5cf0f4 --- /dev/null +++ b/packages/app/src/agent-stream/history-start-pagination.ts @@ -0,0 +1,45 @@ +export const HISTORY_START_THRESHOLD_PX = 96; + +export interface HistoryStartPaginationState { + requestedProgressKey: string | null; +} + +export function createHistoryStartPaginationState(): HistoryStartPaginationState { + return { requestedProgressKey: null }; +} + +export function rearmHistoryStartPagination( + _state: HistoryStartPaginationState, +): HistoryStartPaginationState { + return createHistoryStartPaginationState(); +} + +export function evaluateHistoryStartPagination( + state: HistoryStartPaginationState, + input: { + distanceFromHistoryStart: number; + hasOlderHistory: boolean; + isLoadingOlderHistory: boolean; + isReady: boolean; + progressKey: string | null; + }, +): { state: HistoryStartPaginationState; shouldLoad: boolean } { + if (input.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) { + return { state: createHistoryStartPaginationState(), shouldLoad: false }; + } + if ( + !input.isReady || + !input.hasOlderHistory || + input.isLoadingOlderHistory || + input.progressKey === null + ) { + return { state, shouldLoad: false }; + } + if (state.requestedProgressKey === input.progressKey) { + return { state, shouldLoad: false }; + } + return { + state: { requestedProgressKey: input.progressKey }, + shouldLoad: true, + }; +} diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 629f63fa086..6b67096e7bc 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -9,7 +9,6 @@ import { } from "react"; import { FlatList, - ActivityIndicator, Keyboard, Platform, View, @@ -17,8 +16,12 @@ import { type ListRenderItemInfo, type NativeScrollEvent, type NativeSyntheticEvent, + type ViewStyle, } from "react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { StreamItem } from "@/types/stream"; +import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; import { useBottomAnchorController } from "./bottom-anchor-controller"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; @@ -27,12 +30,28 @@ import { isNearBottomForStreamRenderStrategy, resolveBottomAnchorTransportBehavior, } from "./strategy"; +import { + createHistoryStartPaginationState, + evaluateHistoryStartPagination, + rearmHistoryStartPagination, +} from "./history-start-pagination"; const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ minIndexForVisible: 0, autoscrollToTopThreshold: 0, }); -const HISTORY_START_THRESHOLD_PX = 96; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); +const historyStartSlotStyle: ViewStyle = { + alignItems: "center", + justifyContent: "center", + minHeight: 32, + paddingTop: 4, + paddingBottom: 8, +}; interface HistoryRowDisplayVariants { regular?: StreamItem; @@ -72,6 +91,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat onNearHistoryStart, isLoadingOlderHistory, hasOlderHistory, + olderHistoryProgressKey, scrollEnabled, listStyle, baseListContentContainerStyle, @@ -95,6 +115,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); + const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState()); const historyItems = useMemo(() => { if (segments.historyVirtualized.length === 0) { @@ -123,6 +144,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ), [displayStateHistoryRows, historyRowRevision?.contentById], ); + const evaluateHistoryStart = useStableEvent(() => { + const metrics = streamViewportMetricsRef.current; + const hasMeasuredViewport = + metrics.viewportMeasuredForKey === metrics.containerKey && + metrics.contentMeasuredForKey === metrics.containerKey; + const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, { + distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY, + hasOlderHistory, + isLoadingOlderHistory, + isReady: historyStartReadyRef.current && hasMeasuredViewport, + progressKey: olderHistoryProgressKey, + }); + historyStartPaginationStateRef.current = result.state; + if (result.shouldLoad) { + onNearHistoryStart(); + } + }); const clearNativeViewportSettling = useCallback(() => { if (nativeViewportSettlingFrameIdRef.current !== null) { @@ -222,14 +260,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat clearNativeViewportSettling(); setIsNativeViewportSettling(false); historyStartReadyRef.current = false; + historyStartPaginationStateRef.current = createHistoryStartPaginationState(); const frame = requestAnimationFrame(() => { historyStartReadyRef.current = true; + evaluateHistoryStart(); }); return () => { cancelAnimationFrame(frame); clearPendingUserScrollEnd(); }; - }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]); + }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]); useEffect(() => { const keyboardEvents = [ @@ -308,17 +348,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const nearBottom = isScrollEventNearBottom(event); onNearBottomChange(nearBottom); - const distanceFromOldestEdge = - streamViewportMetricsRef.current.contentHeight - - streamViewportMetricsRef.current.viewportHeight - - contentOffset.y; - if ( - historyStartReadyRef.current && - hasOlderHistory && - distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX - ) { - onNearHistoryStart(); - } + evaluateHistoryStart(); if ( !isUserScrollActiveRef.current && @@ -336,9 +366,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const handleScrollBeginDrag = useStableEvent(() => { + if (!isLoadingOlderHistory) { + historyStartPaginationStateRef.current = rearmHistoryStartPagination( + historyStartPaginationStateRef.current, + ); + } clearPendingUserScrollEnd(); isUserScrollActiveRef.current = true; bottomAnchorController.beginUserScroll(); + evaluateHistoryStart(); }); // Defer drag end so momentum can take ownership, but capture the terminal @@ -395,6 +431,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat previousViewportHeight, viewportHeight, }); + evaluateHistoryStart(); }); const handleContentSizeChange = useStableEvent((_width: number, height: number) => { @@ -410,8 +447,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat previousContentHeight, contentHeight: nextContentHeight, }); + evaluateHistoryStart(); }); + useEffect(() => { + evaluateHistoryStart(); + }, [evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey]); + const renderItem = useStableEvent( ({ item, index }: ListRenderItemInfo): ReactElement | null => { const rendered = renderHistoryMountedRow(item, index, historyItems); @@ -451,15 +493,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ]); const historyFooterContent = useMemo(() => { - if (!isLoadingOlderHistory) { + if (!hasOlderHistory && !isLoadingOlderHistory) { return null; } return ( - - + + {isLoadingOlderHistory ? ( + + ) : null} ); - }, [isLoadingOlderHistory]); + }, [hasOlderHistory, isLoadingOlderHistory]); // RN's FlatList strictMode keeps its internal renderItem wrapper stable when // data or the live header changes, preserving the row identities above. diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index f407fefbbf3..c125293cf51 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -133,6 +133,7 @@ describe("createWebStreamStrategy", () => { onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, + olderHistoryProgressKey: null, scrollEnabled: true, listStyle: null, baseListContentContainerStyle: null, @@ -176,6 +177,7 @@ describe("createWebStreamStrategy", () => { onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, + olderHistoryProgressKey: null, scrollEnabled: true, listStyle: null, baseListContentContainerStyle: null, @@ -231,6 +233,7 @@ describe("createWebStreamStrategy", () => { onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, + olderHistoryProgressKey: null, scrollEnabled: true, listStyle: null, baseListContentContainerStyle: null, @@ -314,6 +317,7 @@ describe("createWebStreamStrategy", () => { onNearHistoryStart, isLoadingOlderHistory: false, hasOlderHistory: true, + olderHistoryProgressKey: "epoch-1:20", scrollEnabled: true, listStyle: null, baseListContentContainerStyle: null, @@ -323,6 +327,80 @@ describe("createWebStreamStrategy", () => { ); }); + const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]'); + if (!(scrollContainer instanceof HTMLElement)) { + throw new Error("Expected agent chat scroll container"); + } + Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 }); + Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 }); + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 }); + + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + + expect(onNearHistoryStart).not.toHaveBeenCalled(); + + act(() => { + scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); + scrollContainer?.dispatchEvent(new Event("scroll")); + }); + + expect(onNearHistoryStart).toHaveBeenCalledTimes(1); + + act(() => { + scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); + }); + + expect(onNearHistoryStart).toHaveBeenCalledTimes(2); + }); + + it("waits for bottom anchoring before evaluating a delayed initial tail", async () => { + HTMLElement.prototype.scrollTo = vi.fn(function ( + this: HTMLElement, + options?: ScrollToOptions | number, + y?: number, + ) { + const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0); + Object.defineProperty(this, "scrollTop", { configurable: true, value: top }); + }); + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + const viewportRef = React.createRef(); + const onNearHistoryStart = vi.fn(); + const renderInput = { + agentId: "agent", + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: false, + hasLiveHead: false, + }, + renderers: createRenderers(vi.fn()), + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onNearHistoryStart, + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + 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, + segments: { historyVirtualized: [], historyMounted: [], liveHead: [] }, + }), + ); + }); await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); @@ -333,13 +411,33 @@ describe("createWebStreamStrategy", () => { } Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 }); Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 }); + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); act(() => { - scrollContainer?.dispatchEvent(new Event("scroll")); + root?.render( + strategy.render({ + ...renderInput, + segments: { + historyVirtualized: [], + historyMounted: [userMessage(1), userMessage(2)], + liveHead: [], + }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + hasOlderHistory: true, + olderHistoryProgressKey: "epoch-1:20", + }), + ); }); - expect(onNearHistoryStart).toHaveBeenCalledTimes(1); + expect(onNearHistoryStart).not.toHaveBeenCalled(); + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + expect(onNearHistoryStart).not.toHaveBeenCalled(); }); it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => { @@ -378,6 +476,7 @@ describe("createWebStreamStrategy", () => { onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, + olderHistoryProgressKey: null, scrollEnabled: true, listStyle: null, baseListContentContainerStyle: null, @@ -485,6 +584,7 @@ describe("createWebStreamStrategy", () => { onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, + olderHistoryProgressKey: null, scrollEnabled: true, listStyle: null, baseListContentContainerStyle: null, @@ -581,6 +681,7 @@ describe("createWebStreamStrategy", () => { onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, + olderHistoryProgressKey: null, scrollEnabled: true, listStyle: null, baseListContentContainerStyle: null, @@ -679,6 +780,7 @@ describe("createWebStreamStrategy", () => { onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, + olderHistoryProgressKey: null, scrollEnabled: true, listStyle: null, baseListContentContainerStyle: null, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index abac0fac174..7df586cef29 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -8,11 +8,19 @@ import React, { useRef, useState, } from "react"; -import { ActivityIndicator } from "react-native"; import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual"; +import { withUnistyles } from "react-native-unistyles"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import type { Theme } from "@/styles/theme"; import { estimateStreamItemHeight } from "./web-virtualization"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy } from "./strategy"; +import { + createHistoryStartPaginationState, + evaluateHistoryStartPagination, + rearmHistoryStartPagination, +} from "./history-start-pagination"; interface CreateWebStreamStrategyInput { isMobileBreakpoint: boolean; @@ -25,7 +33,11 @@ const USER_SCROLL_DELTA_EPSILON = 1; const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; -const HISTORY_START_THRESHOLD_PX = 96; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); const historyStartSlotStyle: CSSProperties = { display: "flex", @@ -107,6 +119,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onNearHistoryStart, isLoadingOlderHistory, hasOlderHistory, + olderHistoryProgressKey, scrollEnabled, isMobileBreakpoint, } = props; @@ -133,6 +146,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const pendingAutoScrollTimeoutRef = useRef(null); const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const historyStartReadyRef = useRef(false); + const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState()); const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const { renderHistoryVirtualizedRow, @@ -173,6 +187,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [rowVirtualizer]); const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotalSize = rowVirtualizer.getTotalSize(); + const evaluateHistoryStart = useStableEvent(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) { + return; + } + const bottomAnchorSettled = + !followOutputRef.current || isScrollContainerNearBottom(scrollContainer); + const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, { + distanceFromHistoryStart: scrollContainer.scrollTop, + hasOlderHistory, + isLoadingOlderHistory, + isReady: historyStartReadyRef.current && bottomAnchorSettled, + progressKey: olderHistoryProgressKey, + }); + historyStartPaginationStateRef.current = result.state; + if (result.shouldLoad) { + onNearHistoryStart(); + } + }); const measureVirtualizedRowElement = useCallback( (node: HTMLDivElement | null) => { @@ -231,8 +264,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollElementToBottom(scrollContainer, behavior); lastKnownScrollTopRef.current = scrollContainer.scrollTop; syncNearBottom(scrollContainer, onNearBottomChange); + evaluateHistoryStart(); }, - [onNearBottomChange], + [evaluateHistoryStart, onNearBottomChange], ); const scheduleStickToBottom = useCallback(() => { @@ -297,24 +331,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool lastKnownScrollTopRef.current = currentScrollTop; updateScrollMetrics(); - if ( - historyStartReadyRef.current && - hasOlderHistory && - currentScrollTop <= HISTORY_START_THRESHOLD_PX - ) { - onNearHistoryStart(); - } - }, [cancelPendingStickToBottom, hasOlderHistory, onNearHistoryStart, updateScrollMetrics]); + evaluateHistoryStart(); + }, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]); useEffect(() => { + historyStartPaginationStateRef.current = createHistoryStartPaginationState(); const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; + evaluateHistoryStart(); }); return () => { window.cancelAnimationFrame(frame); historyStartReadyRef.current = false; }; - }, [props.agentId]); + }, [evaluateHistoryStart, props.agentId]); useLayoutEffect(() => { if (!isActivationReady) { @@ -370,7 +400,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool useEffect(() => { updateScrollMetrics(); + evaluateHistoryStart(); }, [ + evaluateHistoryStart, + hasOlderHistory, + isLoadingOlderHistory, + olderHistoryProgressKey, segments.historyMounted.length, segments.historyVirtualized.length, segments.liveHead.length, @@ -386,8 +421,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } updateScrollMetrics(); + evaluateHistoryStart(); const observer = new ResizeObserver(() => { updateScrollMetrics(); + evaluateHistoryStart(); if (!followOutputRef.current) { return; } @@ -400,7 +437,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { observer.disconnect(); }; - }, [scheduleStickToBottom, updateScrollMetrics]); + }, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]); useEffect(() => { const scrollContainer = scrollContainerRef.current; @@ -410,8 +447,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const handleWheel = (event: WheelEvent) => { if (event.deltaY < 0) { + if (!isLoadingOlderHistory) { + historyStartPaginationStateRef.current = rearmHistoryStartPagination( + historyStartPaginationStateRef.current, + ); + } pendingUserScrollUpIntentRef.current = true; cancelPendingStickToBottom(); + evaluateHistoryStart(); } }; const handlePointerDown = () => { @@ -434,8 +477,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } const previousTouchY = lastTouchClientYRef.current; if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { + if (!isLoadingOlderHistory) { + historyStartPaginationStateRef.current = rearmHistoryStartPagination( + historyStartPaginationStateRef.current, + ); + } pendingUserScrollUpIntentRef.current = true; cancelPendingStickToBottom(); + evaluateHistoryStart(); } lastTouchClientYRef.current = touch.clientY; }; @@ -464,7 +513,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollContainer.removeEventListener("touchend", handleTouchEnd); scrollContainer.removeEventListener("touchcancel", handleTouchEnd); }; - }, [cancelPendingStickToBottom, handleDomScroll]); + }, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]); useEffect(() => { const handle: StreamViewportHandle = { @@ -546,15 +595,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return renderLiveAuxiliary(); }, [renderLiveAuxiliary]); const historyStartSlot = useMemo(() => { - if (!isLoadingOlderHistory) { + if (!hasOlderHistory && !isLoadingOlderHistory) { return null; } return ( -
- +
+ {isLoadingOlderHistory ? ( + + ) : null}
); - }, [isLoadingOlderHistory]); + }, [hasOlderHistory, isLoadingOlderHistory]); const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index dd9a798edf2..9a467c578ad 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -72,6 +72,7 @@ export interface StreamRenderInput { onNearHistoryStart: () => void; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; + olderHistoryProgressKey: string | null; scrollEnabled: boolean; listStyle: StyleProp; baseListContentContainerStyle: StyleProp; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 421fed73c94..5af665d40a6 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1,3 +1,4 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { forwardRef, memo, @@ -17,7 +18,6 @@ import { Text, Pressable, Platform, - ActivityIndicator, type PressableStateCallbackType, type StyleProp, type ViewStyle, @@ -247,6 +247,7 @@ export interface AgentStreamViewProps { historyPagination?: { hasOlder: boolean; isLoadingOlder: boolean; + progressKey: string | null; onLoadOlder: () => void; }; } @@ -388,10 +389,11 @@ const AgentStreamViewComponent = forwardRef; } -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedCheckIcon = withUnistyles(Check); const ThemedXIcon = withUnistyles(X); @@ -1241,7 +1245,7 @@ function PermissionActionButton({ return ( {isRespondingAction ? ( - + ) : ( diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index 477717d4e6c..fb2c4ad8e75 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -1,5 +1,6 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useMemo } from "react"; -import { View, Text, Pressable, ActivityIndicator } from "react-native"; +import { View, Text, Pressable } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native"; import { useTranslation } from "react-i18next"; @@ -98,7 +99,7 @@ export function DictationControls({ {actionsDisabled ? ( - + ) : null} {!actionsDisabled && isFailed ? ( @@ -221,7 +222,7 @@ export function DictationOverlay({ {actionsDisabled ? ( - + ) : null} {!actionsDisabled && isFailed ? ( diff --git a/packages/app/src/components/download-toast.tsx b/packages/app/src/components/download-toast.tsx index b06417213b4..69aa42fdc82 100644 --- a/packages/app/src/components/download-toast.tsx +++ b/packages/app/src/components/download-toast.tsx @@ -1,7 +1,8 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useCallback, useEffect, useMemo, useRef } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { Pressable, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Check, X, XCircle } from "lucide-react-native"; @@ -69,7 +70,7 @@ export function DownloadToast() { {activeDownload.status === "downloading" ? ( - + ) : null} {activeDownload.status === "complete" ? ( diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index d0f1c227e88..540a02bd9aa 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObj import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { - ActivityIndicator, FlatList, ListRenderItemInfo, Pressable, @@ -12,7 +11,7 @@ import { type StyleProp, type ViewStyle, } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import * as Clipboard from "expo-clipboard"; import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native"; @@ -24,6 +23,7 @@ import { WORKSPACE_FILE_ROW_VERTICAL_PADDING, } from "@/components/tree-primitives"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import type { Theme } from "@/styles/theme"; import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { FileActionsMenu } from "@/components/file-actions-menu"; @@ -42,6 +42,11 @@ const SORT_OPTIONS: { value: SortOption }[] = [ { value: "size" }, ]; +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + function formatFileSize({ size }: { size: number }): string { if (size < 1024) { return `${size} B`; @@ -163,7 +168,9 @@ function TreeRowItem({ if (!isDirectory) { return ; } - if (loading) return ; + if (loading) { + return ; + } return ; })()} @@ -549,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { if (showInitialLoading) { return ( - + {t("workspace.fileExplorer.states.loading")} ); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 87fd88d4ac3..8abe679281c 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,9 +1,9 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, Text, Image, Pressable, - ActivityIndicator, type GestureResponderEvent, type LayoutChangeEvent, StyleProp, @@ -172,6 +172,7 @@ const ThemedTodoCheckIcon = withUnistyles(Check); const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); const ThemedChevronRightIcon = withUnistyles(ChevronRight); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -865,7 +866,9 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm return ( - {loadState.status === "loading" ? : null} + {loadState.status === "loading" ? ( + + ) : null} {loadState.status === "error" ? ( {t("message.attachments.imageUnavailable")} @@ -1004,7 +1007,7 @@ function AssistantMarkdownImage({ if (query.isLoading || dataImageQuery.isLoading) { return ( - + ); } @@ -2244,7 +2247,7 @@ export const CompactionMarker = memo(function CompactionMarker({ {status === "loading" ? ( - + ) : ( )} diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index b4831acac5a..aecfaea4c72 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -3,13 +3,7 @@ import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-re import type { TFunction } from "i18next"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - ActivityIndicator, - Pressable, - type PressableStateCallbackType, - Text, - View, -} from "react-native"; +import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, @@ -365,7 +359,7 @@ function DiagnosticSubSheet({ body = ( - + {t("settings.providers.diagnostic.running")} @@ -504,7 +498,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) { if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) { return ( - + {t("settings.providers.models.loading")} ); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 22576a7e048..a1c19df69d1 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,12 +1,6 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useState, useCallback, useMemo } from "react"; -import { - View, - Text, - TextInput, - Pressable, - ActivityIndicator, - type PressableStateCallbackType, -} from "react-native"; +import { View, Text, TextInput, Pressable, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, X } from "lucide-react-native"; @@ -581,7 +575,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi testID="question-form-dismiss" > {respondingAction === "dismiss" ? ( - + ) : ( @@ -599,7 +593,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi testID="question-form-primary-action" > {respondingAction === "submit" ? ( - + ) : ( diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx index 31ec826af7f..88b2e378b41 100644 --- a/packages/app/src/components/realtime-voice-overlay.tsx +++ b/packages/app/src/components/realtime-voice-overlay.tsx @@ -1,6 +1,7 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, MicOff, Square } from "lucide-react-native"; import { FOOTER_HEIGHT } from "@/constants/layout"; @@ -75,7 +76,7 @@ export function RealtimeVoiceOverlay({ style={stopButtonStyle} > {isSwitching ? ( - + ) : ( - + ); } @@ -855,7 +855,7 @@ function NewWorktreeButton({ > {({ hovered, pressed }) => loading ? ( - + ) : ( ({ color: theme.colors.palette.purp const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedMonitor = withUnistyles(Monitor); @@ -207,7 +201,7 @@ function WorkspaceStatusIndicator({ if (loading) { return ( - + ); } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index f5ba2864f2c..bba56145267 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,11 +1,6 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ActivityIndicator, - Pressable, - Text, - View, - type PressableStateCallbackType, -} from "react-native"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; @@ -836,7 +831,7 @@ export function TerminalPane({ {showLoadingOverlay ? ( - + ) : null} diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 3a7a275db26..e1609702232 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -1,3 +1,4 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { default as React, useCallback, @@ -8,7 +9,7 @@ import { type ReactElement, type ReactNode, } from "react"; -import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { Pressable, Text, View } from "react-native"; import type { PressableProps, PressableStateCallbackType, @@ -44,7 +45,7 @@ function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps) if (loading) { return ( - + ); } diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 1869d856870..8a5a2715664 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -1,3 +1,4 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { type ComponentProps, createContext, @@ -14,7 +15,6 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { - ActivityIndicator, Dimensions, Modal, Platform, @@ -630,7 +630,7 @@ function resolveLeadingContent(input: { successColor: string; }): ReactElement | null { if (input.isPending) { - return ; + return ; } if (input.isSuccess) { return ; diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 8daac9efc9c..8c8badfa030 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -1,3 +1,4 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { createContext, useCallback, @@ -14,7 +15,6 @@ import { import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { - ActivityIndicator, Modal, Pressable, Text, @@ -717,7 +717,7 @@ function resolveDropdownItemLeadingContent(input: { }): ReactElement | null { const { isPending, isSuccess, leading, theme } = input; if (isPending) { - return ; + return ; } if (isSuccess) { return ; diff --git a/packages/app/src/components/ui/loading-spinner.tsx b/packages/app/src/components/ui/loading-spinner.tsx index 43a72e5b561..f73fd037bb8 100644 --- a/packages/app/src/components/ui/loading-spinner.tsx +++ b/packages/app/src/components/ui/loading-spinner.tsx @@ -3,8 +3,9 @@ import { ActivityIndicator, type ActivityIndicatorProps } from "react-native"; interface LoadingSpinnerProps { color: string; size?: ActivityIndicatorProps["size"]; + style?: ActivityIndicatorProps["style"]; } -export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) { - return ; +export function LoadingSpinner({ color, size = "small", style }: LoadingSpinnerProps) { + return ; } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index f753a04d41d..35232cd023d 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,8 +1,8 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, Pressable, Text, - ActivityIndicator, StyleSheet as RNStyleSheet, type PressableStateCallbackType, } from "react-native"; @@ -884,7 +884,7 @@ function ComposerCancelButton({ ? t("composer.cancel.cancelingAgent") : t("composer.cancel.stopAgent"); const icon = isCancellingAgent ? ( - + ) : ( ); @@ -984,7 +984,7 @@ function ComposerVoiceModeButton({ const renderTriggerContent = useCallback( ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => { if (isVoiceSwitching) { - return ; + return ; } const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; return ; diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index decb9e692d3..cbe92e33042 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,10 +1,10 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, Text, TextInput, Pressable, Platform, - ActivityIndicator, useWindowDimensions, NativeSyntheticEvent, TextInputContentSizeChangeEventData, @@ -445,7 +445,7 @@ function SendButtonContent({ buttonIconSize: number; }) { if (isSubmitLoading) { - return ; + return ; } if (submitIcon === "return") { return ; @@ -2000,7 +2000,7 @@ const ThemedMic = withUnistyles(Mic); const ThemedMicOff = withUnistyles(MicOff); const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedCornerDownLeft = withUnistyles(CornerDownLeft); -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedTextInput = withUnistyles(TextInput); const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 5fa4685c900..c9047f79bd3 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,5 +1,6 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { type ReactElement, useCallback, useMemo, useState } from "react"; -import { ActivityIndicator, Alert, Text, View } from "react-native"; +import { Alert, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import { useTranslation } from "react-i18next"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -421,7 +422,7 @@ export function LocalDaemonSection() { > {isLoading || isLoadingSettings ? ( - + ) : ( <> diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index 1160dbf4f04..44cc592b5c7 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -1,15 +1,22 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, Image, Text, TextInput, View } from "react-native"; +import { Image, Text, TextInput, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import * as QRCode from "qrcode"; import { useQuery } from "@tanstack/react-query"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { RotateCw, Copy, Check } from "lucide-react-native"; import { settingsStyles } from "@/styles/settings"; import { Button } from "@/components/ui/button"; import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { useState } from "react"; +import type { Theme } from "@/styles/theme"; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); type PairingViewState = | { tag: "loading" } @@ -184,7 +191,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) { if (viewState.tag === "loading") { return ( - + {labels.loadingOffer} ); @@ -240,7 +247,7 @@ function PairDeviceQrContent(props: { if (props.qrQuery.isError) { return {props.unavailableLabel}; } - return ; + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index c34bf710873..eb902eb3303 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,3 +1,4 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { useCallback, useEffect, @@ -8,14 +9,8 @@ import React, { } from "react"; import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client"; import type { FileVersion } from "@getpaseo/protocol/messages"; -import { - ActivityIndicator, - Image as RNImage, - ScrollView as RNScrollView, - Text, - View, -} from "react-native"; -import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; +import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native"; +import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { MarkdownRenderer } from "@/components/markdown/renderer"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -44,6 +39,12 @@ import { FileEditorModel, type FileEditorFile } from "./editor/model"; import { FileEditorView } from "./editor/view"; import { confirmDialog } from "@/utils/confirm-dialog"; import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; +import type { Theme } from "@/styles/theme"; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); interface CodeLineProps { tokens: HighlightToken[]; @@ -264,7 +265,7 @@ function FilePreviewBody({ if (isLoading && !preview) { return ( - + {t("panels.file.loading")} ); @@ -346,7 +347,7 @@ function FilePreviewBody({ if (!imagePreviewUri) { return ( - + {t("panels.file.loading")} ); diff --git a/packages/app/src/git/actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx index 2551f1e9e11..1d589d23757 100644 --- a/packages/app/src/git/actions-split-button.tsx +++ b/packages/app/src/git/actions-split-button.tsx @@ -1,11 +1,6 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useCallback, useMemo } from "react"; -import { - View, - Text, - ActivityIndicator, - Pressable, - type PressableStateCallbackType, -} from "react-native"; +import { View, Text, Pressable, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronDown, Info, MoreVertical } from "lucide-react-native"; import { useTranslation } from "react-i18next"; @@ -146,7 +141,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli accessibilityLabel={gitActions.primary.label} > {gitActions.primary.status === "pending" ? ( - ({ color: theme.colors.foregroundMuted }); -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedAlignJustify = withUnistyles(AlignJustify); const ThemedColumns2 = withUnistyles(Columns2); const ThemedPilcrow = withUnistyles(Pilcrow); @@ -1693,7 +1692,6 @@ export function DiffOptionsMenu({ } const ThemedRotateCw = withUnistyles(RotateCw); -const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); type DiffFlatItemLayoutGetter = NonNullable["getItemLayout"]>; const EMPTY_PATH_LIST: string[] = []; @@ -1779,7 +1777,7 @@ function DiffBodyContent({ if (isStatusLoading) { return ( - + {checkingRepositoryLabel} ); @@ -1801,7 +1799,7 @@ function DiffBodyContent({ if (isDiffLoading) { return ( - + ); } diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index c2cf7c02c3e..1bf8b9557ab 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -77,6 +77,10 @@ export function useLoadOlderAgentHistory({ useSessionStore((state) => state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId), ) === true; + const progressKey = useSessionStore((state) => { + const cursor = state.sessions[serverId]?.agentTimelineCursor.get(agentId); + return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null; + }); const setOlderFetchInFlight = useSessionStore( (state) => state.setAgentTimelineOlderFetchInFlight, ); @@ -116,6 +120,7 @@ export function useLoadOlderAgentHistory({ return { isLoadingOlder, hasOlder, + progressKey, loadOlder, }; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 50890040166..240f300dbb3 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,3 +1,4 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { TFunction } from "i18next"; import { SquarePen } from "lucide-react-native"; @@ -11,7 +12,7 @@ import React, { useSyncExternalStore, } from "react"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, StyleSheet as RNStyleSheet, Text, View } from "react-native"; +import { StyleSheet as RNStyleSheet, Text, View } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -215,7 +216,7 @@ function renderChatAgentNonReadyView(args: { return ( - + ); @@ -716,7 +717,7 @@ function AgentPanelBody({ return ( - + ); @@ -1246,7 +1247,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ {showHistorySyncOverlay ? ( - + ) : null} @@ -1255,7 +1256,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ {isArchivingCurrentAgent ? ( - + {t("agentPanel.states.archivingTitle")} {t("agentPanel.states.archivingSubtitle")} @@ -1600,7 +1601,7 @@ function AgentSessionUnavailableState({ {isConnecting || isPreparingSession ? ( <> - + {isPreparingSession ? t("agentPanel.unavailable.preparingSession", { serverLabel }) @@ -1628,7 +1629,7 @@ function AgentSessionUnavailableState({ ); } -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index d77fa3225c0..a5bac3b7bf6 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -130,6 +130,9 @@ function ProviderSubagentPanel() { target.subagentId, timeline, ]); + const firstTimelineSeq = timeline?.rows.size ? Math.min(...timeline.rows.keys()) : null; + const progressKey = + timeline?.epoch && firstTimelineSeq !== null ? `${timeline.epoch}:${firstTimelineSeq}` : null; const streamContext = useMemo( () => ({ @@ -147,9 +150,10 @@ function ProviderSubagentPanel() { () => ({ hasOlder: timeline?.hasOlder === true, isLoadingOlder, + progressKey, onLoadOlder: loadOlder, }), - [isLoadingOlder, loadOlder, timeline?.hasOlder], + [isLoadingOlder, loadOlder, progressKey, timeline?.hasOlder], ); if (serverInfo && !supported) { diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index a183b82554e..5087cf36530 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -1,14 +1,8 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native"; import { useTranslation } from "react-i18next"; -import { - ActivityIndicator, - Pressable, - type PressableStateCallbackType, - ScrollView, - Text, - View, -} from "react-native"; +import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native"; import invariant from "tiny-invariant"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { usePaneContext } from "@/panels/pane-context"; @@ -69,7 +63,7 @@ type CommandStatus = "running" | "completed" | "failed"; function CommandStatusIcon({ status }: { status: CommandStatus }) { if (status === "running") { - return ; + return ; } if (status === "completed") { return ; @@ -247,7 +241,7 @@ function SetupPanel() { {isWaiting ? ( - + {t("workspace.setup.waiting")} ) : null} @@ -449,7 +443,7 @@ function TopLevelSetupError({ ); } -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedCheckCircle2 = withUnistyles(CheckCircle2); const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedChevronRight = withUnistyles(ChevronRight); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 7206b7f9e6b..2275bd543d3 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1,3 +1,4 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { useCallback, useEffect, @@ -8,7 +9,6 @@ import React, { type SetStateAction, } from "react"; import { - ActivityIndicator, Pressable, ScrollView, Text, @@ -94,7 +94,7 @@ const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedX = withUnistyles(X); const ThemedCopy = withUnistyles(Copy); const ThemedRotateCw = withUnistyles(RotateCw); @@ -696,7 +696,7 @@ function TabChip({ const highlighted = closeHovered || pressed; if (isClosingTab) { return ( - diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 78fa387cfa2..15e9ef09fdd 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -1,12 +1,7 @@ +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { type ReactElement, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { - ActivityIndicator, - Pressable, - Text, - View, - type PressableStateCallbackType, -} from "react-native"; +import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { useMutation } from "@tanstack/react-query"; import { Check, ChevronDown } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -47,7 +42,7 @@ interface OpenTarget { onOpen: () => Promise | void; } -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedEditorTargetIcon = withUnistyles(EditorTargetIcon); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedCheckIcon = withUnistyles(Check); @@ -235,7 +230,7 @@ export function WorkspaceOpenInEditorButton({ } > {openMutation.isPending ? ( - - + ); } From e1b1ca569d6979c424bff4556dd6ed9f49fdbc80 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 13:52:57 +0200 Subject: [PATCH 092/420] Keep large file views from disconnecting (#2482) * fix(files): keep large downloads connected Large file reads were emitted as one frame, crossing the physical socket high-water mark and terminating otherwise healthy connections. Stream bounded chunks from one file handle and pace each transfer by its own send completion without globally queueing unrelated traffic. * fix(files): keep paced relay transfers bounded Carry send completion through encryption to the physical WebSocket callback, cap growing files at their advertised size, and preserve text classification when a UTF-8 code point crosses the sample boundary. * fix(files): reject changing transfer snapshots Abort when a file shrinks below its advertised size, finalize UTF-8 validation for complete samples, and let the physical relay socket remain the sole authority for queued-byte accounting. * fix(files): validate metadata before transfer Keep the daemon handshake pending until the ready frame is physically sent, and classify file content with a bounded full-handle scan so streaming preserves the previous binary/text behavior. * fix(files): detect changing transfer sources * fix(files): enforce transfer snapshot integrity --- docs/architecture.md | 4 + packages/relay/src/encrypted-channel.test.ts | 117 +++++++++++- packages/relay/src/encrypted-channel.ts | 32 ++-- .../src/server/file-explorer/service.test.ts | 130 +++++++++++++- .../src/server/file-explorer/service.ts | 115 ++++++++++++ .../server/src/server/relay-transport.test.ts | 96 +++++++++- packages/server/src/server/relay-transport.ts | 29 +-- packages/server/src/server/session.ts | 35 +++- .../files/workspace-files-session.test.ts | 80 ++++++++- .../session/files/workspace-files-session.ts | 170 ++++++++++-------- ...websocket-server.file-transfer.e2e.test.ts | 168 +++++++++++++++++ .../server/src/server/websocket-server.ts | 31 +++- .../websocket/encrypted-relay-socket.test.ts | 64 ++++--- .../websocket/encrypted-relay-socket.ts | 28 ++- .../server/websocket/physical-socket.test.ts | 54 ++++++ .../src/server/websocket/physical-socket.ts | 39 +++- 16 files changed, 1039 insertions(+), 153 deletions(-) create mode 100644 packages/server/src/server/websocket-server.file-transfer.e2e.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 5b492eb0194..62f80b63b1f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -252,6 +252,10 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport. There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams. +File downloads keep the existing `FileBegin`/`FileChunk`/`FileEnd` framing and stream 256 KiB chunks +from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before +reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated +messages or transfers. ### Compatibility rules diff --git a/packages/relay/src/encrypted-channel.test.ts b/packages/relay/src/encrypted-channel.test.ts index 90ef5e0e305..b2f79c494c5 100644 --- a/packages/relay/src/encrypted-channel.test.ts +++ b/packages/relay/src/encrypted-channel.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi } from "vitest"; -import { createClientChannel, createDaemonChannel, Transport } from "./encrypted-channel.js"; +import { + createClientChannel, + createDaemonChannel, + EncryptedChannel, + Transport, +} from "./encrypted-channel.js"; import { deriveSharedKey, encrypt, @@ -47,6 +52,64 @@ async function waitForAsyncDelivery(): Promise { } describe("EncryptedChannel", () => { + it("rejects the daemon handshake when the ready frame fails to send", async () => { + const daemonKeyPair = generateKeyPair(); + const clientKeyPair = generateKeyPair(); + const transport: Transport = { + send: () => Promise.reject(new Error("ready send failed")), + close: () => undefined, + onmessage: null, + onclose: null, + onerror: null, + }; + const channel = createDaemonChannel(transport, daemonKeyPair); + + transport.onmessage?.({ + data: JSON.stringify({ + type: "e2ee_hello", + key: exportPublicKey(clientKeyPair.publicKey), + }), + isBinary: false, + }); + + await expect(channel).rejects.toThrow("ready send failed"); + }); + + it("waits for transport send completion", async () => { + let completeSend: (() => void) | undefined; + const transport: Transport = { + send: () => + new Promise((resolve) => { + completeSend = resolve; + }), + close: () => undefined, + onmessage: null, + onclose: null, + onerror: null, + }; + const first = generateKeyPair(); + const second = generateKeyPair(); + const channel = new EncryptedChannel( + transport, + deriveSharedKey(first.secretKey, second.publicKey), + {}, + { binaryCiphertext: true }, + ); + channel.setState("open"); + let completed = false; + + const sending = channel.send(new Uint8Array([1, 2, 3]).buffer).then(() => { + completed = true; + return undefined; + }); + await Promise.resolve(); + expect(completed).toBe(false); + + completeSend?.(); + await sending; + expect(completed).toBe(true); + }); + it("establishes encrypted channel between daemon and client", async () => { const [daemonTransport, clientTransport] = createMockTransportPair(); @@ -191,6 +254,58 @@ describe("EncryptedChannel", () => { } }); + it("reports rejected handshake hello sends", async () => { + const daemonKeyPair = generateKeyPair(); + const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey); + const transport: Transport = { + send: () => Promise.reject(new Error("hello send failed")), + close: vi.fn(), + onmessage: null, + onclose: null, + onerror: null, + }; + const onerror = vi.fn(); + + await createClientChannel(transport, daemonPubKeyB64, { onerror }); + await Promise.resolve(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect((onerror.mock.calls[0][0] as Error).message).toBe("hello send failed"); + transport.onclose?.(1000, "closed"); + }); + + it("reports rejected sends while flushing the handshake backlog", async () => { + const daemonKeyPair = generateKeyPair(); + const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey); + let sendAttempts = 0; + const transport: Transport = { + send: () => { + sendAttempts += 1; + return sendAttempts === 1 ? undefined : Promise.reject(new Error("backlog send failed")); + }, + close: vi.fn(), + onmessage: null, + onclose: null, + onerror: null, + }; + const onerror = vi.fn(); + const channel = await createClientChannel(transport, daemonPubKeyB64, { onerror }); + await channel.send(new ArrayBuffer(8)); + + transport.onmessage?.({ + data: JSON.stringify({ + type: "e2ee_ready", + capabilities: { binaryCiphertext: true }, + }), + isBinary: false, + }); + await waitForAsyncDelivery(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect((onerror.mock.calls[0][0] as Error).message).toBe("backlog send failed"); + expect(transport.close).toHaveBeenCalledWith(1011, "backlog send failed"); + }); + it("fails handshake on invalid hello", async () => { const [daemonTransport] = createMockTransportPair(); diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index f25dc3d0509..de953616ade 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -19,7 +19,7 @@ import { import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; export interface Transport { - send(data: string | ArrayBuffer): void; + send(data: string | ArrayBuffer): void | Promise; close(code?: number, reason?: string): void; onmessage: ((message: TransportMessage) => void) | null; onclose: ((code: number, reason: string) => void) | null; @@ -169,7 +169,10 @@ export async function createClientChannel( }; const sendHello = () => { try { - transport.send(helloText); + const result = transport.send(helloText); + if (result) { + void result.catch(emitSendError); + } return true; } catch (error) { // This can happen during daemon restarts while the socket transitions @@ -263,11 +266,7 @@ export async function createDaemonChannel( const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey); const binaryCiphertext = supportsBinaryCiphertext(msg); - const channel = new EncryptedChannel(transport, sharedKey, events, { - daemonKeyPair, - binaryCiphertext, - }); - transport.send( + await transport.send( JSON.stringify({ type: "e2ee_ready", ...(binaryCiphertext @@ -276,6 +275,10 @@ export async function createDaemonChannel( } satisfies E2EEReadyMessage), ); + const channel = new EncryptedChannel(transport, sharedKey, events, { + daemonKeyPair, + binaryCiphertext, + }); channel.setState("open"); events.onopen?.(); @@ -354,7 +357,14 @@ export class EncryptedChannel { this.state = "open"; this.events.onopen?.(); for (const cb of this.onOpenCallbacks) cb(); - await this.flushPendingSends(); + try { + await this.flushPendingSends(); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.events.onerror?.(err); + this.state = "closed"; + this.transport.close(1011, err.message); + } } } catch { // ignore non-ready handshake traffic @@ -455,12 +465,12 @@ export class EncryptedChannel { const ciphertext = encrypt(this.sharedKey, data); if (this.options.binaryCiphertext && data instanceof ArrayBuffer) { - this.transport.send(ciphertext); + await this.transport.send(ciphertext); return; } // COMPAT(binaryCiphertext): added in v0.2.3, remove base64 binary sends // after 2027-01-27 once the supported peer floor includes negotiation. - this.transport.send(arrayBufferToBase64(ciphertext)); + await this.transport.send(arrayBufferToBase64(ciphertext)); } outboundWireByteLength(data: string | ArrayBuffer): number { @@ -489,7 +499,7 @@ export class EncryptedChannel { // "ready" but do not re-key. Re-keying here would desync // the channel and cause decrypt failures. if (keysEqual(nextSharedKey, this.sharedKey)) { - this.transport.send( + await this.transport.send( JSON.stringify({ type: "e2ee_ready", ...(this.options.binaryCiphertext diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index 1025f395e98..07f451e83c2 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -1,8 +1,13 @@ -import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { appendFile, chmod, mkdtemp, rm, stat, truncate, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { getExplorerFileVersion, readExplorerFile, writeExplorerFile } from "./service.js"; +import { + getExplorerFileVersion, + readExplorerFile, + streamExplorerFile, + writeExplorerFile, +} from "./service.js"; async function createHomeTempDir(prefix: string): Promise { return mkdtemp(path.join(os.homedir(), prefix)); @@ -192,6 +197,127 @@ describe("file explorer service", () => { } }); + it("fails a stream when the file grows after its revision is advertised", async () => { + const root = await createTempDir("paseo-file-stream-growth-"); + + try { + const filePath = path.join(root, "growing.log"); + const initial = Buffer.alloc(300 * 1024, 0x61); + await writeFile(filePath, initial); + await expect( + streamExplorerFile({ root, relativePath: "growing.log" }, async (file) => { + await appendFile(filePath, Buffer.alloc(300 * 1024, 0x62)); + for await (const _chunk of file.chunks) { + // Consume through the advertised prefix before validating the revision. + } + }), + ).rejects.toThrow("File changed during transfer"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("fails a stream when the file shrinks below its advertised size", async () => { + const root = await createTempDir("paseo-file-stream-truncate-"); + + try { + const filePath = path.join(root, "shrinking.log"); + await writeFile(filePath, Buffer.alloc(300 * 1024, 0x61)); + + await expect( + streamExplorerFile({ root, relativePath: "shrinking.log" }, async (file) => { + await truncate(filePath, 100 * 1024); + for await (const _chunk of file.chunks) { + // Consume until the stream detects the premature EOF. + } + }), + ).rejects.toThrow("File changed during transfer"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("fails a stream when the file is overwritten in place", async () => { + const root = await createTempDir("paseo-file-stream-overwrite-"); + + try { + const filePath = path.join(root, "changing.log"); + const initial = Buffer.alloc(600 * 1024, 0x61); + await writeFile(filePath, initial); + + await expect( + streamExplorerFile({ root, relativePath: "changing.log" }, async (file) => { + let chunkIndex = 0; + for await (const _chunk of file.chunks) { + chunkIndex += 1; + if (chunkIndex === 1) { + const replacement = Buffer.alloc(initial.byteLength, 0x62); + await writeFile(filePath, replacement); + } + } + }), + ).rejects.toThrow("File changed during transfer"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("classifies sampled text when UTF-8 crosses the sample boundary", async () => { + const root = await createTempDir("paseo-file-stream-utf8-"); + + try { + const content = Buffer.concat([Buffer.alloc(8191, 0x61), Buffer.from("€"), Buffer.from("z")]); + await writeFile(path.join(root, "sample.txt"), content); + let kind: string | undefined; + let encoding: string | undefined; + + await streamExplorerFile({ root, relativePath: "sample.txt" }, async (file) => { + kind = file.kind; + encoding = file.encoding; + }); + + expect(kind).toBe("text"); + expect(encoding).toBe("utf-8"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects incomplete UTF-8 when the whole file was sampled", async () => { + const root = await createTempDir("paseo-file-stream-invalid-utf8-"); + + try { + await writeFile(path.join(root, "invalid.txt"), Buffer.from([0x61, 0xe2, 0x82])); + let kind: string | undefined; + + await streamExplorerFile({ root, relativePath: "invalid.txt" }, async (file) => { + kind = file.kind; + }); + + expect(kind).toBe("binary"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("detects binary bytes beyond the initial classification block", async () => { + const root = await createTempDir("paseo-file-stream-late-binary-"); + + try { + const content = Buffer.concat([Buffer.alloc(8192, 0x61), Buffer.from([0xff])]); + await writeFile(path.join(root, "late-binary.unknown"), content); + let kind: string | undefined; + + await streamExplorerFile({ root, relativePath: "late-binary.unknown" }, async (file) => { + kind = file.kind; + }); + + expect(kind).toBe("binary"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("expands a ~ prefix in relative paths against the user home directory", async () => { const root = await createHomeTempDir(".paseo-file-explorer-home-"); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 45a8d74ca52..a33007b5964 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -76,12 +76,24 @@ export interface FileExplorerFileBytes { revision: string; } +export interface FileExplorerFileStream { + path: string; + kind: ExplorerFileKind; + encoding: "utf-8" | "binary"; + mimeType: string; + size: number; + modifiedAt: string; + revision: string; + chunks: AsyncIterable; +} + const TEXT_MIME_TYPES: Record = { ".json": "application/json", }; const DEFAULT_TEXT_MIME_TYPE = "text/plain"; const FILE_TYPE_SAMPLE_BYTES = 8192; +export const FILE_EXPLORER_STREAM_CHUNK_BYTES = 256 * 1024; export const MAX_EDITABLE_FILE_BYTES = 1024 * 1024; const READ_FILE_OPEN_FLAGS = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW; @@ -275,6 +287,109 @@ export async function readExplorerFileBytes({ } } +export async function streamExplorerFile( + { root, relativePath }: ReadFileParams, + consume: (file: FileExplorerFileStream) => Promise, +): Promise { + const filePath = await resolveScopedPath({ root, relativePath }); + const handle = await openFileForRead(filePath.resolvedPath); + + try { + const stats = await handle.stat({ bigint: true }); + if (!stats.isFile()) { + throw new Error("Requested path is not a file"); + } + + const advertisedSize = Number(stats.size); + const advertisedRevision = fileRevision(stats); + const ext = path.extname(filePath.resolvedPath).toLowerCase(); + const isImage = ext in IMAGE_MIME_TYPES; + const isBinary = isImage || (await isFileHandleBinary(handle, advertisedSize)); + let kind: ExplorerFileKind = "text"; + let mimeType = textMimeTypeForExtension(ext); + if (isImage) { + kind = "image"; + mimeType = IMAGE_MIME_TYPES[ext]; + } else if (isBinary) { + kind = "binary"; + mimeType = "application/octet-stream"; + } + + await consume({ + path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }), + kind, + encoding: isBinary ? "binary" : "utf-8", + mimeType, + size: advertisedSize, + modifiedAt: stats.mtime.toISOString(), + revision: advertisedRevision, + chunks: readFileHandleChunks(handle, advertisedSize, advertisedRevision), + }); + } finally { + await handle.close(); + } +} + +async function isFileHandleBinary(handle: FileHandle, advertisedSize: number): Promise { + if (advertisedSize === 0) return false; + + const decoder = new TextDecoder("utf-8", { fatal: true }); + let position = 0; + let suspiciousBytes = 0; + while (position < advertisedSize) { + const block = Buffer.allocUnsafe( + Math.min(FILE_EXPLORER_STREAM_CHUNK_BYTES, advertisedSize - position), + ); + const { bytesRead } = await handle.read(block, 0, block.byteLength, position); + if (bytesRead === 0) { + throw new Error("File changed during transfer"); + } + const bytes = block.subarray(0, bytesRead); + for (const byte of bytes) { + if (byte === 0) return true; + const isControl = byte < 32 && byte !== 9 && byte !== 10 && byte !== 13; + if (isControl || byte === 127) suspiciousBytes += 1; + } + try { + decoder.decode(bytes, { stream: true }); + } catch { + return true; + } + position += bytesRead; + } + + try { + decoder.decode(); + } catch { + return true; + } + return suspiciousBytes / advertisedSize > 0.3; +} + +async function* readFileHandleChunks( + handle: FileHandle, + advertisedSize: number, + advertisedRevision: string, +): AsyncIterable { + let position = 0; + while (position < advertisedSize) { + const chunk = Buffer.allocUnsafe( + Math.min(FILE_EXPLORER_STREAM_CHUNK_BYTES, advertisedSize - position), + ); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, position); + if (bytesRead === 0) { + throw new Error("File changed during transfer"); + } + position += bytesRead; + yield chunk.subarray(0, bytesRead); + } + + const finalStats = await handle.stat({ bigint: true }); + if (fileRevision(finalStats) !== advertisedRevision) { + throw new Error("File changed during transfer"); + } +} + export async function getExplorerFileVersion({ root, relativePath, diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index fb1ddfe4b7e..06775e5c5c8 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type pino from "pino"; +import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; +import { exportPublicKey, generateKeyPair } from "@getpaseo/relay"; import { startRelayTransport } from "./relay-transport"; function createMockLogger() { @@ -32,7 +34,10 @@ class FakeRelayWebSocket { sent: Array = []; terminateCalls = 0; pingCalls = 0; + deferSendCompletion = false; + onSend: ((data: string | Uint8Array | ArrayBuffer) => void) | null = null; private readonly listeners = new Map void>>(); + private readonly pendingSendCallbacks: Array<(error?: Error) => void> = []; constructor(readonly url: string) {} @@ -61,11 +66,22 @@ class FakeRelayWebSocket { this.emit("close", 1006, ""); } - send(data: string | Uint8Array | ArrayBuffer) { + send(data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) { if (this.readyState !== FakeRelayWebSocket.OPEN) { throw new Error(`WebSocket not open (readyState=${this.readyState})`); } this.sent.push(data); + this.onSend?.(data); + if (!callback) return; + if (this.deferSendCompletion) { + this.pendingSendCallbacks.push(callback); + return; + } + callback(); + } + + completeNextSend() { + this.pendingSendCallbacks.shift()?.(); } ping() { @@ -80,8 +96,8 @@ class FakeRelayWebSocket { this.emit("open"); } - message(data: unknown) { - this.emit("message", data); + message(data: unknown, isBinary = data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + this.emit("message", data, isBinary); } pong() { @@ -239,6 +255,80 @@ describe("relay-transport control lifecycle", () => { ]); }); + test("encrypted sends wait for the physical data socket callback", async () => { + const logger = createMockLogger(); + const daemonKeyPair = generateKeyPair(); + let resolveAttached: ((socket: unknown) => void) | undefined; + const attached = new Promise((resolve) => { + resolveAttached = resolve; + }); + const controller = startRelayTransport({ + logger: logger as unknown as pino.Logger, + attachSocket: async (socket) => resolveAttached?.(socket), + relayEndpoint: "relay.paseo.sh:443", + relayUseTls: true, + serverId: "srv_test", + daemonKeyPair, + createWebSocket: relay.createWebSocket, + }); + controllers.push(controller); + + const control = relay.sockets[0]; + control.open(); + control.message(JSON.stringify({ type: "sync", connectionIds: [] }), false); + control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }), false); + + const dataSocket = relay.sockets[1]; + dataSocket.deferSendCompletion = true; + dataSocket.open(); + let clientTransport: Transport; + clientTransport = { + send: (data) => dataSocket.message(data, data instanceof ArrayBuffer), + close: () => undefined, + onmessage: null, + onclose: null, + onerror: null, + }; + dataSocket.onSend = (data) => { + clientTransport.onmessage?.({ + data: data instanceof Uint8Array ? data.slice().buffer : data, + isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array, + }); + }; + let resolveClientOpen: (() => void) | undefined; + const clientOpen = new Promise((resolve) => { + resolveClientOpen = resolve; + }); + await createClientChannel(clientTransport, exportPublicKey(daemonKeyPair.publicKey), { + onopen: () => resolveClientOpen?.(), + }); + + let attachedCompleted = false; + void attached.then(() => { + attachedCompleted = true; + return undefined; + }); + await clientOpen; + await Promise.resolve(); + expect(attachedCompleted).toBe(false); + dataSocket.completeNextSend(); + const encryptedSocket = (await attached) as { + send: (data: Uint8Array) => void | Promise; + }; + let completed = false; + + const sending = Promise.resolve(encryptedSocket.send(new Uint8Array([1, 2, 3]))).then(() => { + completed = true; + return undefined; + }); + await Promise.resolve(); + expect(completed).toBe(false); + + dataSocket.completeNextSend(); + await sending; + expect(completed).toBe(true); + }); + test("uses relayUseTls for control and data socket URLs", () => { const logger = createMockLogger(); const controller = startRelayTransport({ diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index b39fef1a212..b60b01bd005 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -28,7 +28,7 @@ export interface RelayTransportController { interface RelaySocketLike { readyState: number; bufferedAmount?: number; - send: (data: string | Uint8Array | ArrayBuffer) => void; + send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void; close: (code?: number, reason?: string) => void; terminate?: () => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; @@ -467,16 +467,23 @@ function createRelayTransportAdapter( logger: pino.Logger, ): RelayTransport { const relayTransport: RelayTransport = { - send: (data) => { - try { - socket.send(data); - } catch (err) { - // Socket likely transitioned to closed between checks; let onclose/onerror - // drive cleanup. Without this guard the synchronous throw would propagate - // up as an uncaughtException and take down the daemon. - logger.warn({ err }, "relay_socket_send_failed"); - } - }, + send: (data) => + new Promise((resolve, reject) => { + try { + socket.send(data, (error) => { + if (!error) { + resolve(); + return; + } + logger.warn({ err: error }, "relay_socket_send_failed"); + reject(error); + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn({ err }, "relay_socket_send_failed"); + reject(err); + } + }), close: (code?: number, reason?: string) => socket.close(code, reason), onmessage: null, onclose: null, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 04f04fe6f28..7cf2f24c333 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -405,6 +405,7 @@ export interface SessionOptions { onMessage: (msg: SessionOutboundMessage) => void; onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; + onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; getTransportBufferedAmount?: () => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise; @@ -568,6 +569,9 @@ export class Session { | ((source: object, msg: SessionOutboundMessage) => void) | null; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; + private readonly onBinaryMessageToSource: + | ((source: object, frame: Uint8Array) => Promise) + | null; private readonly getTransportBufferedAmount: () => number | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly onWorkspaceRecovered: @@ -646,6 +650,7 @@ export class Session { onMessage, onMessageToSource, onBinaryMessage, + onBinaryMessageToSource, getTransportBufferedAmount, onLifecycleIntent, onWorkspaceRecovered, @@ -698,6 +703,7 @@ export class Session { this.onMessage = onMessage; this.onMessageToSource = onMessageToSource ?? null; this.onBinaryMessage = onBinaryMessage ?? null; + this.onBinaryMessageToSource = onBinaryMessageToSource ?? null; this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0); this.onLifecycleIntent = onLifecycleIntent ?? null; this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; @@ -711,8 +717,8 @@ export class Session { }); this.workspaceFilesSession = new WorkspaceFilesSession({ host: { - emit: (msg) => this.emit(msg), - emitBinary: (frame) => this.emitBinary(frame), + emit: (msg, source) => this.emitForSource(msg, source), + emitBinary: (frame, source) => this.emitBinaryForFileTransfer(frame, source), hasBinaryChannel: () => this.onBinaryMessage !== null, }, downloadTokenStore, @@ -1786,7 +1792,7 @@ export class Session { this.dispatchCheckoutMessage(msg) ?? this.dispatchWorkspaceRecoveryMessage(msg) ?? this.dispatchWorkspaceAndProjectMessage(msg) ?? - this.dispatchWorkspaceFileMessage(msg) ?? + this.dispatchWorkspaceFileMessage(msg, source) ?? this.dispatchProviderMessage(msg) ?? this.dispatchTerminalMessage(msg) ?? this.dispatchChatScheduleLoopMessage(msg) ?? @@ -2106,10 +2112,13 @@ export class Session { } } - private dispatchWorkspaceFileMessage(msg: SessionInboundMessage): Promise | undefined { + private dispatchWorkspaceFileMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { switch (msg.type) { case "file_explorer_request": - return this.workspaceFilesSession.handleFileExplorerRequest(msg); + return this.workspaceFilesSession.handleFileExplorerRequest(msg, source); case "fs.file.subscribe.request": return this.workspaceFilesSession.handleFileSubscribeRequest(msg); case "fs.file.unsubscribe.request": @@ -6532,6 +6541,22 @@ export class Session { } } + private async emitBinaryForFileTransfer(frame: Uint8Array, source?: object): Promise { + if (source && this.onBinaryMessageToSource) { + await this.onBinaryMessageToSource(source, frame); + return; + } + this.emitBinary(frame); + } + + private emitForSource(msg: SessionOutboundMessage, source?: object): void { + if (source && this.onMessageToSource) { + this.onMessageToSource(source, msg); + return; + } + this.emit(msg); + } + /** * Clean up session resources */ diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts index cfc45c5c760..0b09c877434 100644 --- a/packages/server/src/server/session/files/workspace-files-session.test.ts +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -30,13 +30,21 @@ function makeDir(prefix: string): string { return dir; } -function makeSubsystem(options: { hasBinaryChannel?: boolean } = {}) { +function makeSubsystem( + options: { + hasBinaryChannel?: boolean; + emitBinary?: (frame: Uint8Array) => Promise | void; + } = {}, +) { const emitted: SessionOutboundMessage[] = []; const binary: Uint8Array[] = []; let hasBinary = options.hasBinaryChannel ?? false; const host: WorkspaceFilesSessionHost = { emit: (msg) => emitted.push(msg), - emitBinary: (frame) => binary.push(frame), + emitBinary: async (frame) => { + binary.push(frame); + await options.emitBinary?.(frame); + }, hasBinaryChannel: () => hasBinary, }; const paseoHome = makeDir("workspace-files-home-"); @@ -136,6 +144,74 @@ describe("WorkspaceFilesSession", () => { ]); }); + test("streams a real file larger than the socket limit as paced ordered chunks", async () => { + const cwd = makeDir("workspace-files-large-binary-"); + const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123); + for (let index = 0; index < fileBytes.length; index += 1) { + fileBytes[index] = index % 251; + } + writeFileSync(join(cwd, "large.bin"), fileBytes); + + let releaseFirstChunk: (() => void) | undefined; + const firstChunkSent = new Promise((resolve) => { + releaseFirstChunk = resolve; + }); + let chunkSends = 0; + const { subsystem, emitted, binary } = makeSubsystem({ + hasBinaryChannel: true, + emitBinary: async (frame) => { + if (decodeFileTransferFrame(frame)?.opcode !== FileTransferOpcode.FileChunk) return; + chunkSends += 1; + if (chunkSends === 1) await firstChunkSent; + }, + }); + + const transfer = subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + cwd, + path: "large.bin", + mode: "file", + requestId: "req-large-binary", + acceptBinary: true, + }); + + await expect.poll(() => chunkSends).toBe(1); + expect(binary.map((frame) => decodeFileTransferFrame(frame)?.opcode)).toEqual([ + FileTransferOpcode.FileBegin, + FileTransferOpcode.FileChunk, + ]); + + await subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + cwd, + path: ".", + mode: "list", + requestId: "req-unrelated-list", + }); + expect(emitted).toEqual([ + expect.objectContaining({ + type: "file_explorer_response", + payload: expect.objectContaining({ requestId: "req-unrelated-list", error: null }), + }), + ]); + + releaseFirstChunk?.(); + await transfer; + + const frames = binary.map((frame) => decodeFileTransferFrame(frame)); + const chunks = frames.flatMap((frame) => + frame?.opcode === FileTransferOpcode.FileChunk ? [frame.payload] : [], + ); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.byteLength <= 256 * 1024)).toBe(true); + expect( + Buffer.compare(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))), fileBytes), + ).toBe(0); + expect(frames.at(0)?.opcode).toBe(FileTransferOpcode.FileBegin); + expect(frames.at(-1)?.opcode).toBe(FileTransferOpcode.FileEnd); + expect(emitted).toHaveLength(1); + }, 30_000); + test("rejects an empty file-explorer cwd with an error envelope", async () => { const { subsystem, emitted } = makeSubsystem(); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index 740f74a61e9..dd7d7d04d4f 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -21,7 +21,7 @@ import { getDownloadableFileInfo, listDirectoryEntries, readExplorerFile, - readExplorerFileBytes, + streamExplorerFile, writeExplorerFile, } from "../../file-explorer/service.js"; import { workspaceFileObserver, type FileObserver } from "../../file-explorer/observer.js"; @@ -34,8 +34,8 @@ import { getProjectIcon } from "../../../utils/project-icon.js"; * — old clients without a binary channel fall back to inline JSON file content. */ export interface WorkspaceFilesSessionHost { - emit(msg: SessionOutboundMessage): void; - emitBinary(frame: Uint8Array): void; + emit(msg: SessionOutboundMessage, source?: object): void; + emitBinary(frame: Uint8Array, source?: object): Promise; hasBinaryChannel(): boolean; } @@ -136,22 +136,25 @@ export class WorkspaceFilesSession { this.fileSubscriptions.clear(); } - async handleFileExplorerRequest(request: FileExplorerRequest): Promise { + async handleFileExplorerRequest(request: FileExplorerRequest, source?: object): Promise { const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request; const cwd = workspaceCwd.trim(); if (!cwd) { - this.host.emit({ - type: "file_explorer_response", - payload: { - cwd: workspaceCwd, - path: requestedPath, - mode, - directory: null, - file: null, - error: "cwd is required", - requestId, + this.host.emit( + { + type: "file_explorer_response", + payload: { + cwd: workspaceCwd, + path: requestedPath, + mode, + directory: null, + file: null, + error: "cwd is required", + requestId, + }, }, - }); + source, + ); return; } @@ -162,69 +165,77 @@ export class WorkspaceFilesSession { relativePath: requestedPath, }); - this.host.emit({ - type: "file_explorer_response", - payload: { - cwd, - path: directory.path, - mode, - directory, - file: null, - error: null, - requestId, + this.host.emit( + { + type: "file_explorer_response", + payload: { + cwd, + path: directory.path, + mode, + directory, + file: null, + error: null, + requestId, + }, }, - }); + source, + ); } else { if (request.acceptBinary && this.host.hasBinaryChannel()) { - const file = await readExplorerFileBytes({ - root: cwd, - relativePath: requestedPath, + await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => { + await this.host.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: file.mimeType, + size: file.size, + encoding: file.encoding, + modifiedAt: file.modifiedAt, + revision: file.revision, + }, + }), + source, + ); + for await (const chunk of file.chunks) { + await this.host.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: chunk, + }), + source, + ); + } + await this.host.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId, + }), + source, + ); }); - - this.host.emitBinary( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileBegin, - requestId, - metadata: { - mime: file.mimeType, - size: file.size, - encoding: file.encoding, - modifiedAt: file.modifiedAt, - revision: file.revision, - }, - }), - ); - this.host.emitBinary( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileChunk, - requestId, - payload: file.bytes, - }), - ); - this.host.emitBinary( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileEnd, - requestId, - }), - ); } else { const file = await readExplorerFile({ root: cwd, relativePath: requestedPath, }); - this.host.emit({ - type: "file_explorer_response", - payload: { - cwd, - path: file.path, - mode, - directory: null, - file, - error: null, - requestId, + this.host.emit( + { + type: "file_explorer_response", + payload: { + cwd, + path: file.path, + mode, + directory: null, + file, + error: null, + requestId, + }, }, - }); + source, + ); } } } catch (error) { @@ -232,18 +243,21 @@ export class WorkspaceFilesSession { { err: error, cwd, path: requestedPath }, `Failed to fulfill file explorer request for workspace ${cwd}`, ); - this.host.emit({ - type: "file_explorer_response", - payload: { - cwd, - path: requestedPath, - mode, - directory: null, - file: null, - error: getErrorMessage(error), - requestId, + this.host.emit( + { + type: "file_explorer_response", + payload: { + cwd, + path: requestedPath, + mode, + directory: null, + file: null, + error: getErrorMessage(error), + requestId, + }, }, - }); + source, + ); } } diff --git a/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts b/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts new file mode 100644 index 00000000000..5131456ee9e --- /dev/null +++ b/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts @@ -0,0 +1,168 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { WebSocket, type RawData } from "ws"; +import { + decodeFileTransferFrame, + FileTransferOpcode, +} from "@getpaseo/protocol/binary-frames/index"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/index.js"; +import { WSOutboundMessageSchema, type WSOutboundMessage } from "./messages.js"; + +const TEST_TIMEOUT_MS = 30_000; +const FILE_SIZE = 8 * 1024 * 1024 + 123; + +let daemon: TestPaseoDaemon | undefined; +const temporaryDirectories: string[] = []; +const sockets: WebSocket[] = []; + +afterEach(async () => { + for (const socket of sockets.splice(0)) socket.terminate(); + await daemon?.close(); + daemon = undefined; + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test( + "a large file stays ordered, source-scoped, and does not block another socket", + async () => { + const cwd = mkdtempSync(join(tmpdir(), "paseo-large-file-transfer-")); + temporaryDirectories.push(cwd); + const expected = Buffer.alloc(FILE_SIZE); + for (let index = 0; index < expected.length; index += 1) expected[index] = index % 251; + writeFileSync(join(cwd, "large.bin"), expected); + + daemon = await createTestPaseoDaemon(); + const source = await connectSocket(daemon.port, "shared-file-client"); + const unrelated = await connectSocket(daemon.port, "shared-file-client"); + sockets.push(source, unrelated); + + let unrelatedBinaryFrames = 0; + unrelated.on("message", (_data, isBinary) => { + if (isBinary) unrelatedBinaryFrames += 1; + }); + + const transfer = receiveFileTransfer(source, "req-large-file"); + source.send( + JSON.stringify({ + type: "session", + message: { + type: "file_explorer_request", + cwd, + path: "large.bin", + mode: "file", + acceptBinary: true, + requestId: "req-large-file", + }, + }), + ); + + await sendAndWait( + unrelated, + { + type: "session", + message: { type: "ping", requestId: "req-unrelated", clientSentAt: 1 }, + }, + (message) => + message.type === "session" && + message.message.type === "pong" && + message.message.payload.requestId === "req-unrelated", + ); + + const frames = await transfer; + const chunks = frames.flatMap((frame) => + frame.opcode === FileTransferOpcode.FileChunk ? [frame.payload] : [], + ); + expect(frames[0]?.opcode).toBe(FileTransferOpcode.FileBegin); + expect(frames.at(-1)?.opcode).toBe(FileTransferOpcode.FileEnd); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.byteLength <= 256 * 1024)).toBe(true); + expect(Buffer.compare(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))), expected)).toBe( + 0, + ); + expect(source.readyState).toBe(WebSocket.OPEN); + expect(unrelated.readyState).toBe(WebSocket.OPEN); + expect(unrelatedBinaryFrames).toBe(0); + }, + TEST_TIMEOUT_MS, +); + +async function connectSocket(port: number, clientId: string): Promise { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + await sendAndWait( + socket, + { type: "hello", clientId, clientType: "browser", protocolVersion: 1 }, + (message) => + message.type === "session" && + message.message.type === "status" && + message.message.payload.status === "server_info", + ); + return socket; +} + +function receiveFileTransfer(source: WebSocket, requestId: string) { + return new Promise>[]>( + (resolve, reject) => { + const frames: NonNullable>[] = []; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for file transfer")); + }, TEST_TIMEOUT_MS); + const onMessage = (data: RawData, isBinary: boolean) => { + if (!isBinary) return; + const frame = decodeFileTransferFrame(new Uint8Array(data as Buffer)); + if (!frame || frame.requestId !== requestId) return; + frames.push(frame); + if (frame.opcode === FileTransferOpcode.FileEnd) { + cleanup(); + resolve(frames); + } + }; + const onClose = () => { + cleanup(); + reject(new Error("Socket closed during file transfer")); + }; + const cleanup = () => { + clearTimeout(timeout); + source.off("message", onMessage); + source.off("close", onClose); + }; + source.on("message", onMessage); + source.on("close", onClose); + }, + ); +} + +function sendAndWait( + socket: WebSocket, + message: unknown, + matches: (message: WSOutboundMessage) => boolean, +): Promise { + const response = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for WebSocket message")); + }, TEST_TIMEOUT_MS); + const onMessage = (data: RawData, isBinary: boolean) => { + if (isBinary) return; + const parsed = WSOutboundMessageSchema.safeParse(JSON.parse(data.toString())); + if (!parsed.success || !matches(parsed.data)) return; + cleanup(); + resolve(parsed.data); + }; + const cleanup = () => { + clearTimeout(timeout); + socket.off("message", onMessage); + }; + socket.on("message", onMessage); + }); + socket.send(JSON.stringify(message)); + return response; +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 104eb08e9b6..c54c4c5fc73 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -96,6 +96,7 @@ import { outboundFrameByteLength, physicalSocketHasCapacity, sendBoundedPhysicalFrame, + sendBoundedPhysicalFrameAndWait, } from "./websocket/physical-socket.js"; const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; @@ -371,7 +372,10 @@ function getBrowserHostCapability( export interface WebSocketLike { readyState: number; bufferedAmount?: number; - send: (data: string | Uint8Array | ArrayBuffer) => void; + send: ( + data: string | Uint8Array | ArrayBuffer, + callback?: (error?: Error) => void, + ) => void | Promise; close: (code?: number, reason?: string) => void; terminate?: () => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; @@ -423,6 +427,7 @@ interface SocketSessionOptions { onMessage: (message: SessionOutboundMessage) => void; onMessageToSource?: (source: object, message: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; + onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; getTransportBufferedAmount?: () => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; hubExecutionAgents?: HubExecutionAgents; @@ -1054,6 +1059,23 @@ export class VoiceAssistantWebSocketServer { }); } + private async sendBinaryToClientAndWait(ws: WebSocketLike, frame: Uint8Array): Promise { + try { + const sent = await sendBoundedPhysicalFrameAndWait({ + socket: ws, + frame, + onHighWater: () => this.closeAtOutboundHighWater(ws), + }); + if (!sent) { + throw new Error("Physical WebSocket is not open"); + } + this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount); + } catch (err) { + this.logger.warn({ err }, "ws_send_failed"); + throw err; + } + } + private sendFrameToClient( ws: WebSocketLike, frame: string | Uint8Array, @@ -1226,6 +1248,12 @@ export class VoiceAssistantWebSocketServer { } this.sendBinaryToConnection(connection, frame); }, + onBinaryMessageToSource: async (source, frame) => { + if (!connection || !connection.sockets.has(source as WebSocketLike)) { + throw new Error("File transfer source socket is no longer attached"); + } + await this.sendBinaryToClientAndWait(source as WebSocketLike, frame); + }, getTransportBufferedAmount: () => { if (!connection) { return null; @@ -1271,6 +1299,7 @@ export class VoiceAssistantWebSocketServer { onMessage: options.onMessage, onMessageToSource: options.onMessageToSource, onBinaryMessage: options.onBinaryMessage, + onBinaryMessageToSource: options.onBinaryMessageToSource, getTransportBufferedAmount: options.getTransportBufferedAmount, onLifecycleIntent: options.onLifecycleIntent, logger: options.connectionLogger.child({ module: "session" }), diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts index 53b230c569e..2e50d75305e 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts @@ -40,10 +40,11 @@ class BlockingChannel implements EncryptedRelayChannel { test("negotiated binary ciphertext accepts the exact hard bound and rejects one byte over", async () => { const channel = new BlockingChannel(); let terminations = 0; + let transportBufferedAmount = 0; const socket = createEncryptedRelaySocket({ channel, emitter: new EventEmitter(), - getTransportBufferedAmount: () => 0, + getTransportBufferedAmount: () => transportBufferedAmount, terminateTransport: () => { terminations += 1; }, @@ -51,9 +52,11 @@ test("negotiated binary ciphertext accepts the exact hard bound and rejects one socket.send(new Uint8Array(MAX_PHYSICAL_SOCKET_BUFFERED_BYTES - 40)); expect(channel.sent).toHaveLength(1); + transportBufferedAmount = MAX_PHYSICAL_SOCKET_BUFFERED_BYTES; expect(socket.bufferedAmount).toBe(MAX_PHYSICAL_SOCKET_BUFFERED_BYTES); - socket.send(new Uint8Array(1)); + const rejected = socket.send(new Uint8Array(1)); + await expect(rejected).rejects.toThrow("outbound high-water mark"); expect(channel.sent).toHaveLength(1); expect(terminations).toBe(1); @@ -64,7 +67,7 @@ test("negotiated binary ciphertext accepts the exact hard bound and rejects one await Promise.resolve(); }); -test("underlying relay backpressure rejects binary before encryption and terminates physically", () => { +test("underlying relay backpressure rejects binary before encryption and terminates physically", async () => { const channel = new BlockingChannel(); let terminations = 0; const socket = createEncryptedRelaySocket({ @@ -76,51 +79,68 @@ test("underlying relay backpressure rejects binary before encryption and termina }, }); - socket.send(new Uint8Array(1)); + const rejected = socket.send(new Uint8Array(1)); + await expect(rejected).rejects.toThrow("outbound high-water mark"); expect(channel.sent).toEqual([]); expect(channel.closes).toEqual([]); expect(terminations).toBe(1); }); -test("pending encryption and underlying relay backpressure share one hard bound", () => { +test("explicit encrypted-socket termination forcibly terminates the relay transport", () => { const channel = new BlockingChannel(); - let transportBufferedAmount = 3 * 1024 * 1024; let terminations = 0; const socket = createEncryptedRelaySocket({ channel, emitter: new EventEmitter(), - getTransportBufferedAmount: () => transportBufferedAmount, + getTransportBufferedAmount: () => 0, terminateTransport: () => { terminations += 1; }, }); - socket.send(new Uint8Array(3 * 1024 * 1024)); - expect(channel.sent).toHaveLength(1); - - transportBufferedAmount = 6 * 1024 * 1024; - socket.send(new Uint8Array(1)); + socket.terminate(); - expect(channel.sent).toHaveLength(1); expect(terminations).toBe(1); + expect(channel.closes).toEqual([]); + expect(socket.readyState).toBe(3); }); -test("explicit encrypted-socket termination forcibly terminates the relay transport", () => { +test("encrypted sends report physical completion through the returned promise", async () => { const channel = new BlockingChannel(); - let terminations = 0; const socket = createEncryptedRelaySocket({ channel, emitter: new EventEmitter(), getTransportBufferedAmount: () => 0, - terminateTransport: () => { - terminations += 1; - }, + terminateTransport: () => undefined, }); + let completed = false; - socket.terminate(); + const sending = socket.send(new Uint8Array([1])); + if (!sending) throw new Error("Expected an awaitable encrypted send"); + void sending.then(() => (completed = true)); + await Promise.resolve(); + expect(completed).toBe(false); - expect(terminations).toBe(1); - expect(channel.closes).toEqual([]); - expect(socket.readyState).toBe(3); + channel.drain(); + await sending; + expect(completed).toBe(true); + expect(socket.bufferedAmount).toBe(0); +}); + +test("encrypted sockets do not double-count bytes already buffered by the transport", () => { + const channel = new BlockingChannel(); + let transportBufferedAmount = 0; + const socket = createEncryptedRelaySocket({ + channel, + emitter: new EventEmitter(), + getTransportBufferedAmount: () => transportBufferedAmount, + terminateTransport: () => undefined, + }); + const payload = new Uint8Array(3 * 1024 * 1024); + + void socket.send(payload); + transportBufferedAmount = payload.byteLength + 40; + + expect(socket.bufferedAmount).toBe(payload.byteLength + 40); }); diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.ts b/packages/server/src/server/websocket/encrypted-relay-socket.ts index c225ed3ece4..3b76a497114 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.ts @@ -11,7 +11,7 @@ export interface EncryptedRelayChannel { export interface EncryptedRelaySocket { readonly readyState: number; readonly bufferedAmount: number; - send: (data: string | Uint8Array | ArrayBuffer) => void; + send: (data: string | Uint8Array | ArrayBuffer) => void | Promise; close: (code?: number, reason?: string) => void; terminate: () => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; @@ -26,7 +26,6 @@ export function createEncryptedRelaySocket(params: { }): EncryptedRelaySocket { const { channel, emitter, getTransportBufferedAmount, terminateTransport } = params; let readyState = 1; - let pendingEncryptedBytes = 0; channel.setState("open"); @@ -51,26 +50,25 @@ export function createEncryptedRelaySocket(params: { return readyState; }, get bufferedAmount() { - return pendingEncryptedBytes + (getTransportBufferedAmount() ?? 0); + return getTransportBufferedAmount() ?? 0; }, send: (data) => { - if (readyState !== 1) return; + if (readyState !== 1) { + return Promise.reject(new Error("Encrypted relay socket is not open")); + } const outbound = normalizeRelaySendPayload(data); const outboundBytes = channel.outboundWireByteLength(outbound); - const queuedBytes = pendingEncryptedBytes + (getTransportBufferedAmount() ?? 0); + const queuedBytes = getTransportBufferedAmount() ?? 0; if (queuedBytes + outboundBytes > MAX_PHYSICAL_SOCKET_BUFFERED_BYTES) { terminate(); - return; + return Promise.reject( + new Error("Encrypted relay socket exceeded its outbound high-water mark"), + ); } - pendingEncryptedBytes += outboundBytes; - void channel - .send(outbound) - .catch((error) => { - emitter.emit("error", error); - }) - .finally(() => { - pendingEncryptedBytes -= outboundBytes; - }); + return channel.send(outbound).catch((error) => { + emitter.emit("error", error); + throw error; + }); }, close, terminate, diff --git a/packages/server/src/server/websocket/physical-socket.test.ts b/packages/server/src/server/websocket/physical-socket.test.ts index 9b334cc112e..c81b1410d6d 100644 --- a/packages/server/src/server/websocket/physical-socket.test.ts +++ b/packages/server/src/server/websocket/physical-socket.test.ts @@ -4,6 +4,7 @@ import { ApplicationSocketLease, MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, sendBoundedPhysicalFrame, + sendBoundedPhysicalFrameAndWait, } from "./physical-socket.js"; test("sockets remain exempt until they send an application ping", () => { @@ -66,3 +67,56 @@ test("the shared physical send boundary rejects binary above the hard bound", () expect(sent).toEqual([]); expect(terminated).toBe(true); }); + +test("the awaitable physical send resolves only when that frame send completes", async () => { + const sent: Array = []; + let completeSend: (() => void) | undefined; + const socket = { + readyState: 1, + bufferedAmount: 0, + send: (_data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => { + sent.push(_data); + if (callback) completeSend = () => callback(); + }, + }; + let completed = false; + + const sending = sendBoundedPhysicalFrameAndWait({ + socket, + frame: new Uint8Array([1, 2, 3]), + onHighWater: () => undefined, + }).then(() => { + return (completed = true); + }); + + await Promise.resolve(); + expect(completed).toBe(false); + expect( + sendBoundedPhysicalFrame({ + socket, + frame: "unrelated", + onHighWater: () => undefined, + }), + ).toBe(true); + expect(sent).toEqual([new Uint8Array([1, 2, 3]), "unrelated"]); + completeSend?.(); + await sending; + expect(completed).toBe(true); +}); + +test("the awaitable physical send rejects callback errors", async () => { + const socket = { + readyState: 1, + bufferedAmount: 0, + send: (_data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => + callback?.(new Error("send failed")), + }; + + await expect( + sendBoundedPhysicalFrameAndWait({ + socket, + frame: new Uint8Array([1]), + onHighWater: () => undefined, + }), + ).rejects.toThrow("send failed"); +}); diff --git a/packages/server/src/server/websocket/physical-socket.ts b/packages/server/src/server/websocket/physical-socket.ts index eed29ae350c..491a7c612d5 100644 --- a/packages/server/src/server/websocket/physical-socket.ts +++ b/packages/server/src/server/websocket/physical-socket.ts @@ -50,7 +50,39 @@ export function outboundFrameByteLength(data: string | Uint8Array | ArrayBuffer) interface BoundedPhysicalSocket { readyState: number; bufferedAmount?: number; - send: (data: string | Uint8Array | ArrayBuffer) => void; + send: ( + data: string | Uint8Array | ArrayBuffer, + callback?: (error?: Error) => void, + ) => void | Promise; +} + +export async function sendBoundedPhysicalFrameAndWait(params: { + socket: BoundedPhysicalSocket; + frame: string | Uint8Array | ArrayBuffer; + frameBytes?: number; + onHighWater: () => void; +}): Promise { + const { socket, frame, frameBytes = outboundFrameByteLength(frame), onHighWater } = params; + if (socket.readyState !== 1) return false; + if (!physicalSocketHasCapacity(socket, frameBytes)) { + onHighWater(); + return false; + } + + await new Promise((resolve, reject) => { + let callbackUsed = false; + const result = socket.send(frame, (error) => { + callbackUsed = true; + if (error) reject(error); + else resolve(); + }); + if (result && typeof result.then === "function") { + result.then(resolve, reject); + } else if (socket.send.length < 2 && !callbackUsed) { + resolve(); + } + }); + return true; } export function physicalSocketHasCapacity( @@ -73,6 +105,9 @@ export function sendBoundedPhysicalFrame(params: { onHighWater(); return false; } - socket.send(frame); + const result = socket.send(frame); + if (result && typeof result.then === "function") { + void result.catch(() => undefined); + } return true; } From 2acb10fce96ff65eae0cad9894ff7338139d417b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 14:04:29 +0200 Subject: [PATCH 093/420] Keep provider settings above the model selector (#2476) * fix(app): keep provider settings above model selector Desktop web comboboxes used the browser top layer, so ordinary modal portals could not cover them. Keep web overlays in one relative layer stack while preserving native bottom-sheet stacking. * fix(app): route overlay keyboard ownership * fix(app): preserve nested overlay ownership * fix(app): route command center overlay input * fix(app): preserve global overlay ancestry * fix(app): register global dialog hosts --- docs/floating-panels.md | 23 ++ ...w-workspace-codex-mode-preferences.spec.ts | 6 +- .../app/e2e/provider-settings-refresh.spec.ts | 92 +++++- .../app/src/command-center/command-center.tsx | 68 +++-- .../src/components/adaptive-modal-sheet.tsx | 73 +++-- .../app/src/components/add-project-flow.tsx | 42 ++- packages/app/src/components/model-browser.tsx | 51 ++-- .../src/components/provider-settings-host.tsx | 18 +- packages/app/src/components/ui/combobox.tsx | 117 +++++--- .../app/src/components/ui/dropdown-menu.tsx | 85 +++--- packages/app/src/hosts/host-chooser.tsx | 50 ++-- packages/app/src/lib/overlay-root.ts | 261 +++++++++++++++++- .../stores/provider-settings-store.test.ts | 28 ++ .../app/src/stores/provider-settings-store.ts | 7 +- 14 files changed, 720 insertions(+), 201 deletions(-) create mode 100644 packages/app/src/stores/provider-settings-store.test.ts diff --git a/docs/floating-panels.md b/docs/floating-panels.md index d646dc609c2..12c91a04673 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -78,6 +78,29 @@ ordinary portals regardless of `z-index`, which would hide app toasts and tooltips behind the menu. The shared overlay scale keeps menus below toasts and lets tooltip portals paint above both. +The shared overlay scale is relative for interactive surfaces: a base floating +panel is below a base modal, while a floating panel rendered from inside a modal +inherits that modal's layer and paints above it. Wrap portal content in +`OverlayLayerProvider`; do not assign one global menu z-index. Desktop web +comboboxes must use `overlay-root` too. Rendering them through React Native +Web's `` puts them in the browser top layer, where no ordinary modal +portal can cover them. + +Painting and keyboard ownership use the same relative layer model. Register +desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the +highest painted scope alone receives overlay keys, traps focus, and restores +focus when it closes. Do not add component-local global Escape listeners: two +stacked overlays would both close on one keypress. + +If an overlay is rendered by a global host rather than beneath its opener in +the React tree, carry the opener's current layer through the host store and +restore it with `OverlayLayerProvider`. Otherwise painting and keyboard +ownership silently reset at the app root. When the opener is a global keyboard +action and has no component context to carry, resolve the host layer with +`useGlobalWebOverlayLayer` on its closed-to-open transition. It captures the +current top registered layer before the new host joins the stack; do not give a +global dialog a fixed root-derived modal layer. + ## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance A Portal escapes Android's hit-test, but it also escapes two things you were diff --git a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts index fa184ae0a16..b106810b844 100644 --- a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts @@ -100,11 +100,7 @@ async function selectMode(page: Page, label: string): Promise { await expect(searchInput).toBeVisible({ timeout: 10_000 }); await searchInput.fill(label); - const option = page - .getByRole("dialog") - .last() - .getByText(new RegExp(`^${escapeRegex(label)}$`, "i")) - .first(); + const option = popup.getByText(new RegExp(`^${escapeRegex(label)}$`, "i")).first(); await expect(option).toBeVisible({ timeout: 10_000 }); await option.click({ force: true }); await expect(searchInput).not.toBeVisible({ timeout: 5_000 }); diff --git a/packages/app/e2e/provider-settings-refresh.spec.ts b/packages/app/e2e/provider-settings-refresh.spec.ts index 14234cf8091..3ade9563434 100644 --- a/packages/app/e2e/provider-settings-refresh.spec.ts +++ b/packages/app/e2e/provider-settings-refresh.spec.ts @@ -1,3 +1,4 @@ +import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "./fixtures"; import { expectComposerVisible } from "./helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; @@ -63,6 +64,33 @@ async function closeSheetByHeaderButton(page: Page, testId: string) { await expect(sheet).not.toBeVisible({ timeout: 10_000 }); } +async function expectOverlayAbove(page: Page, frontTestId: string, backTestId: string) { + const frontCoversBack = await page.evaluate( + ({ frontTestId: frontId, backTestId: backId }) => { + const front = document.querySelector(`[data-testid="${frontId}"]`); + const back = document.querySelector(`[data-testid="${backId}"]`); + if (!(front instanceof HTMLElement) || !(back instanceof HTMLElement)) return false; + + const frontRect = front.getBoundingClientRect(); + const backRect = back.getBoundingClientRect(); + const left = Math.max(frontRect.left, backRect.left); + const right = Math.min(frontRect.right, backRect.right); + const top = Math.max(frontRect.top, backRect.top); + const bottom = Math.min(frontRect.bottom, backRect.bottom); + if (left >= right || top >= bottom) return false; + + const topElement = document.elementFromPoint((left + right) / 2, (top + bottom) / 2); + return topElement != null && front.contains(topElement); + }, + { frontTestId, backTestId }, + ); + expect(frontCoversBack).toBe(true); +} + +async function hasFocusWithin(locator: Locator): Promise { + return locator.evaluate((element) => element.contains(document.activeElement)); +} + async function expectProviderSettingsVisible(page: Page) { await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole("button", { name: "Add model" })).toBeVisible(); @@ -89,7 +117,69 @@ async function exerciseProviderSettingsStack(page: Page) { await expectProviderSettingsVisible(page); } -test.describe("provider settings bottom-sheet stack", () => { +test.describe("provider settings overlay stack", () => { + test("provider settings covers the desktop model selector without closing it", async ({ + page, + }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "provider-modal-layer-", + title: "Provider modal layer e2e", + }); + + try { + await openAgentRoute(page, session); + await expectComposerVisible(page); + + await page.getByRole("button", { name: /Select model/ }).click(); + const selector = page.getByTestId("combobox-desktop-container"); + await expect(selector).toBeVisible({ timeout: 10_000 }); + const searchInput = page.getByRole("textbox", { name: /search models/i }); + await expect(searchInput).toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect.poll(() => hasFocusWithin(selector)).toBe(true); + + await page.keyboard.press("Shift+?"); + const shortcuts = page.getByTestId("keyboard-shortcuts-dialog"); + await expect(shortcuts).toBeVisible({ timeout: 10_000 }); + await expect(page.getByPlaceholder("Search shortcuts")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(shortcuts).not.toBeVisible({ timeout: 10_000 }); + await expect(selector).toBeVisible(); + + await page.keyboard.press("ControlOrMeta+K"); + const commandCenter = page.getByTestId("command-center-panel"); + await expect(commandCenter).toBeVisible({ timeout: 10_000 }); + await expect(commandCenter.getByTestId("command-center-input")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(commandCenter).not.toBeVisible({ timeout: 10_000 }); + await expect(selector).toBeVisible(); + + await page.keyboard.press("ControlOrMeta+K"); + await expect(commandCenter).toBeVisible({ timeout: 10_000 }); + await commandCenter.getByText("Add project", { exact: true }).click(); + const addProject = page.getByTestId("add-project-flow"); + await expect(addProject).toBeVisible({ timeout: 10_000 }); + await expect(addProject.getByTestId("add-project-flow-input")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(addProject).not.toBeVisible({ timeout: 10_000 }); + await expect(selector).toBeVisible(); + + const settingsButton = page.getByTestId("selector-header-settings-mock"); + await settingsButton.click(); + + const settings = page.getByTestId("provider-settings-sheet"); + await expect(settings).toBeVisible({ timeout: 10_000 }); + await expectOverlayAbove(page, "provider-settings-sheet", "combobox-desktop-container"); + + await page.keyboard.press("Escape"); + await expect(settings).not.toBeVisible({ timeout: 10_000 }); + await expect(selector).toBeVisible(); + await expect(settingsButton).toBeFocused(); + } finally { + await session.cleanup(); + } + }); + test("provider settings and children close back through the model selector stack", async ({ page, }) => { diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 11d59048cb3..f66cbf254ad 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -31,6 +31,11 @@ import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregate import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useProjects } from "@/hooks/use-projects"; +import { + OverlayLayerProvider, + useGlobalWebOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; import { useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; @@ -372,15 +377,6 @@ function useCommandCenterState(): CommandCenterState { return cancel; }, [open]); - useEffect(() => { - if (!open || !isWeb) return; - const listener = (event: KeyboardEvent) => { - if (key(event.key)) event.preventDefault(); - }; - window.addEventListener("keydown", listener, true); - return () => window.removeEventListener("keydown", listener, true); - }, [key, open]); - return { open, query, @@ -553,6 +549,7 @@ export function CommandCenter() { const state = useCommandCenterState(); const isCompact = useIsCompactFormFactor(); const showBottomSheet = isCompact && isNative; + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet); const listRef = useRef>(null); const bottomSheetListRef = useRef(null); const bottomSheetInputRef = useRef>(null); @@ -618,6 +615,19 @@ export function CommandCenter() { [state], ); const submit = useCallback(() => state.key("Enter"), [state]); + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!state.key(event.key)) return false; + event.preventDefault(); + return true; + }, + [state], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && state.open && !showBottomSheet, + layer: modalLayer, + onKeyDown: handleWebOverlayKeyDown, + }); const backdrop = useCallback( (props: React.ComponentProps) => ( @@ -663,27 +673,29 @@ export function CommandCenter() { } if (!state.open) return null; return ( - - - - - - + + + + + + + + + - - - + + ); } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 07c157fa89c..8d06a218132 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -6,7 +6,12 @@ import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "r import type { StyleProp, TextInputProps, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; +import { + getOverlayRoot, + OverlayLayerProvider, + useGlobalWebOverlayLayer, + useWebOverlayRegistration, +} from "../lib/overlay-root"; import { BottomSheetBackdrop, BottomSheetScrollView, @@ -54,36 +59,8 @@ export interface SheetHeader { search?: SheetHeaderSearch; } -type EscHandler = () => void; -const escStack: EscHandler[] = []; -let escListenerAttached = false; const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject }; -function handleEscKeyDown(event: KeyboardEvent) { - if (event.key !== "Escape") return; - const top = escStack[escStack.length - 1]; - if (!top) return; - event.stopPropagation(); - event.preventDefault(); - top(); -} - -function pushEscHandler(handler: EscHandler): () => void { - escStack.push(handler); - if (!escListenerAttached && typeof window !== "undefined") { - window.addEventListener("keydown", handleEscKeyDown, true); - escListenerAttached = true; - } - return () => { - const index = escStack.lastIndexOf(handler); - if (index !== -1) escStack.splice(index, 1); - if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") { - window.removeEventListener("keydown", handleEscKeyDown, true); - escListenerAttached = false; - } - }; -} - const styles = StyleSheet.create((theme) => ({ desktopOverlay: { ...StyleSheet.absoluteFillObject, @@ -91,7 +68,6 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", alignItems: "center", padding: theme.spacing[6], - zIndex: OVERLAY_Z.modal, pointerEvents: "auto" as const, }, desktopCard: { @@ -582,6 +558,7 @@ export function AdaptiveModalSheet({ }); const [shouldRenderWeb, setShouldRenderWeb] = useState(visible); const [isWebClosing, setIsWebClosing] = useState(false); + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb); const nativeModalDismissNotifiedRef = useRef(!visible); const handleDismiss = useCallback(() => { handleSheetDismiss(); @@ -610,19 +587,31 @@ export function AdaptiveModalSheet({ () => [ styles.desktopOverlay, isWeb && { + zIndex: modalLayer, opacity: isWebClosing ? 0 : 1, transitionDuration: `${WEB_EXIT_DURATION_MS}ms`, transitionProperty: "opacity", transitionTimingFunction: "ease", }, ], - [isWebClosing], + [isWebClosing, modalLayer], ); - useEffect(() => { - if (!isWeb || isMobile || !visible) return; - return pushEscHandler(onClose); - }, [visible, isMobile, onClose]); + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== "Escape") return false; + event.preventDefault(); + event.stopPropagation(); + onClose(); + return true; + }, + [onClose], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && !isMobile && visible, + layer: modalLayer, + onKeyDown: handleWebOverlayKeyDown, + }); useEffect(() => { if (visible) { @@ -700,7 +689,7 @@ export function AdaptiveModalSheet({ } const cardInner = ( - <> + {scrollable ? ( @@ -717,7 +706,7 @@ export function AdaptiveModalSheet({ {children} )} {footer ? {footer} : null} - + ); const desktopContent = ( @@ -727,7 +716,15 @@ export function AdaptiveModalSheet({ style={ABSOLUTE_FILL_STYLE} onPress={onClose} /> - {cardInner} + + {cardInner} + ); diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index cfdda3d86da..3078062a779 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -11,7 +11,15 @@ import { Search, Server, } from "lucide-react-native"; -import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react"; +import { + createElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, +} from "react"; import { Modal, Pressable, @@ -66,6 +74,11 @@ import { useFetchQuery } from "@/data/query"; import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project"; import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project"; +import { + OverlayLayerProvider, + useGlobalWebOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; import { useHosts, useHostRuntimeClient, @@ -769,14 +782,20 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { [activeIndex, handleBack, rows, submitActive], ); - useEffect(() => { - if (!isWeb || typeof window === "undefined") return; - const listener = (event: KeyboardEvent) => { - if (handleKey(event.key)) event.preventDefault(); - }; - window.addEventListener("keydown", listener, true); - return () => window.removeEventListener("keydown", listener, true); - }, [handleKey]); + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb); + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!handleKey(event.key)) return false; + event.preventDefault(); + return true; + }, + [handleKey], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb, + layer: modalLayer, + onKeyDown: handleWebOverlayKeyDown, + }); const handleNativeKeyPress = useCallback( ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => { @@ -816,11 +835,12 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { ? joinDirectoryPath(page.parentPath, page.name.trim()) : null; - return ( + const modal = ( ); + + return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal); } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index 7f656c8c4da..e9c34b0ec8a 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -34,6 +34,7 @@ import { type ProviderSelectorProvider, } from "@/provider-selection/provider-selection"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; +import { useCurrentOverlayLayer } from "@/lib/overlay-root"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { resolveInitialModelBrowserView, @@ -53,6 +54,36 @@ const ThemedSearch = withUnistyles(Search); const ThemedSettings = withUnistyles(Settings); const ThemedStar = withUnistyles(Star); +function ProviderSettingsAction({ + accessibilityLabel, + provider, + serverId, +}: { + accessibilityLabel: string; + provider: string; + serverId: string | null; +}) { + const overlayParentLayer = useCurrentOverlayLayer(); + const handlePress = useCallback(() => { + if (!serverId) return; + useProviderSettingsStore.getState().open({ serverId, provider, overlayParentLayer }); + }, [overlayParentLayer, provider, serverId]); + + return ( + + + + ); +} + const IndependentScrollGestureContext = createContext | null>( null, ); @@ -237,11 +268,6 @@ export function useModelBrowser({ setSearchQuery(value); }, []); - const openProviderSettings = useCallback(() => { - if (!serverId || view.kind !== "provider") return; - useProviderSettingsStore.getState().open({ serverId, provider: view.providerId }); - }, [serverId, view]); - const singleProviderView = providers.length === 1; const header = useMemo(() => { if (view.kind === "all") { @@ -254,19 +280,13 @@ export function useModelBrowser({ ), back: singleProviderView ? undefined : { onPress: handleBackToAll }, actions: ( - - - + /> ), search: { onChange: handleSearchQueryChange, @@ -279,7 +299,6 @@ export function useModelBrowser({ }, [ handleBackToAll, handleSearchQueryChange, - openProviderSettings, searchResetKey, serverId, singleProviderView, diff --git a/packages/app/src/components/provider-settings-host.tsx b/packages/app/src/components/provider-settings-host.tsx index 7518fc28f5d..f6ff75c74e0 100644 --- a/packages/app/src/components/provider-settings-host.tsx +++ b/packages/app/src/components/provider-settings-host.tsx @@ -1,11 +1,13 @@ import { useCallback } from "react"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; +import { OverlayLayerProvider } from "@/lib/overlay-root"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; export function ProviderSettingsHost() { const serverId = useProviderSettingsStore((state) => state.serverId); const provider = useProviderSettingsStore((state) => state.provider); const visible = useProviderSettingsStore((state) => state.visible); + const overlayParentLayer = useProviderSettingsStore((state) => state.overlayParentLayer); const close = useProviderSettingsStore((state) => state.close); const handleClose = useCallback(() => { @@ -17,12 +19,14 @@ export function ProviderSettingsHost() { } return ( - + + + ); } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 2d862a13359..50f328787a5 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -23,6 +23,7 @@ import { type StyleProp, type ViewStyle, } from "react-native"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -62,6 +63,12 @@ import { } from "@/components/adaptive-modal-sheet"; import { FloatingSurface } from "@/components/ui/floating"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; +import { + getOverlayRoot, + OverlayLayerProvider, + useOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; import { buildDesktopFrameStyle } from "./combobox-frame-style"; export { buildDesktopFrameStyle } from "./combobox-frame-style"; @@ -863,45 +870,31 @@ function isDesktopKey(key: string): key is DesktopKey { return key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape"; } -function useWebKeyboardListener( - isOpen: boolean, - handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => void, -) { - useEffect(() => { - if (!IS_WEB || !isOpen) return; - - const handler = (event: KeyboardEvent) => { - if (!isDesktopKey(event.key)) return; - handleDesktopKey(event.key, event); - }; - - // react-native-web's TextInput can stop propagation on key events, so listen in capture phase. - window.addEventListener("keydown", handler, true); - return () => { - window.removeEventListener("keydown", handler, true); - }; - }, [handleDesktopKey, isOpen]); -} - -function dispatchDesktopKey(input: DesktopKeyHandlerInput, key: DesktopKey, event?: KeyboardEvent) { - if (!input.isOpen) return; - if (!IS_WEB && input.isMobile) return; +function dispatchDesktopKey( + input: DesktopKeyHandlerInput, + key: DesktopKey, + event?: KeyboardEvent, +): boolean { + if (!input.isOpen) return false; + if (!IS_WEB && input.isMobile) return false; if (key === "ArrowDown" || key === "ArrowUp") { event?.preventDefault(); handleDesktopArrowKey(input, key); - return; + return true; } if (key === "Enter") { - if (input.orderedVisibleOptions.length === 0) return; + if (input.orderedVisibleOptions.length === 0) return false; event?.preventDefault(); handleDesktopEnterKey(input); - return; + return true; } if (key === "Escape") { event?.preventDefault(); input.handleClose(); + return true; } + return false; } function resolveInitialActiveIndex( @@ -1050,8 +1043,10 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { } interface DesktopBodyProps { + overlayLayer: number; isOpen: boolean; handleClose: () => void; + handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean; refs: ReturnType["refs"]; shouldUseDesktopFade: boolean; desktopFrameStyle: StyleProp; @@ -1166,14 +1161,38 @@ function DesktopComboboxOptionsBody(props: { } function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { - return ( - - + const handleDesktopKey = props.handleDesktopKey; + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!isDesktopKey(event.key)) return false; + return handleDesktopKey(event.key, event); + }, + [handleDesktopKey], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && props.isOpen, + layer: props.overlayLayer, + onKeyDown: handleWebOverlayKeyDown, + }); + const setFloatingRef = useCallback( + (node: View | null) => { + props.refs.setFloating(node); + setWebOverlayScope(node); + }, + [props.refs, setWebOverlayScope], + ); + + const overlay = ( + + {props.hasChildren ? ( @@ -1215,6 +1237,21 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { {props.footer ? {props.footer} : null} + + ); + + if (isWeb && typeof document !== "undefined") { + return createPortal(overlay, getOverlayRoot()); + } + + return ( + + {overlay} ); } @@ -1257,6 +1294,7 @@ export function Combobox({ const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch"); const resolvedTitle = title ?? t("common.actions.select"); const isMobile = useIsCompactFormFactor(); + const floatingLayer = useOverlayLayer("floating"); const safeAreaInsets = useSafeAreaInsets(); const titleColor = theme.colors.foreground; const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition); @@ -1462,7 +1500,7 @@ export function Combobox({ const handleDesktopKey = useCallback( (key: DesktopKey, event?: KeyboardEvent) => { - dispatchDesktopKey( + return dispatchDesktopKey( { isOpen, isMobile, @@ -1479,7 +1517,6 @@ export function Combobox({ [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions], ); - useWebKeyboardListener(isOpen, handleDesktopKey); useDismissKeyboardOnOpen(isOpen, isMobile); const handleIndicatorStyle = useMemo( @@ -1556,8 +1593,10 @@ export function Combobox({ return ( ({ desktopOverlay: { flex: 1, }, + desktopOverlayWeb: { + ...StyleSheet.absoluteFillObject, + pointerEvents: "auto" as const, + }, desktopBackdrop: { position: "absolute", top: 0, diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 8c8badfa030..0f21bdb2365 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -34,7 +34,12 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { isWeb } from "@/constants/platform"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; -import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; +import { + getOverlayRoot, + OverlayLayerProvider, + useOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; // Action status for menu items with loading/success feedback export type ActionStatus = "idle" | "pending" | "success"; @@ -185,6 +190,7 @@ function renderDropdownSurface(input: { content: ReactElement; surfaceNativeID: string; onExited: () => void; + scopeRef: (node: View | null) => void; }): ReactElement { const { frameStyle, @@ -195,6 +201,7 @@ function renderDropdownSurface(input: { content, surfaceNativeID, onExited, + scopeRef, } = input; const body = scrollable ? ( @@ -212,7 +219,9 @@ function renderDropdownSurface(input: { return ( ): ReactElement | null { const { t } = useTranslation(); + const floatingLayer = useOverlayLayer("floating"); const { open, setOpen, triggerRef, flushPendingSelect } = useDropdownMenuContext("DropdownMenuContent"); const [modalVisible, setModalVisible] = useState(false); @@ -502,16 +512,21 @@ export function DropdownMenuContent({ setOpen(false); }, [setOpen]); - useEffect(() => { - if (!isWeb || !modalVisible || typeof window === "undefined") return undefined; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== "Escape") return false; + event.preventDefault(); event.stopPropagation(); handleClose(); - }; - window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [handleClose, modalVisible]); + return true; + }, + [handleClose], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && modalVisible, + layer: floatingLayer, + onKeyDown: handleWebOverlayKeyDown, + }); // Measure trigger when opening useEffect(() => { @@ -631,27 +646,36 @@ export function DropdownMenuContent({ ); const overlay = ( - - - {!closing - ? renderDropdownSurface({ - frameStyle, - testID, - surfaceStyle, - scrollable, - scrollViewportStyle, - content, - surfaceNativeID, - onExited: () => setModalVisible(false), - }) - : null} - + + + + {!closing + ? renderDropdownSurface({ + frameStyle, + testID, + surfaceStyle, + scrollable, + scrollViewportStyle, + content, + surfaceNativeID, + scopeRef: setWebOverlayScope, + onExited: () => setModalVisible(false), + }) + : null} + + ); if (isWeb && typeof document !== "undefined") { @@ -905,7 +929,6 @@ const styles = StyleSheet.create((theme) => ({ }, overlayWeb: { ...StyleSheet.absoluteFillObject, - zIndex: OVERLAY_Z.modal, pointerEvents: "auto" as const, }, backdrop: { diff --git a/packages/app/src/hosts/host-chooser.tsx b/packages/app/src/hosts/host-chooser.tsx index 4f8debd14e2..d0724e9cfc9 100644 --- a/packages/app/src/hosts/host-chooser.tsx +++ b/packages/app/src/hosts/host-chooser.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Modal, Pressable, @@ -13,8 +13,13 @@ import { Server } from "lucide-react-native"; import { create } from "zustand"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { HostStatusDotSlot } from "@/components/hosts/host-picker"; -import { isNative } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; +import { + OverlayLayerProvider, + useGlobalWebOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; import { useHosts } from "@/runtime/host-runtime"; import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection"; import { buildSettingsAddHostRoute } from "@/utils/host-routes"; @@ -143,6 +148,7 @@ export function HostChooserModal() { const inputRef = useRef(null); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); + const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && request != null); const requestHosts = useMemo(() => { if (!request) return []; @@ -182,58 +188,60 @@ export function HostChooserModal() { [close, request], ); - useEffect(() => { - if (!request || isNative || typeof window === "undefined") return; - - const handleKeyDown = (event: KeyboardEvent) => { + const handleWebOverlayKeyDown = useCallback( + (event: KeyboardEvent) => { if ( event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter" && event.key !== "Escape" ) { - return; + return false; } if (event.key === "Escape") { event.preventDefault(); close(); - return; + return true; } if (event.key === "Enter") { const host = options[activeOptionIndex]; - if (!host) return; + if (!host) return false; event.preventDefault(); chooseHost(host.serverId); - return; + return true; } - if (options.length === 0) return; + if (options.length === 0) return false; event.preventDefault(); const next = event.key === "ArrowDown" ? activeOptionIndex + 1 : activeOptionIndex - 1; if (next < 0) { setActiveIndex(options.length - 1); - return; + return true; } if (next >= options.length) { setActiveIndex(0); - return; + return true; } setActiveIndex(next); - }; - - window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [activeOptionIndex, chooseHost, close, options, request]); + return true; + }, + [activeOptionIndex, chooseHost, close, options], + ); + const setWebOverlayScope = useWebOverlayRegistration({ + active: isWeb && request != null, + layer: modalLayer, + onKeyDown: handleWebOverlayKeyDown, + }); if (!request) return null; - return ( + const modal = ( - + {request.title} ); + + return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal); } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index 95b4e4d2cf3..9b19b882526 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -1,11 +1,27 @@ +import { + createContext, + createElement, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + type ReactNode, +} from "react"; + /** * Shared overlay root for web portals (modals, toasts, etc.) * This ensures consistent stacking order by controlling a single overlay container. * * Z-index scale within overlay root: - * - Modal backdrop/content: 10 - * - Toast: 20 - * - Tooltip: 30 + * - Floating panel: parent layer + 10 + * - Modal: parent layer + 20 + * - Toast: 10,000 + * - Tooltip: 20,000 + * + * Floating panels and modals provide their resolved layer to descendants. A + * dropdown opened inside a modal therefore paints above that modal, while a + * base dropdown remains below a modal opened over it. */ export function getOverlayRoot(): HTMLElement { let el = document.getElementById("overlay-root"); @@ -21,7 +37,240 @@ export function getOverlayRoot(): HTMLElement { } export const OVERLAY_Z = { - modal: 10, - toast: 20, - tooltip: 30, + floating: 10, + modal: 20, + toast: 10_000, + tooltip: 20_000, } as const; + +type OverlayKind = "floating" | "modal"; + +const OverlayLayerContext = createContext(0); + +export function useOverlayLayer(kind: OverlayKind): number { + return useContext(OverlayLayerContext) + OVERLAY_Z[kind]; +} + +/** + * Resolves a globally hosted web overlay above whichever registered overlay + * was topmost when it opened. Global hosts live outside their opener's React + * tree, so context alone cannot preserve that relative ownership. + */ +export function useGlobalWebOverlayLayer(kind: OverlayKind, active: boolean): number { + const contextualLayer = useOverlayLayer(kind); + return useMemo(() => { + if (!active) return contextualLayer; + const topLayer = getTopWebOverlay()?.getLayer() ?? 0; + return Math.max(contextualLayer, topLayer + OVERLAY_Z[kind]); + }, [active, contextualLayer, kind]); +} + +export function useCurrentOverlayLayer(): number { + return useContext(OverlayLayerContext); +} + +export function OverlayLayerProvider({ layer, children }: { layer: number; children?: ReactNode }) { + return createElement(OverlayLayerContext.Provider, { value: layer }, children); +} + +type WebOverlayKeyHandler = (event: KeyboardEvent) => boolean; + +interface WebOverlayEntry { + id: symbol; + order: number; + getLayer: () => number; + getScope: () => HTMLElement | null; + getKeyHandler: () => WebOverlayKeyHandler; + restoreFocus: HTMLElement | null; +} + +const webOverlayEntries: WebOverlayEntry[] = []; +let webOverlayOrder = 0; +let webOverlayListenersAttached = false; +let webOverlayFocusCheckQueued = false; + +function getTopWebOverlay(): WebOverlayEntry | undefined { + return webOverlayEntries.reduce((top, entry) => { + if (!top) return entry; + const layerDifference = entry.getLayer() - top.getLayer(); + return layerDifference > 0 || (layerDifference === 0 && entry.order > top.order) ? entry : top; + }, undefined); +} + +function getFocusableElements(scope: HTMLElement): HTMLElement[] { + const selector = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', + ].join(","); + + return Array.from(scope.querySelectorAll(selector)).filter( + (element) => + element.getAttribute("aria-hidden") !== "true" && + !element.hasAttribute("hidden") && + element.getClientRects().length > 0, + ); +} + +function focusFirstElement(scope: HTMLElement): void { + const first = getFocusableElements(scope)[0]; + (first ?? scope).focus(); +} + +function handleWebOverlayFocus(event: FocusEvent): void { + const top = getTopWebOverlay(); + const scope = top?.getScope(); + if (!scope || scope.contains(event.target as Node)) return; + if (webOverlayFocusCheckQueued) return; + + // React can autofocus a child before its parent scope ref attaches. Defer + // enforcement until the commit finishes so a newly mounted higher overlay + // can register without the previous scope stealing the requested focus. + webOverlayFocusCheckQueued = true; + queueMicrotask(() => { + webOverlayFocusCheckQueued = false; + const currentScope = getTopWebOverlay()?.getScope(); + if (!currentScope || currentScope.contains(document.activeElement)) return; + focusFirstElement(currentScope); + }); +} + +function handleWebOverlayKeyDown(event: KeyboardEvent): void { + const top = getTopWebOverlay(); + const scope = top?.getScope(); + if (!top || !scope) return; + + if (event.key === "Tab") { + const focusable = getFocusableElements(scope); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + const shouldWrapBackward = event.shiftKey && (!scope.contains(active) || active === first); + const shouldWrapForward = !event.shiftKey && (!scope.contains(active) || active === last); + if (shouldWrapBackward || shouldWrapForward) { + event.preventDefault(); + event.stopImmediatePropagation(); + (shouldWrapBackward ? last : first)?.focus(); + if (!first) scope.focus(); + return; + } + } + + if (!top.getKeyHandler()(event)) return; + event.stopImmediatePropagation(); +} + +function attachWebOverlayListeners(): void { + if (webOverlayListenersAttached) return; + window.addEventListener("keydown", handleWebOverlayKeyDown, true); + document.addEventListener("focusin", handleWebOverlayFocus, true); + webOverlayListenersAttached = true; +} + +function detachWebOverlayListeners(): void { + if (!webOverlayListenersAttached || webOverlayEntries.length > 0) return; + window.removeEventListener("keydown", handleWebOverlayKeyDown, true); + document.removeEventListener("focusin", handleWebOverlayFocus, true); + webOverlayListenersAttached = false; +} + +function addWebOverlay(entry: WebOverlayEntry): () => void { + webOverlayEntries.push(entry); + attachWebOverlayListeners(); + + const focusFrame = window.requestAnimationFrame(() => { + const scope = entry.getScope(); + if (getTopWebOverlay() === entry && scope && !scope.contains(document.activeElement)) { + focusFirstElement(scope); + } + }); + + return () => { + window.cancelAnimationFrame(focusFrame); + const index = webOverlayEntries.findIndex((candidate) => candidate.id === entry.id); + if (index !== -1) webOverlayEntries.splice(index, 1); + detachWebOverlayListeners(); + if (entry.restoreFocus && document.contains(entry.restoreFocus)) { + entry.restoreFocus.focus(); + } + }; +} + +interface WebOverlayRegistration { + active: boolean; + layer: number; + onKeyDown: WebOverlayKeyHandler; +} + +/** + * Registers a focus scope in the same relative layer model used for painting. + * Only the highest painted overlay receives keyboard input; focus is trapped + * there and restored to the opener when that overlay closes. + */ +export function useWebOverlayRegistration({ active, layer, onKeyDown }: WebOverlayRegistration) { + const idRef = useRef(Symbol("web-overlay")); + const scopeRef = useRef(null); + const layerRef = useRef(layer); + const keyHandlerRef = useRef(onKeyDown); + const restoreFocusRef = useRef(null); + const removeEntryRef = useRef<(() => void) | null>(null); + const activeRef = useRef(active); + const wasActiveRef = useRef(false); + + activeRef.current = active; + layerRef.current = layer; + keyHandlerRef.current = onKeyDown; + if (active && !wasActiveRef.current && typeof document !== "undefined") { + restoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + wasActiveRef.current = active; + + const syncRegistration = useCallback(() => { + const shouldRegister = + activeRef.current && + scopeRef.current != null && + typeof window !== "undefined" && + typeof document !== "undefined"; + if (!shouldRegister) { + removeEntryRef.current?.(); + removeEntryRef.current = null; + return; + } + if (removeEntryRef.current) return; + + const entry: WebOverlayEntry = { + id: idRef.current, + order: ++webOverlayOrder, + getLayer: () => layerRef.current, + getScope: () => scopeRef.current, + getKeyHandler: () => keyHandlerRef.current, + restoreFocus: restoreFocusRef.current, + }; + removeEntryRef.current = addWebOverlay(entry); + }, []); + + const setScope = useCallback( + (node: unknown) => { + scopeRef.current = + typeof HTMLElement !== "undefined" && node instanceof HTMLElement ? node : null; + // Host refs attach before descendant layout effects and autofocus. Register + // here so the previous overlay cannot redirect that pending focus. + syncRegistration(); + }, + [syncRegistration], + ); + + useLayoutEffect(() => { + syncRegistration(); + return () => { + removeEntryRef.current?.(); + removeEntryRef.current = null; + }; + }, [active, syncRegistration]); + + return setScope; +} diff --git a/packages/app/src/stores/provider-settings-store.test.ts b/packages/app/src/stores/provider-settings-store.test.ts new file mode 100644 index 00000000000..ca3dc41e7a2 --- /dev/null +++ b/packages/app/src/stores/provider-settings-store.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { useProviderSettingsStore } from "./provider-settings-store"; + +describe("provider settings store", () => { + afterEach(() => { + useProviderSettingsStore.setState({ + serverId: null, + provider: null, + overlayParentLayer: 0, + visible: false, + }); + }); + + it("carries the opener layer without leaking it into later base-level opens", () => { + useProviderSettingsStore.getState().open({ + serverId: "server-1", + provider: "codex", + overlayParentLayer: 30, + }); + expect(useProviderSettingsStore.getState().overlayParentLayer).toBe(30); + + useProviderSettingsStore.getState().open({ + serverId: "server-1", + provider: "claude", + }); + expect(useProviderSettingsStore.getState().overlayParentLayer).toBe(0); + }); +}); diff --git a/packages/app/src/stores/provider-settings-store.ts b/packages/app/src/stores/provider-settings-store.ts index abd214c5a46..4529f7e3ec4 100644 --- a/packages/app/src/stores/provider-settings-store.ts +++ b/packages/app/src/stores/provider-settings-store.ts @@ -3,11 +3,13 @@ import { create } from "zustand"; interface ProviderSettingsTarget { serverId: string; provider: string; + overlayParentLayer?: number; } interface ProviderSettingsStoreState { serverId: string | null; provider: string | null; + overlayParentLayer: number; visible: boolean; open: (target: ProviderSettingsTarget) => void; close: () => void; @@ -16,9 +18,10 @@ interface ProviderSettingsStoreState { export const useProviderSettingsStore = create()((set) => ({ serverId: null, provider: null, + overlayParentLayer: 0, visible: false, - open: ({ serverId, provider }) => { - set({ serverId, provider, visible: true }); + open: ({ serverId, provider, overlayParentLayer = 0 }) => { + set({ serverId, provider, overlayParentLayer, visible: true }); }, close: () => { set({ visible: false }); From 5d397754bd905da6fab15761c1ae6a33454b2681 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 19:43:09 +0200 Subject: [PATCH 094/420] docs: add 0.2.3 changelog --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc541a62018..48f70b7041f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 0.2.3 - 2026-07-27 + +### Added + +- Manage workspace scripts from the CLI and agent MCP tools ([#1992](https://github.com/getpaseo/paseo/pull/1992) by [@mcowger](https://github.com/mcowger)) +- Copy terminal IDs from terminal tab menus ([#2371](https://github.com/getpaseo/paseo/pull/2371)) +- Long Markdown lines wrap by default in the file editor ([#2459](https://github.com/getpaseo/paseo/pull/2459)) + +### Improved + +- Desktop stops its managed daemon when you quit unless “Keep daemon running after quit” is enabled ([#2454](https://github.com/getpaseo/paseo/pull/2454)) +- Remote terminal and file traffic uses less bandwidth over encrypted connections ([#2480](https://github.com/getpaseo/paseo/pull/2480)) +- Workspace search now shows and matches project names ([#2345](https://github.com/getpaseo/paseo/pull/2345) by [@cleiter](https://github.com/cleiter)) +- Claude usage shows model-specific weekly limits ([#2303](https://github.com/getpaseo/paseo/pull/2303) by [@cleiter](https://github.com/cleiter)) +- OMP models show only the thinking levels they support ([#2171](https://github.com/getpaseo/paseo/pull/2171) by [@bendavid](https://github.com/bendavid)) + +### Fixed + +- Image uploads preserve the correct image format ([#2380](https://github.com/getpaseo/paseo/pull/2380)) +- Large file views no longer disconnect the session ([#2482](https://github.com/getpaseo/paseo/pull/2482)) +- Reaching the top of a chat loads the complete older history ([#2481](https://github.com/getpaseo/paseo/pull/2481)) +- Parent agents stay available while child agents are working ([#2458](https://github.com/getpaseo/paseo/pull/2458)) +- Stale client connections no longer exhaust daemon memory ([#2169](https://github.com/getpaseo/paseo/pull/2169)) +- Pin and unpin shortcuts work when sidebar sections are collapsed ([#2299](https://github.com/getpaseo/paseo/pull/2299) by [@cleiter](https://github.com/cleiter)) +- `Shift+Tab` no longer changes a background agent’s permission mode ([#1848](https://github.com/getpaseo/paseo/pull/1848) by [@cleiter](https://github.com/cleiter)) +- Proxied services preserve ports in redirects ([#2288](https://github.com/getpaseo/paseo/pull/2288) by [@cleiter](https://github.com/cleiter)) +- Provider settings open correctly above the model selector ([#2476](https://github.com/getpaseo/paseo/pull/2476)) +- Clicking the file editor correctly focuses its pane ([#2457](https://github.com/getpaseo/paseo/pull/2457)) + ## 0.2.2 - 2026-07-25 ### Fixed From 43cf858c3760679ec9be805ba8b903cdf20f7103 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 19:46:38 +0200 Subject: [PATCH 095/420] chore(release): cut 0.2.3 --- package-lock.json | 42 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++--- packages/client/package.json | 6 ++-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 +++--- packages/website/package.json | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 458b1be374b..6f56e046bfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.2.2", + "version": "0.2.3", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35211,7 +35211,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36236,12 +36236,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.2", - "@getpaseo/protocol": "0.2.2", - "@getpaseo/server": "0.2.2", + "@getpaseo/client": "0.2.3", + "@getpaseo/protocol": "0.2.3", + "@getpaseo/server": "0.2.3", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36488,10 +36488,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { - "@getpaseo/protocol": "0.2.2", - "@getpaseo/relay": "0.2.2", + "@getpaseo/protocol": "0.2.3", + "@getpaseo/relay": "0.2.3", "zod": "^4.4.3" }, "devDependencies": { @@ -36502,7 +36502,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.2.2", + "version": "0.2.3", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36745,7 +36745,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.2", + "version": "0.2.3", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37641,7 +37641,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -37873,7 +37873,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "zod": "^4.4.3" }, @@ -37886,7 +37886,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38104,15 +38104,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.2", - "@getpaseo/highlight": "0.2.2", - "@getpaseo/protocol": "0.2.2", - "@getpaseo/relay": "0.2.2", + "@getpaseo/client": "0.2.3", + "@getpaseo/highlight": "0.2.3", + "@getpaseo/protocol": "0.2.3", + "@getpaseo/relay": "0.2.3", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38649,7 +38649,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 2dfe2723d6f..a477e4ac551 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.2.2", + "version": "0.2.3", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index ffa7d475dd1..3936b7db52d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.2.2", + "version": "0.2.3", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index c3ab5b8db72..01e3c7238c9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.2.2", + "version": "0.2.3", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.2", - "@getpaseo/protocol": "0.2.2", - "@getpaseo/server": "0.2.2", + "@getpaseo/client": "0.2.3", + "@getpaseo/protocol": "0.2.3", + "@getpaseo/server": "0.2.3", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 4988a509b85..d6993a4962c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.2.2", + "version": "0.2.3", "description": "Paseo client SDK package", "files": [ "dist", @@ -35,8 +35,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.2.2", - "@getpaseo/relay": "0.2.2", + "@getpaseo/protocol": "0.2.3", + "@getpaseo/relay": "0.2.3", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 8fd412ef813..c6e97aa1007 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.2.2", + "version": "0.2.3", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 2974d5db00d..3db50c01804 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.2", + "version": "0.2.3", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index cf8eab5508d..3b0aa347862 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.2.2", + "version": "0.2.3", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index ba5e2323293..9455c455d37 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.2.2", + "version": "0.2.3", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index a156443d51e..86cd6a05a3e 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.2.2", + "version": "0.2.3", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 9e5f9186c6f..479c8fcb6ed 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.2.2", + "version": "0.2.3", "description": "Paseo backend server", "files": [ "dist/server", @@ -67,10 +67,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.2", - "@getpaseo/highlight": "0.2.2", - "@getpaseo/protocol": "0.2.2", - "@getpaseo/relay": "0.2.2", + "@getpaseo/client": "0.2.3", + "@getpaseo/highlight": "0.2.3", + "@getpaseo/protocol": "0.2.3", + "@getpaseo/relay": "0.2.3", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 7574ece0f3c..2a731595115 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.2.2", + "version": "0.2.3", "private": true, "type": "module", "scripts": { From 1f253d92e2175ce24b13e2ffc5919be68f9b1304 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:00:50 +0000 Subject: [PATCH 096/420] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index cd0ec1a6ffb..73a04d18624 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-DaVBk1PxsNr1AQu/mTG1Inp67pLLDmc+Ed0IZ5GyzgY= +sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc= From fa1198c2be5cb0617501815f0295365ccc399d61 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 21:27:10 +0200 Subject: [PATCH 097/420] Stop completed turns from appearing stuck (#2484) * fix(app): stop completed turns from appearing stuck Turn activity was inferred from a user-message row flag, so a stale duplicate row could keep the working footer active after the turn ended. Track submission lifecycle separately and route every send path through the shared submission flow. * fix(app): guard pending message identity * test(app): measure submission layout independent of scroll * fix(app): keep submitted messages consistent through reconnects Track each in-flight send until its own RPC establishes acceptance, and keep canonical timeline placement authoritative. Restore legacy cached rewind IDs during cache deserialization. * fix(app): close canonical submission races * test(app): cover canonical submission races in browser * fix(app): keep replacement submissions authoritative Keep current pending rows out of legacy cache migration and leave ambiguous terminal lifecycle events to the daemon snapshot. This prevents fabricated rewind identities and stale completion events from marking replacement turns idle. * fix(app): keep submitted messages stable across sync Give submission transport and canonical timeline ingestion separate authority. Preserve every unresolved local send across replacement, reconcile provider identity once, and bridge RPC acceptance to authoritative running state without deriving lifecycle from timeline rows. * fix(app): settle submissions in either acknowledgement order Complete submission transactions when RPC and provider acknowledgement arrive in either order. Cache only transaction-owned local rows as transient data, preserve canonical ID-less prompts, and invalidate ambiguous legacy display caches instead of inventing provider identity. * fix(app): keep agent visible during history handoff Running and terminal updates could clear create continuity before the initial authoritative timeline arrived, leaving a streaming agent behind the loading screen. End the handoff only when authoritative history is applied. * fix(app): settle attachment-only submissions Canonical providers can acknowledge image-only prompts with empty text. Reconcile those events by client identity without rendering a blank canonical row. * fix(agent): settle out-of-band message submissions Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer. * fix(app): settle out-of-band submissions compatibly * fix(app): preserve canonical prompt order * test(app): keep workspace status check timing-independent The workspace-status scenario asserted footer settlement before initial agent creation had necessarily completed. The dedicated draft-handoff coverage owns that lifecycle contract. --- docs/timeline-sync.md | 40 +- .../app/e2e/agent-message-submission.spec.ts | 685 ++++++++++++++++++ .../app/e2e/helpers/agent-message-gate.ts | 86 +++ .../app/e2e/helpers/daemon-websocket-gate.ts | 308 +++++++- .../out-of-band-command.codex.real.spec.ts | 52 ++ .../app/e2e/rewind-menu.ui-contract.spec.ts | 146 ++++ packages/app/src/agent-stream/view.tsx | 22 +- packages/app/src/components/message.tsx | 18 +- .../rewind/use-rewind-agent-mutation.ts | 8 - packages/app/src/composer/actions.test.ts | 151 +++- packages/app/src/composer/actions.ts | 70 +- .../src/composer/draft/create-flow.test.ts | 6 +- .../app/src/composer/draft/create-flow.ts | 25 +- .../app/src/composer/draft/workspace-tab.tsx | 6 +- packages/app/src/composer/index.tsx | 22 +- .../app/src/composer/submission/model.test.ts | 123 ++++ packages/app/src/composer/submission/model.ts | 108 +++ .../app/src/composer/submission/writer.ts | 20 + packages/app/src/composer/submit.ts | 2 +- packages/app/src/contexts/session-context.tsx | 55 +- packages/app/src/panels/agent-panel.tsx | 8 + packages/app/src/runtime/host-runtime.test.ts | 65 +- packages/app/src/runtime/host-runtime.ts | 26 +- .../app/src/runtime/replica-cache/index.ts | 20 +- packages/app/src/stores/create-flow-store.ts | 5 +- packages/app/src/stores/session-store.ts | 208 +++++- .../timeline/session-stream-reducers.test.ts | 541 +++++++------- .../src/timeline/session-stream-reducers.ts | 441 ++++------- packages/app/src/timeline/turn-time.test.ts | 26 +- packages/app/src/timeline/turn-time.ts | 8 +- packages/app/src/types/stream.test.ts | 377 +++++++--- packages/app/src/types/stream.ts | 615 ++++++++++++---- .../src/utils/agent-directory-sync.test.ts | 106 +++ packages/client/src/daemon-client.ts | 8 +- packages/client/src/index.ts | 4 +- packages/protocol/src/messages.ts | 2 + .../providers/mock-load-test-agent.test.ts | 19 + .../agent/providers/mock-load-test-agent.ts | 12 + packages/server/src/server/session.ts | 2 + 39 files changed, 3407 insertions(+), 1039 deletions(-) create mode 100644 packages/app/e2e/agent-message-submission.spec.ts create mode 100644 packages/app/e2e/helpers/agent-message-gate.ts create mode 100644 packages/app/e2e/out-of-band-command.codex.real.spec.ts create mode 100644 packages/app/src/composer/submission/model.test.ts create mode 100644 packages/app/src/composer/submission/model.ts create mode 100644 packages/app/src/composer/submission/writer.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index b881744f50a..b852e340a8e 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -87,14 +87,42 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap remaining page through the existing stream reducer. It must not append full projected text to a live prefix. -Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts -them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly -where the user submitted it. Other canonical rows are applied after the already-present timeline -instead of relocating visible user messages around newly fetched history. +Every path that sends a message to an agent — composer send, dictation accept-and-send, queued +send-now, and the automatic queue drain in `HostRuntime` — goes through +`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same +product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending +footer, and permanently drops attachments because the daemon does not echo them back. + +A submitted prompt is one `UserMessageItem` row. That row is the authoritative local presentation: +its stable identity, text, timestamp, images, and attachments do not change when the provider +acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape or +a property inferred from message identity. The transaction registry holds every unresolved send and +records RPC acceptance and provider acknowledgement independently. Provider acknowledgement exists +solely so a later transport error cannot roll back a prompt already observed canonically. + +The daemon's accepted response already waits for the correlated run start, but its response and the +directory update reach client state separately. An accepted transaction remains active until the +directory observes that run or canonical ingestion acknowledges the prompt, bridging those ordered +authorities without inspecting timeline snapshots. Either signal clears only an RPC-accepted +transaction, regardless of which arrived first; it cannot settle a fresh send. +Overlapping sends settle independently rather than collapsing to one newest pending message. Canonical submitted user rows carry the provider's `messageId` and Paseo's optional -`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is -limited to the dated compatibility path for daemon timelines created before that field existed. +`clientMessageId`. The user-message producer reconciles them by `clientMessageId`, adds provider +identity to the existing row, and keeps the local presentation in its original timeline slot. +Content matching is limited to the dated compatibility path for daemon timelines created before +that field existed. Canonical ingestion may match only an explicit unreconciled local candidate; +the draft-create handoff is the one boundary that also permits the legacy canonical twin to have +arrived first. Generic reducers and consumers do not reimplement message identity matching. + +Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted +rows because a provider may never echo them. A known epoch change or rewind replaces history and +drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet +acknowledged by the provider, and no other local row, crosses that destructive boundary. + +Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and +payload while taking the canonical row's ordered position. If a live assistant head is the +canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes. ## Relevant code diff --git a/packages/app/e2e/agent-message-submission.spec.ts b/packages/app/e2e/agent-message-submission.spec.ts new file mode 100644 index 00000000000..092159b9bb3 --- /dev/null +++ b/packages/app/e2e/agent-message-submission.spec.ts @@ -0,0 +1,685 @@ +import type { Locator, Page } from "@playwright/test"; +import { expect, test as baseTest } from "./fixtures"; +import { awaitToolCall, expectAgentIdle } from "./helpers/agent-stream"; +import { gateNextAgentMessage } from "./helpers/agent-message-gate"; +import { + attachImageFromMenu, + expectComposerDraft, + expectComposerEditable, + expectAttachmentPill, + expectComposerVisible, + fillComposerDraft, + sendDraftToQueue, + startRunningMockAgent, +} from "./helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { readScrollMetrics } from "./helpers/agent-bottom-anchor"; +import { seedWorkspace } from "./helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { getServerId } from "./helpers/server-id"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { selectModel } from "./helpers/app"; + +const IMAGE = { + name: "message-submission.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), +}; + +interface MessageGeometry { + x: number; + y: number; + width: number; + height: number; +} + +interface SubmissionScenario { + gate: Awaited>; +} + +interface DraftCreateScenario { + workspaceId: string; + agentCreatedDelay: Awaited>; +} + +interface RejectionScenario { + errorMessage: string; +} + +interface UnrelatedRunningScenario { + gate: Awaited>; + agent: Awaited>; +} + +const test = baseTest.extend<{ + submissionScenario: SubmissionScenario; + draftCreateScenario: DraftCreateScenario; + rejectionScenario: RejectionScenario; + unrelatedRunningScenario: UnrelatedRunningScenario; +}>({ + submissionScenario: async ({ page }, provide, testInfo) => { + const gate = await gateNextAgentMessage(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `message-submission-${testInfo.workerIndex}-`, + title: "Message submission regression", + model: "ten-second-stream", + }); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + await provide({ gate }); + await agent.cleanup(); + }, + draftCreateScenario: async ({ page }, provide, testInfo) => { + const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page); + const workspace = await seedWorkspace({ + repoPrefix: `message-create-handoff-${testInfo.workerIndex}-`, + }); + await provide({ workspaceId: workspace.workspaceId, agentCreatedDelay }); + agentCreatedDelay.release(); + await workspace.cleanup(); + }, + rejectionScenario: async ({ page }, provide, testInfo) => { + const errorMessage = "Requested mock prompt rejection"; + const agent = await seedMockAgentWorkspace({ + repoPrefix: `message-rejection-${testInfo.workerIndex}-`, + title: "Message rejection regression", + model: "ten-second-stream", + featureValues: { mockPromptRejections: 1 }, + }); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + await provide({ errorMessage }); + await agent.cleanup(); + }, + unrelatedRunningScenario: async ({ page }, provide, testInfo) => { + const gate = await gateNextAgentMessage(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `unrelated-running-${testInfo.workerIndex}-`, + title: "Unrelated running transition", + model: "one-minute-stream", + }); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + await provide({ gate, agent }); + await agent.cleanup(); + }, +}); + +async function submitMessageWithImage(page: Page, prompt: string): Promise { + await attachImageFromMenu(page, IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(prompt); + await composer.press("Enter"); + const nextFrame = await composer.evaluate( + (composerElement, submittedPrompt) => + new Promise<{ + rowPresent: boolean; + workingPresent: boolean; + composerValue: string | null; + attachmentPresent: boolean; + }>((resolve) => { + requestAnimationFrame(() => { + const rows = Array.from(document.querySelectorAll('[data-testid="user-message"]')); + const composerInput = composerElement as HTMLInputElement | HTMLTextAreaElement; + resolve({ + rowPresent: rows.some((row) => row.textContent?.includes(submittedPrompt)), + workingPresent: Boolean( + document.querySelector('[data-testid="turn-working-indicator"]'), + ), + composerValue: composerInput.value, + attachmentPresent: Boolean( + document.querySelector('[data-testid="composer-image-attachment-pill"]'), + ), + }); + }); + }), + prompt, + ); + expect(nextFrame).toEqual({ + rowPresent: true, + workingPresent: true, + composerValue: "", + attachmentPresent: false, + }); + return page.getByTestId("user-message").filter({ hasText: prompt }).last(); +} + +async function submitImageOnlyMessage(page: Page): Promise { + await attachImageFromMenu(page, IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter"); + const userMessage = page.getByTestId("user-message").last(); + await expect(userMessage).toBeVisible(); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); + return userMessage; +} + +async function expectPendingSubmission(page: Page, userMessage: Locator): Promise { + await expect(userMessage).toBeVisible(); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue(""); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await expect(userMessage.getByTestId("user-message-timestamp")).toBeAttached(); + await expect(userMessage.getByTestId("user-message-trailing-row")).toHaveCSS("opacity", "0"); + await expect(userMessage).toHaveAttribute("aria-busy", "true"); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); +} + +async function readMessageGeometry(page: Page, userMessage: Locator): Promise { + const box = await userMessage.boundingBox(); + if (!box) throw new Error("Submitted user message has no browser geometry"); + const { offsetY } = await readScrollMetrics(page); + return { x: box.x, y: box.y + offsetY, width: box.width, height: box.height }; +} + +async function beginWorkingFooterContinuityCheck(page: Page): Promise<() => Promise> { + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await page.evaluate(() => { + const state = { active: true, sawMissing: false }; + const windowState = window as unknown as Record; + windowState.__messageSubmissionFooterContinuity = state; + const checkFrame = () => { + if (!state.active) return; + if (!document.querySelector('[data-testid="turn-working-indicator"]')) { + state.sawMissing = true; + } + requestAnimationFrame(checkFrame); + }; + requestAnimationFrame(checkFrame); + }); + + return async () => { + const sawMissing = await page.evaluate(() => { + const windowState = window as unknown as Record; + const state = windowState.__messageSubmissionFooterContinuity as + | { active: boolean; sawMissing: boolean } + | undefined; + if (!state) throw new Error("Working-footer continuity check was not started"); + state.active = false; + delete windowState.__messageSubmissionFooterContinuity; + return state.sawMissing; + }); + expect(sawMissing).toBe(false); + }; +} + +async function expectAcceptedSubmission( + page: Page, + userMessage: Locator, + submittedGeometry: MessageGeometry, +): Promise { + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + expect(await readMessageGeometry(page, userMessage)).toEqual(submittedGeometry); +} + +async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise { + await attachImageFromMenu(page, IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(prompt); + await composer.press("Enter"); +} + +async function expectRejectedSubmissionRestored( + page: Page, + input: { prompt: string; errorMessage: string }, +): Promise { + await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 }); + await expectComposerDraft(page, input.prompt); + await expectComposerEditable(page); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled(); + await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); +} + +async function retryRestoredSubmission(page: Page, prompt: string): Promise { + await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter"); + const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(userMessage).toHaveCount(1); + await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); +} + +async function queueMessage(page: Page, prompt: string): Promise { + await fillComposerDraft(page, prompt); + await sendDraftToQueue(page); +} + +async function expectQueuedSendFailuresRestored(page: Page, prompts: string[]): Promise { + await expect(page.getByRole("button", { name: "Send queued message now" })).toHaveCount( + prompts.length, + ); + for (const prompt of prompts) { + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0); + } +} + +async function expectFailedSubmissionRestored(page: Page, prompt: string): Promise { + await expectComposerDraft(page, prompt); + await expectComposerEditable(page); + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0); +} + +async function expectInterruptedTurnOrderAfterReconnect( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-reconnect-${testInfo.workerIndex}-`, + title: "Submission reconnect ordering", + model: "ten-second-stream", + }); + const prompt = "Keep this prompt before its response."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await agent.client.sendAgentMessage(agent.agentId, "Start the turn that will be interrupted."); + await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible(); + await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible(); + await queueMessage(page, prompt); + gate.setAgentStreamSuppressed(true); + await page.getByRole("button", { name: "Send queued message now" }).click(); + const promptRow = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(promptRow).toBeVisible(); + await gate.waitForServerMessage("send_agent_message_response"); + await gate.drop(); + await agent.client.waitForFinish(agent.agentId, 30_000); + gate.setAgentStreamSuppressed(false); + gate.forceNextTimelineEpochReset(); + gate.restoreFresh(); + await gate.waitForServerMessage("fetch_agent_timeline_response", 2); + const response = page.getByText("(end of synthetic stream)", { exact: true }).last(); + await expect(promptRow).toBeVisible(); + await expect(response).toBeVisible(); + await expectRenderedBefore(promptRow, response); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectCompletedSubmissionClearsAfterMissedRunningTransition( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`, + title: "Submission missed running transition", + model: "ten-second-stream", + }); + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + gate.holdNextClientRequest("send_agent_message_request"); + const userMessage = await submitImageOnlyMessage(page); + await gate.waitForHeldClientRequest(); + gate.setServerMessageSuppressed("agent_status", true); + gate.setServerMessageSuppressed("agent_update", true); + gate.releaseHeldClientRequest(); + await gate.waitForServerMessage("send_agent_message_response"); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + await gate.drop(); + await agent.client.waitForFinish(agent.agentId, 30_000); + gate.setServerMessageSuppressed("agent_status", false); + gate.setServerMessageSuppressed("agent_update", false); + gate.restoreFresh(); + await gate.waitForServerMessage("fetch_agent_timeline_response", 2); + await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-ack-before-rpc-${testInfo.workerIndex}-`, + title: "Submission acknowledgement before RPC", + model: "ten-second-stream", + }); + const prompt = "Settle this provider-acknowledged submission."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + gate.setServerMessageSuppressed("agent_status", true); + gate.setServerMessageSuppressed("agent_update", true); + gate.holdNextServerMessage("send_agent_message_response"); + const userMessage = await submitMessageWithImage(page, prompt); + await gate.waitForHeldServerMessage(); + await gate.waitForAgentStreamItem("user_message"); + gate.releaseHeldServerMessage(); + await gate.drop(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectLegacyAssistantStartsAfterInterruptedPrompt( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-legacy-assistant-${testInfo.workerIndex}-`, + title: "Legacy assistant interrupt boundary", + model: "ten-second-stream", + }); + const prompt = "Start the replacement answer after this prompt."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await agent.client.sendAgentMessage(agent.agentId, "Start the interrupted answer."); + await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible(); + await queueMessage(page, prompt); + gate.setAssistantMessageIdsStripped(true); + gate.setAgentStreamEventSuppressed("turn_canceled", true); + await page.getByRole("button", { name: "Send queued message now" }).click(); + const promptRow = page.getByTestId("user-message").filter({ hasText: prompt }); + const replacementAnswer = page.getByText("(end of synthetic stream)", { exact: true }).last(); + await expect(promptRow).toBeVisible(); + await expect(replacementAnswer).toBeVisible({ timeout: 30_000 }); + await expectRenderedBefore(promptRow, replacementAnswer); + } finally { + gate.setAssistantMessageIdsStripped(false); + gate.setAgentStreamEventSuppressed("turn_canceled", false); + await agent.cleanup(); + } +} + +async function expectStaleCanonicalPagePreservesNewerLiveOutput( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-stale-canonical-${testInfo.workerIndex}-`, + title: "Stale canonical page race", + model: "one-minute-stream", + }); + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await agent.client.sendAgentMessage(agent.agentId, "End the snapshot at a tool call."); + await awaitToolCall(page, "read"); + await page + .getByRole("button", { name: /stop|cancel/i }) + .first() + .click(); + await expectAgentIdle(page); + + gate.holdNextServerMessage("fetch_agent_timeline_response"); + gate.requestTimelineTail(agent.agentId); + await gate.waitForHeldServerMessage(); + gate.truncateHeldTimelineAfterLast("tool_call"); + expect(gate.getHeldTimelineLastItemType()).toBe("tool_call"); + + const nextPrompt = "Stream after the stale snapshot."; + await agent.client.sendAgentMessage(agent.agentId, nextPrompt); + const nextPromptRow = page.getByTestId("user-message").filter({ hasText: nextPrompt }); + const liveAssistant = nextPromptRow.locator( + 'xpath=following::*[@data-testid="assistant-message"][1]', + ); + await expect(nextPromptRow).toBeVisible(); + await expect(liveAssistant).toContainText("Cycle 1"); + gate.releaseHeldServerMessage(); + await expect(liveAssistant).toContainText("Cycle 1"); + } finally { + await agent.cleanup(); + } +} + +async function expectCanonicalOrderWinsAcrossOverlappingClients( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`, + title: "Cross-client submission order", + model: "ten-second-stream", + }); + const localPrompt = "Send this after the other client turn."; + const remotePrompt = "Commit this other client turn first."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + gate.holdNextClientRequest("send_agent_message_request"); + const localRow = await submitMessageWithImage(page, localPrompt); + await gate.waitForHeldClientRequest(); + + await agent.client.sendAgentMessage(agent.agentId, remotePrompt); + await agent.client.waitForFinish(agent.agentId, 30_000); + const remoteRow = page.getByTestId("user-message").filter({ hasText: remotePrompt }); + await expect(remoteRow).toBeVisible(); + + const userMessageCount = gate.getAgentStreamItemCount("user_message"); + gate.releaseHeldClientRequest(); + await gate.waitForAgentStreamItem("user_message", userMessageCount + 1); + await expect(localRow).toHaveAttribute("aria-busy", "false"); + await expect(localRow.getByRole("button", { name: "Open image attachment" })).toBeVisible(); + await expect + .poll(async () => { + const localElement = await localRow.elementHandle(); + if (!localElement) return false; + return remoteRow.evaluate( + (remoteElement, localNode) => + Boolean( + remoteElement.compareDocumentPosition(localNode) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + localElement, + ); + }) + .toBe(true); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectRenderedBefore(first: Locator, second: Locator): Promise { + const secondElement = await second.elementHandle(); + if (!secondElement) throw new Error("Expected the second timeline item to be rendered"); + expect( + await first.evaluate( + (firstElement, secondNode) => + Boolean( + firstElement.compareDocumentPosition(secondNode) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + secondElement, + ), + ).toBe(true); +} + +async function openWorkspaceDraft(page: Page, workspaceId: string): Promise { + await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId)); + await waitForWorkspaceTabsVisible(page); + await page.getByTestId("workspace-new-agent-tab-inline").click(); + await expectComposerVisible(page); +} + +async function expectCreatedAgentHandoff( + page: Page, + prompt: string, + userMessage: Locator, +): Promise { + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(page.getByTestId(/^workspace-tab-agent_/).first()).toBeVisible({ timeout: 30_000 }); + await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); +} + +interface DraftCreatePendingSubmission { + prompt: string; + userMessage: Locator; +} + +async function beginDraftCreateSubmission( + page: Page, + scenario: DraftCreateScenario, +): Promise { + await openWorkspaceDraft(page, scenario.workspaceId); + await selectModel(page, "one-minute-stream"); + const prompt = "Keep this row through create handoff."; + const userMessage = await submitMessageWithImage(page, prompt); + await scenario.agentCreatedDelay.waitForCreateRequest(); + await scenario.agentCreatedDelay.waitForDelayedCreatedStatus(); + await expectPendingSubmission(page, userMessage); + return { prompt, userMessage }; +} + +async function completeDraftCreateSubmission( + page: Page, + scenario: DraftCreateScenario, + pending: DraftCreatePendingSubmission, +): Promise { + scenario.agentCreatedDelay.release(); + await expectCreatedAgentHandoff(page, pending.prompt, pending.userMessage); +} + +test.describe("Agent message submission", () => { + test("keeps the submitted row stable when the host accepts", async ({ + page, + submissionScenario, + }) => { + const userMessage = await submitMessageWithImage(page, "Hold this submission."); + await expectPendingSubmission(page, userMessage); + await submissionScenario.gate.waitForRequest(); + const submittedGeometry = await readMessageGeometry(page, userMessage); + const finishFooterContinuityCheck = await beginWorkingFooterContinuityCheck(page); + submissionScenario.gate.accept(); + await expectAcceptedSubmission(page, userMessage, submittedGeometry); + await finishFooterContinuityCheck(); + }); + + test("keeps the submitted row stable through draft create handoff", async ({ + page, + draftCreateScenario, + }) => { + test.setTimeout(120_000); + const pending = await beginDraftCreateSubmission(page, draftCreateScenario); + await completeDraftCreateSubmission(page, draftCreateScenario, pending); + }); + + test("restores a rejected submission and accepts its retry", async ({ + page, + rejectionScenario, + }) => { + const prompt = "Restore this rejected submission."; + await submitMessageThatWillBeRejected(page, prompt); + await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario }); + await retryRestoredSubmission(page, prompt); + }); + + test("restores overlapping queued sends when their connection fails", async ({ + page, + }, testInfo) => { + test.setTimeout(120_000); + const gate = await gateNextAgentMessage(page); + const agent = await startRunningMockAgent(page, { + prefix: `overlapping-queued-send-${testInfo.workerIndex}-`, + model: "one-minute-stream", + prompt: "Keep the agent running while messages queue.", + }); + const prompts = ["Restore the first queued send.", "Restore the second queued send."]; + try { + await queueMessage(page, prompts[0]); + await queueMessage(page, prompts[1]); + await page.getByRole("button", { name: "Send queued message now" }).first().click(); + await gate.waitForRequest(1); + await page.getByRole("button", { name: "Send queued message now" }).first().click(); + await gate.waitForRequest(2); + await gate.disconnect(); + await expectQueuedSendFailuresRestored(page, prompts); + } finally { + await agent.cleanup(); + } + }); + + test("does not accept a failed submission from an unrelated running turn", async ({ + page, + unrelatedRunningScenario, + }) => { + const prompt = "Restore this unsent prompt."; + await submitMessageThatWillBeRejected(page, prompt); + await unrelatedRunningScenario.gate.waitForRequest(); + await unrelatedRunningScenario.agent.client.sendAgentMessage( + unrelatedRunningScenario.agent.agentId, + "Start an unrelated turn.", + ); + await expect( + page.getByTestId("user-message").filter({ hasText: "Start an unrelated turn." }), + ).toBeVisible(); + await unrelatedRunningScenario.gate.disconnect(); + await expectFailedSubmissionRestored(page, prompt); + }); + + test("keeps a submitted prompt before its response when canonical history arrives", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectInterruptedTurnOrderAfterReconnect(page, testInfo); + }); + + test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectCompletedSubmissionClearsAfterMissedRunningTransition(page, testInfo); + }); + + test("clears a provider acknowledgement that arrives before RPC acceptance", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(page, testInfo); + }); + + test("keeps an old-daemon replacement answer after its interrupted prompt", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectLegacyAssistantStartsAfterInterruptedPrompt(page, testInfo); + }); + + test("preserves newer live output when a stale canonical page arrives", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectStaleCanonicalPagePreservesNewerLiveOutput(page, testInfo); + }); + + test("uses canonical order when another client turn overtakes a held submission", async ({ + page, + }, testInfo) => { + await expectCanonicalOrderWinsAcrossOverlappingClients(page, testInfo); + }); +}); diff --git a/packages/app/e2e/helpers/agent-message-gate.ts b/packages/app/e2e/helpers/agent-message-gate.ts new file mode 100644 index 00000000000..866d257706e --- /dev/null +++ b/packages/app/e2e/helpers/agent-message-gate.ts @@ -0,0 +1,86 @@ +import type { Page, WebSocketRoute } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +type WebSocketMessage = string | Buffer; + +interface SendAgentMessageRequest { + type: "send_agent_message_request"; + requestId: string; + agentId: string; +} + +function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null { + if (typeof message !== "string") return null; + try { + const envelope = JSON.parse(message) as { + type?: unknown; + message?: Record; + }; + const request = envelope.type === "session" ? envelope.message : null; + if ( + request?.type !== "send_agent_message_request" || + typeof request.requestId !== "string" || + typeof request.agentId !== "string" + ) { + return null; + } + return { + type: "send_agent_message_request", + requestId: request.requestId, + agentId: request.agentId, + }; + } catch { + return null; + } +} + +export async function gateNextAgentMessage(page: Page) { + let serverSocket: WebSocketRoute | null = null; + let browserSocket: WebSocketRoute | null = null; + const heldMessages: Array = []; + const requests: SendAgentMessageRequest[] = []; + const requestWaiters = new Set<() => void>(); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + browserSocket = ws; + const server = ws.connectToServer(); + serverSocket = server; + + ws.onMessage((message) => { + const request = readSendRequest(message); + if (request) { + heldMessages.push(message); + requests.push(request); + for (const resolve of requestWaiters) resolve(); + requestWaiters.clear(); + return; + } + server.send(message); + }); + + server.onMessage((message) => ws.send(message)); + }); + + const waitForRequest = async (count = 1): Promise => { + while (requests.length < count) { + await new Promise((resolve) => requestWaiters.add(resolve)); + } + return requests[count - 1]; + }; + + return { + waitForRequest, + accept(index = 0) { + const heldMessage = heldMessages[index]; + if (!serverSocket || !heldMessage) { + throw new Error("No held send-agent-message request to accept"); + } + serverSocket.send(heldMessage); + heldMessages[index] = null; + }, + async disconnect(): Promise { + if (!browserSocket) throw new Error("No browser daemon socket to disconnect"); + await browserSocket.close({ code: 1008, reason: "Dropped by submission test." }); + }, + }; +} diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts index a6c7f38675b..c1e57f7adc7 100644 --- a/packages/app/e2e/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -16,6 +16,20 @@ interface ClientRequest { type?: unknown; subscribe?: unknown; page?: { cursor?: unknown }; + payload?: unknown; +} + +function readSessionMessage(message: string | Buffer): ClientRequest | null { + if (typeof message !== "string") return null; + try { + const envelope = JSON.parse(message) as { + type?: unknown; + message?: ClientRequest; + }; + return envelope.message ?? envelope; + } catch { + return null; + } } function readClientRequest(message: string | Buffer): ClientRequest | null { @@ -38,15 +52,111 @@ function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCo return null; } +function stripAssistantMessageId( + message: string | Buffer, + enabled: boolean, + messageType: unknown, +): string | Buffer { + if (!enabled || messageType !== "agent_stream" || typeof message !== "string") return message; + const envelope = JSON.parse(message) as { + message?: { payload?: { event?: { type?: unknown; item?: Record } } }; + payload?: { event?: { type?: unknown; item?: Record } }; + }; + const event = (envelope.message?.payload ?? envelope.payload)?.event; + if (event?.type !== "timeline" || event.item?.type !== "assistant_message") return message; + delete event.item.messageId; + return JSON.stringify(envelope); +} + +function stripMessageSubmissionDisposition( + message: string | Buffer, + enabled: boolean, + messageType: unknown, +): string | Buffer { + if (!enabled || messageType !== "send_agent_message_response" || typeof message !== "string") { + return message; + } + const envelope = JSON.parse(message) as { + message?: { payload?: Record }; + payload?: Record; + }; + const payload = envelope.message?.payload ?? envelope.payload; + if (!payload) return message; + delete payload.outOfBand; + return JSON.stringify(envelope); +} + +function forceTimelineReset(message: string | Buffer, enabled: boolean): string | Buffer { + if (!enabled || typeof message !== "string") return message; + const envelope = JSON.parse(message) as { + message?: { payload?: Record }; + payload?: Record; + }; + const payload = envelope.message?.payload ?? envelope.payload; + if (!payload) return message; + payload.epoch = `playwright-reset-${Date.now()}`; + payload.reset = true; + return JSON.stringify(envelope); +} + +function readAgentStreamEventType(message: ClientRequest | null): string | null { + if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") { + return null; + } + const event = (message.payload as { event?: { type?: unknown } }).event; + return typeof event?.type === "string" ? event.type : null; +} + +function readAgentStreamItemType(message: ClientRequest | null): string | null { + if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") { + return null; + } + const event = (message.payload as { event?: { type?: unknown; item?: { type?: unknown } } }) + .event; + return event?.type === "timeline" && typeof event.item?.type === "string" + ? event.item.type + : null; +} + +function shouldSuppressServerMessage(input: { + message: ClientRequest | null; + messageTypes: ReadonlySet; + agentStreamEventTypes: ReadonlySet; + suppressAgentStream: boolean; +}): boolean { + const messageType = typeof input.message?.type === "string" ? input.message.type : null; + if (messageType && input.messageTypes.has(messageType)) return true; + if (input.suppressAgentStream && messageType === "agent_stream") return true; + const eventType = readAgentStreamEventType(input.message); + return Boolean(eventType && input.agentStreamEventTypes.has(eventType)); +} + export async function installDaemonWebSocketGate(page: Page) { let acceptingConnections = true; + let reconnectWithFreshClient = false; + let suppressAgentStream = false; + let forceTimelineEpochReset = false; + let stripAssistantMessageIds = false; + let stripSubmissionDisposition = false; + let heldClientRequestType: string | null = null; + let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null; + let resolveHeldClientRequest: (() => void) | null = null; + let heldServerMessageType: string | null = null; + let heldServerMessage: { browser: WebSocketRoute; message: string | Buffer } | null = null; + let resolveHeldServerMessage: (() => void) | null = null; + const suppressedServerMessageTypes = new Set(); + const suppressedAgentStreamEventTypes = new Set(); const activeSockets = new Set(); + let latestServer: WebSocketRoute | null = null; const directoryStarts: DirectoryRequestStartCounts = { subscribed: { agents: 0, workspaces: 0 }, unsubscribed: { agents: 0, workspaces: 0 }, total: { agents: 0, workspaces: 0 }, }; const clientRequestCounts = new Map(); + const serverMessageCounts = new Map(); + const agentStreamItemCounts = new Map(); + const serverMessageWaiters = new Set<() => void>(); await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { if (!acceptingConnections) { @@ -56,9 +166,20 @@ export async function installDaemonWebSocketGate(page: Page) { activeSockets.add(ws); const server = ws.connectToServer(); + latestServer = server; ws.onMessage((message) => { if (!acceptingConnections) return; + if (reconnectWithFreshClient && typeof message === "string") { + const hello = readClientRequest(message); + if (hello?.type === "hello") { + const parsed = JSON.parse(message) as { clientId?: string }; + parsed.clientId = `${parsed.clientId ?? "playwright"}-fresh-${Date.now()}`; + reconnectWithFreshClient = false; + server.send(JSON.stringify(parsed)); + return; + } + } const request = readClientRequest(message); if (typeof request?.type === "string") { clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1); @@ -69,6 +190,12 @@ export async function installDaemonWebSocketGate(page: Page) { directoryStarts.total[directory] += 1; } } + if (request?.type === heldClientRequestType) { + heldClientRequest = { server, message }; + resolveHeldClientRequest?.(); + resolveHeldClientRequest = null; + return; + } try { server.send(message); } catch { @@ -78,8 +205,55 @@ export async function installDaemonWebSocketGate(page: Page) { server.onMessage((message) => { if (!acceptingConnections) return; + const serverMessage = readSessionMessage(message); + let outboundMessage = stripAssistantMessageId( + message, + stripAssistantMessageIds, + serverMessage?.type, + ); + outboundMessage = stripMessageSubmissionDisposition( + outboundMessage, + stripSubmissionDisposition, + serverMessage?.type, + ); + const shouldForceTimelineReset = + forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response"; + outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset); + if (shouldForceTimelineReset) forceTimelineEpochReset = false; + if (typeof serverMessage?.type === "string") { + serverMessageCounts.set( + serverMessage.type, + (serverMessageCounts.get(serverMessage.type) ?? 0) + 1, + ); + for (const resolve of serverMessageWaiters) resolve(); + serverMessageWaiters.clear(); + } + const agentStreamItemType = readAgentStreamItemType(serverMessage); + if (agentStreamItemType) { + agentStreamItemCounts.set( + agentStreamItemType, + (agentStreamItemCounts.get(agentStreamItemType) ?? 0) + 1, + ); + for (const resolve of serverMessageWaiters) resolve(); + serverMessageWaiters.clear(); + } + if (serverMessage?.type === heldServerMessageType) { + heldServerMessage = { browser: ws, message: outboundMessage }; + resolveHeldServerMessage?.(); + resolveHeldServerMessage = null; + return; + } + if ( + shouldSuppressServerMessage({ + message: serverMessage, + messageTypes: suppressedServerMessageTypes, + agentStreamEventTypes: suppressedAgentStreamEventTypes, + suppressAgentStream, + }) + ) + return; try { - ws.send(message); + ws.send(outboundMessage); } catch { activeSockets.delete(ws); } @@ -100,6 +274,125 @@ export async function installDaemonWebSocketGate(page: Page) { restore(): void { acceptingConnections = true; }, + restoreFresh(): void { + reconnectWithFreshClient = true; + acceptingConnections = true; + }, + holdNextClientRequest(type: string): void { + heldClientRequestType = type; + heldClientRequest = null; + }, + waitForHeldClientRequest(): Promise { + if (heldClientRequest) return Promise.resolve(); + return new Promise((resolve) => { + resolveHeldClientRequest = resolve; + }); + }, + releaseHeldClientRequest(): void { + if (!heldClientRequest) throw new Error("No held client request to release"); + heldClientRequest.server.send(heldClientRequest.message); + heldClientRequest = null; + heldClientRequestType = null; + }, + holdNextServerMessage(type: string): void { + heldServerMessageType = type; + heldServerMessage = null; + }, + waitForHeldServerMessage(): Promise { + if (heldServerMessage) return Promise.resolve(); + return new Promise((resolve) => { + resolveHeldServerMessage = resolve; + }); + }, + releaseHeldServerMessage(): void { + if (!heldServerMessage) throw new Error("No held server message to release"); + heldServerMessage.browser.send(heldServerMessage.message); + heldServerMessage = null; + heldServerMessageType = null; + }, + requestTimelineTail(agentId: string): void { + if (!latestServer) throw new Error("No daemon WebSocket is connected"); + latestServer.send( + JSON.stringify({ + type: "session", + message: { + type: "fetch_agent_timeline_request", + agentId, + requestId: `playwright-timeline-${Date.now()}`, + direction: "tail", + limit: 0, + projection: "projected", + }, + }), + ); + }, + getHeldTimelineLastItemType(): string | null { + if (!heldServerMessage) throw new Error("No held server message to inspect"); + const response = readSessionMessage(heldServerMessage.message); + const payload = response?.payload; + if (!payload || typeof payload !== "object") return null; + const entries = (payload as { entries?: unknown }).entries; + if (!Array.isArray(entries)) return null; + const last = entries.at(-1) as { item?: { type?: unknown } } | undefined; + return typeof last?.item?.type === "string" ? last.item.type : null; + }, + truncateHeldTimelineAfterLast(itemType: string): void { + if (!heldServerMessage || typeof heldServerMessage.message !== "string") { + throw new Error("No held text server message to truncate"); + } + const envelope = JSON.parse(heldServerMessage.message) as { + message?: { payload?: Record }; + payload?: Record; + }; + const payload = envelope.message?.payload ?? envelope.payload; + if (!payload) throw new Error("Held message has no payload"); + const entries = payload.entries; + if (!Array.isArray(entries)) throw new Error("Held message is not a timeline response"); + const index = entries.findLastIndex( + (entry) => + typeof entry === "object" && + entry !== null && + (entry as { item?: { type?: unknown } }).item?.type === itemType, + ); + if (index < 0) throw new Error(`Timeline response has no ${itemType} item`); + const retained = entries.slice(0, index + 1) as Array<{ seqEnd?: unknown }>; + const lastSeq = retained.at(-1)?.seqEnd; + if (typeof lastSeq !== "number") throw new Error("Timeline entry has no sequence end"); + payload.entries = retained; + payload.endCursor = { epoch: payload.epoch, seq: lastSeq }; + payload.hasNewer = false; + if (payload.window && typeof payload.window === "object") { + (payload.window as Record).maxSeq = lastSeq; + (payload.window as Record).nextSeq = lastSeq + 1; + } + heldServerMessage.message = JSON.stringify(envelope); + }, + setServerMessageSuppressed(type: string, suppressed: boolean): void { + if (suppressed) { + suppressedServerMessageTypes.add(type); + } else { + suppressedServerMessageTypes.delete(type); + } + }, + setAgentStreamEventSuppressed(type: string, suppressed: boolean): void { + if (suppressed) { + suppressedAgentStreamEventTypes.add(type); + } else { + suppressedAgentStreamEventTypes.delete(type); + } + }, + setAssistantMessageIdsStripped(stripped: boolean): void { + stripAssistantMessageIds = stripped; + }, + setMessageSubmissionDispositionStripped(stripped: boolean): void { + stripSubmissionDisposition = stripped; + }, + setAgentStreamSuppressed(suppressed: boolean): void { + suppressAgentStream = suppressed; + }, + forceNextTimelineEpochReset(): void { + forceTimelineEpochReset = true; + }, getDirectoryRequestStartCounts(): DirectoryRequestStartCounts { return { subscribed: { ...directoryStarts.subscribed }, @@ -110,5 +403,18 @@ export async function installDaemonWebSocketGate(page: Page) { getClientRequestCount(type: string): number { return clientRequestCounts.get(type) ?? 0; }, + getAgentStreamItemCount(type: string): number { + return agentStreamItemCounts.get(type) ?? 0; + }, + async waitForServerMessage(type: string, count = 1): Promise { + while ((serverMessageCounts.get(type) ?? 0) < count) { + await new Promise((resolve) => serverMessageWaiters.add(resolve)); + } + }, + async waitForAgentStreamItem(type: string, count = 1): Promise { + while ((agentStreamItemCounts.get(type) ?? 0) < count) { + await new Promise((resolve) => serverMessageWaiters.add(resolve)); + } + }, }; } diff --git a/packages/app/e2e/out-of-band-command.codex.real.spec.ts b/packages/app/e2e/out-of-band-command.codex.real.spec.ts new file mode 100644 index 00000000000..2df34398d88 --- /dev/null +++ b/packages/app/e2e/out-of-band-command.codex.real.spec.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "./fixtures"; +import { submitMessage } from "./helpers/composer"; +import { cleanupRewindFlow, launchAgent, type AgentHandle } from "./helpers/rewind-flow"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; + +test.describe("Codex out-of-band commands", () => { + test.setTimeout(300_000); + + test("settles the submitted row when a goal command completes without a turn", async ({ + page, + }) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" }); + await submitMessage(page, "/goal clear"); + const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" }); + + await expect(command).toBeVisible(); + await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); + + test("settles the submitted row when an older daemon omits submission disposition", async ({ + page, + }) => { + const gate = await installDaemonWebSocketGate(page); + gate.setMessageSubmissionDispositionStripped(true); + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-compat-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" }); + await submitMessage(page, "/goal clear"); + const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" }); + + await expect(command).toBeVisible(); + await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + } finally { + gate.restore(); + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/app/e2e/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/rewind-menu.ui-contract.spec.ts index 158f40ae18a..7c12f3f5b02 100644 --- a/packages/app/e2e/rewind-menu.ui-contract.spec.ts +++ b/packages/app/e2e/rewind-menu.ui-contract.spec.ts @@ -1,6 +1,7 @@ import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "./fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; import { composerLocator, expectComposerDraft, @@ -23,7 +24,152 @@ async function expectUserMessageVisible(page: Page, text: string): Promise await expect(userMessage(page, text)).toBeVisible(); } +async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise { + await expect + .poll(() => + page.evaluate((messageText) => { + const raw = localStorage.getItem("@paseo:replica-cache"); + if (!raw) return false; + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + for (const host of cache.hosts ?? []) { + for (const item of host.timeline?.items ?? []) { + if (item.kind === "user_message" && item.text === messageText && item.messageId) { + return true; + } + } + } + return false; + }, prompt), + ) + .toBe(true); + + await page.evaluate((messageText) => { + const key = "@paseo:replica-cache"; + const raw = localStorage.getItem(key); + if (!raw) throw new Error("Replica cache was not persisted"); + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + const cachedMessage = cache.hosts + ?.flatMap((host) => host.timeline?.items ?? []) + .find((item) => item.kind === "user_message" && item.text === messageText); + if (!cachedMessage) throw new Error("Cached user message was not found"); + delete cachedMessage.messageId; + localStorage.setItem(key, JSON.stringify(cache)); + }, prompt); +} + +async function waitForCurrentSubmissionExcludedFromCache( + page: Page, + prompt: string, +): Promise { + await expect + .poll(() => + page.evaluate((messageText) => { + const raw = localStorage.getItem("@paseo:replica-cache"); + if (!raw) return false; + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + return !cache.hosts + ?.flatMap((host) => host.timeline?.items ?? []) + .some( + (item) => + item.kind === "user_message" && + item.text === messageText && + typeof item.clientMessageId === "string" && + item.messageId === undefined, + ); + }, prompt), + ) + .toBe(true); +} + +async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise { + await expect + .poll(() => + page.evaluate((messageText) => { + const raw = localStorage.getItem("@paseo:replica-cache"); + if (!raw) return false; + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + return cache.hosts + ?.flatMap((host) => host.timeline?.items ?? []) + .some( + (item) => + item.kind === "user_message" && + item.text === messageText && + item.messageId === undefined, + ); + }, prompt), + ) + .toBe(true); +} + +async function expectPendingSubmissionNotRestoredAfterReload(page: Page): Promise { + const prompt = "Keep this cached submission pending."; + const gate = await installDaemonWebSocketGate(page); + const session = await seedMockAgentWorkspace({ + repoPrefix: "rewind-current-cache-e2e-", + title: "Current cache submission e2e", + }); + + try { + await openAgentRoute(page, session); + await expectComposerVisible(page); + gate.holdNextClientRequest("send_agent_message_request"); + await submitMessage(page, prompt); + await gate.waitForHeldClientRequest(); + await waitForCurrentSubmissionExcludedFromCache(page, prompt); + await gate.drop(); + await page.reload(); + + await expect(userMessage(page, prompt)).toHaveCount(0); + } finally { + gate.restore(); + await session.cleanup(); + } +} + test.describe("Rewind sheet", () => { + test("does not restore a local-only submission from the display cache", async ({ page }) => { + await expectPendingSubmissionNotRestoredAfterReload(page); + }); + + test("does not invent rewind identity for an ID-less cached message", async ({ page }) => { + const prompt = "Restore this rewind identity from the legacy cache."; + const gate = await installDaemonWebSocketGate(page); + const session = await seedMockAgentWorkspace({ + repoPrefix: "rewind-cache-upgrade-e2e-", + title: "Rewind cache upgrade e2e", + initialPrompt: prompt, + }); + let heldTimelineRequest = false; + + try { + await openAgentRoute(page, session); + await expectUserMessageVisible(page, prompt); + await rewriteCachedMessageAsLegacyRow(page, prompt); + gate.holdNextClientRequest("fetch_agent_timeline_request"); + await page.reload(); + await gate.waitForHeldClientRequest(); + heldTimelineRequest = true; + + const restoredMessage = userMessage(page, prompt); + await expect(restoredMessage).toBeVisible(); + await restoredMessage.hover(); + await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0); + await waitForCachedMessageWithoutProviderId(page, prompt); + } finally { + if (heldTimelineRequest) gate.releaseHeldClientRequest(); + gate.restore(); + await session.cleanup(); + } + }); + test("rewinds from a user message sheet option", async ({ page }) => { const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn."; const secondPrompt = "Prepare deleted rewind turn assistant content."; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 5af665d40a6..0bc079e78c5 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -41,6 +41,7 @@ import { } from "@/components/message"; import { PlanCard } from "@/components/plan-card"; import type { StreamItem } from "@/types/stream"; +import type { PendingMessageSubmission } from "@/composer/submission/model"; import type { PendingPermission } from "@/types/shared"; import type { AgentCapabilityFlags, @@ -239,6 +240,7 @@ export interface AgentStreamViewProps { streamItems: StreamItem[]; streamHead?: StreamItem[]; pendingPermissions: Map; + pendingMessageSubmissions?: readonly PendingMessageSubmission[]; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; toast?: ToastApi | null; @@ -265,6 +267,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ ]; const EMPTY_STREAM_HEAD: StreamItem[] = []; +const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = []; const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200; function buildChatHistoryAttachment(input: { @@ -327,6 +330,7 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning); const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); const viewportRef = useRef(null); + const pendingClientMessageIds = useMemo( + () => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)), + [pendingMessageSubmissions], + ); const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( () => @@ -656,7 +664,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [context.capabilities, agentId, client, resolvedServerId], + [context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId], ); const renderAssistantMessageItem = useCallback( @@ -878,7 +890,8 @@ const AgentStreamViewComponent = forwardRef 0; const pendingPermissionsNode = useMemo( () => renderPendingPermissionsNode({ @@ -1157,6 +1170,9 @@ function agentStreamViewPropsEqual( if (left.streamItems !== right.streamItems) reasons.push("streamItems"); if (left.streamHead !== right.streamHead) reasons.push("streamHead"); if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions"); + if (left.pendingMessageSubmissions !== right.pendingMessageSubmissions) { + reasons.push("pendingMessageSubmissions"); + } if ( !bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest) ) { diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8abe679281c..e1870bd7296 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -132,6 +132,7 @@ interface UserMessageProps { client?: DaemonClient | null; isFirstInGroup?: boolean; isLastInGroup?: boolean; + isPending?: boolean; disableOuterSpacing?: boolean; } @@ -430,6 +431,7 @@ export const UserMessage = memo(function UserMessage({ client, isFirstInGroup = true, isLastInGroup = true, + isPending = false, disableOuterSpacing, }: UserMessageProps) { const isCompact = useIsCompactFormFactor(); @@ -441,7 +443,7 @@ export const UserMessage = memo(function UserMessage({ const hasText = message.trim().length > 0; const hasImages = images.length > 0; const hasAttachments = attachments.length > 0; - const showTrailingRow = hasText && (isCompact || isNative || isHovered); + const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered); const formattedTimestamp = useMemo( () => formatMessageTimestamp(new Date(timestamp)), [timestamp], @@ -494,7 +496,7 @@ export const UserMessage = memo(function UserMessage({ ); return ( - + {hasText ? ( - - {formattedTimestamp} - {capabilities ? ( + + + {formattedTimestamp} + + {capabilities && messageId ? ( void } = {}, ): ComposerSendClient & { calls: FakeSendCall[] } { const calls: FakeSendCall[] = []; return { calls, sendAgentMessage: async (agentId, text, opts) => { - calls.push({ agentId, text, options: opts }); + const call = { agentId, text, options: opts }; + calls.push(call); if (options.rejection) { + options.beforeRejection?.(call); throw options.rejection; } }, @@ -183,7 +195,7 @@ function createFakeSendClient( }; } -interface FakeStream extends AgentStreamWriter { +interface FakeStream extends MessageSubmissionWriter { head: Map; tail: Map; } @@ -192,18 +204,70 @@ function createFakeStream(initialHead: Map = new Map()): F const fake: FakeStream = { head: new Map(initialHead), tail: new Map(), - getTail: (agentId) => fake.tail.get(agentId), - getHead: (agentId) => fake.head.get(agentId), - setHead: (updater) => { - fake.head = updater(fake.head); + begin: (agentId, message) => { + const current = readSubmission(fake, agentId); + const stream = appendSubmittedUserMessage({ + tail: current.tail, + head: current.head, + message, + }); + writeSubmission(fake, agentId, { + ...stream, + submissions: beginMessageSubmission(current.submissions, { + clientMessageId: message.clientMessageId!, + submittedAt: message.timestamp, + }), + }); + }, + accept: (agentId, clientMessageId) => { + const current = readSubmission(fake, agentId); + writeSubmission(fake, agentId, { + ...current, + submissions: acceptMessageSubmission(current.submissions, clientMessageId, true, false), + }); }, - setTail: (updater) => { - fake.tail = updater(fake.tail); + reject: (agentId, clientMessageId) => { + const current = readSubmission(fake, agentId); + const result = rejectMessageSubmission(current.submissions, clientMessageId); + const stream = + result.outcome === "rejected" + ? removeSubmittedUserMessage({ + tail: current.tail, + head: current.head, + clientMessageId, + }) + : current; + writeSubmission(fake, agentId, { ...stream, submissions: result.submissions }); + return result.outcome; }, }; return fake; } +const submissionsByFakeStream = new WeakMap>(); + +interface FakeSubmissionState { + tail: StreamItem[]; + head: StreamItem[]; + submissions: MessageSubmissionRecord[]; +} + +function readSubmission(fake: FakeStream, agentId: string): FakeSubmissionState { + return { + tail: fake.tail.get(agentId) ?? [], + head: fake.head.get(agentId) ?? [], + submissions: submissionsByFakeStream.get(fake)?.get(agentId) ?? [], + }; +} + +function writeSubmission(fake: FakeStream, agentId: string, state: FakeSubmissionState): void { + fake.tail = new Map(fake.tail).set(agentId, state.tail); + fake.head = new Map(fake.head).set(agentId, state.head); + const submissions = submissionsByFakeStream.get(fake) ?? new Map(); + submissions.set(agentId, state.submissions); + submissionsByFakeStream.set(fake, submissions); +} + function createFakeQueue( initial: Map = new Map(), ): QueueWriter & { state: Map } { @@ -337,7 +401,7 @@ describe("pickAndPersistImages", () => { }); describe("dispatchComposerAgentMessage", () => { - it("removes the optimistic prompt when the host rejects it", async () => { + it("removes the submitted prompt when the host rejects it", async () => { const rejection = new Error("Host rejected prompt"); const client = createFakeSendClient({ rejection }); const stream = createFakeStream(); @@ -349,14 +413,54 @@ describe("dispatchComposerAgentMessage", () => { text: "rejected prompt", attachments: [], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }), ).rejects.toBe(rejection); - expect(stream.head.get("agent")).toBeUndefined(); + expect(stream.head.get("agent")).toEqual([]); + expect(stream.tail.get("agent") ?? []).toEqual([]); + }); + + it("rolls back an already-running force send when its RPC fails", async () => { + const stream = createFakeStream(); + const transportError = new Error("Force send failed while the prior turn was running"); + const client = createFakeSendClient({ rejection: transportError }); + + await expect( + dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "force send", + attachments: [], + encodeImages: passthroughEncodeImages, + submission: stream, + }), + ).rejects.toBe(transportError); + expect(stream.tail.get("agent") ?? []).toEqual([]); }); + it("does not swallow a transport error when submission state is missing", async () => { + const transportError = new Error("Connection lost with unknown submission state"); + const client = createFakeSendClient({ rejection: transportError }); + const submission: MessageSubmissionWriter = { + begin: () => {}, + accept: () => {}, + reject: () => "unknown", + }; + + await expect( + dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "unknown state", + attachments: [], + encodeImages: passthroughEncodeImages, + submission, + }), + ).rejects.toBe(transportError); + }); + it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => { const client = createFakeSendClient(); const stream = createFakeStream(); @@ -371,7 +475,7 @@ describe("dispatchComposerAgentMessage", () => { { kind: "github_pr", item: prItem }, ], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls).toHaveLength(1); @@ -393,7 +497,7 @@ describe("dispatchComposerAgentMessage", () => { }, ]); - expect(stream.head.get("agent")).toBeUndefined(); + expect(stream.head.get("agent")).toEqual([]); const tail = stream.tail.get("agent"); expect(tail).toHaveLength(1); const userMessage = tail?.[0] as Extract; @@ -402,7 +506,8 @@ describe("dispatchComposerAgentMessage", () => { expect(userMessage.images).toEqual([image]); expect(userMessage.attachments).toEqual(call.options.attachments); expect(userMessage.id).toBe(call.options.messageId); - expect(userMessage.optimistic).toBe(true); + expect(userMessage.clientMessageId).toBe(call.options.messageId); + expect(userMessage.messageId).toBeUndefined(); }); it("can send legacy GitHub attachment payloads for old daemons", async () => { @@ -416,7 +521,7 @@ describe("dispatchComposerAgentMessage", () => { attachments: [{ kind: "forge_change_request", item: prItem }], attachmentSubmitFormat: "legacy-github", encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0].options.attachments).toEqual([ @@ -449,11 +554,11 @@ describe("dispatchComposerAgentMessage", () => { text: "next message", attachments: [], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(stream.head.get("agent")).toHaveLength(2); - expect(stream.tail.get("agent")).toBeUndefined(); + expect(stream.tail.get("agent")).toEqual([]); }); it("submits empty wire arrays when no attachments are provided", async () => { @@ -466,7 +571,7 @@ describe("dispatchComposerAgentMessage", () => { text: "plain message", attachments: [], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0]?.options).toMatchObject({ @@ -486,7 +591,7 @@ describe("dispatchComposerAgentMessage", () => { text: "review this", attachments: [review], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0]?.options.attachments).toEqual([review.attachment]); @@ -504,7 +609,7 @@ describe("dispatchComposerAgentMessage", () => { text: "inspect element", attachments: [browserElement], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0]?.options.attachments).toEqual([ diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 44fc3174782..08cd0d03e79 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -12,13 +12,8 @@ import { splitComposerAttachmentsForSubmit, type ComposerAttachmentSubmitFormat, } from "@/composer/attachments/submit"; -import { - appendOptimisticUserMessageToStream, - buildOptimisticUserMessage, - generateMessageId, - type StreamItem, - type UserMessageItem, -} from "@/types/stream"; +import { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream"; +import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model"; import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker"; import { i18n } from "@/i18n/i18next"; @@ -51,7 +46,7 @@ export interface ComposerSendClient { images: Array<{ data: string; mimeType: string }>; attachments: ReturnType["attachments"]; }, - ) => Promise; + ) => Promise; uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{ requestId: string; file: { @@ -70,11 +65,10 @@ export interface ComposerCancelClient { cancelAgent: (agentId: string) => Promise | void; } -export interface AgentStreamWriter { - getTail: (agentId: string) => StreamItem[] | undefined; - getHead: (agentId: string) => StreamItem[] | undefined; - setHead: (updater: (prev: Map) => Map) => void; - setTail: (updater: (prev: Map) => Map) => void; +export interface MessageSubmissionWriter { + begin: (agentId: string, message: UserMessageItem) => void; + accept: (agentId: string, clientMessageId: string, outOfBand: boolean | undefined) => void; + reject: (agentId: string, clientMessageId: string) => MessageSubmissionRejectionOutcome; } export interface QueueWriter { @@ -169,7 +163,7 @@ export interface DispatchComposerAgentMessageInput { encodeImages: ( images: AttachmentMetadata[], ) => Promise | undefined>; - stream: AgentStreamWriter; + submission: MessageSubmissionWriter; } export async function dispatchComposerAgentMessage( @@ -178,60 +172,30 @@ export async function dispatchComposerAgentMessage( const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, { format: input.attachmentSubmitFormat, }); - const messageId = generateMessageId(); - const userMessage = buildOptimisticUserMessage({ - id: messageId, + const clientMessageId = generateMessageId(); + const userMessage = createUserMessage({ + clientMessageId, text: input.text, timestamp: new Date(), images: wirePayload.images, attachments: wirePayload.attachments, }); - const rollbackOptimisticMessage = appendUserMessageToStream( - input.agentId, - userMessage, - input.stream, - ); + input.submission.begin(input.agentId, userMessage); try { const imagesData = await input.encodeImages(wirePayload.images); - await input.client.sendAgentMessage(input.agentId, input.text, { - messageId, + const result = await input.client.sendAgentMessage(input.agentId, input.text, { + messageId: clientMessageId, images: imagesData ?? [], attachments: wirePayload.attachments, }); + input.submission.accept(input.agentId, clientMessageId, result?.outOfBand); } catch (error) { - rollbackOptimisticMessage(); + const outcome = input.submission.reject(input.agentId, clientMessageId); + if (outcome === "accepted") return; throw error; } } -function appendUserMessageToStream( - agentId: string, - userMessage: UserMessageItem, - stream: AgentStreamWriter, -): () => void { - const result = appendOptimisticUserMessageToStream({ - tail: stream.getTail(agentId) ?? [], - head: stream.getHead(agentId) ?? [], - message: userMessage, - placement: "active-head", - }); - const write = result.changedHead ? stream.setHead : stream.setTail; - const items = result.changedHead ? result.head : result.tail; - write((prev) => new Map(prev).set(agentId, items)); - - return () => { - write((prev) => { - const current = prev.get(agentId); - if (!current) return prev; - const nextItems = current.filter( - (item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic, - ); - if (nextItems.length === current.length) return prev; - return new Map(prev).set(agentId, nextItems); - }); - }; -} - export interface QueueComposerMessageInput { agentId: string; text: string; diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 41ea2a4d942..471d1fc8b90 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -13,7 +13,7 @@ describe("useDraftAgentCreateFlow", () => { useCreateFlowStore.setState({ pendingByDraftId: {} }); }); - it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => { + it("renders a prepared new-workspace submission before continuing it", async () => { const image: UserMessageImageAttachment = { id: "image-1", mimeType: "image/png", @@ -60,13 +60,13 @@ describe("useDraftAgentCreateFlow", () => { expect(result.current.isSubmitting).toBe(true); expect(result.current.draftAgent).toEqual({ currentAttempt: attempt }); - expect(result.current.optimisticStreamItems).toEqual([ + expect(result.current.submittedStreamItems).toEqual([ { kind: "user_message", id: "msg-prepared", + clientMessageId: "msg-prepared", text: "build this", timestamp: attempt.timestamp, - optimistic: true, images: [image], attachments: [attachment], }, diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index d4fddc412e1..ea3638da6c1 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -8,12 +8,13 @@ import { import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore } from "@/stores/session-store"; import { - buildOptimisticUserMessage, + createUserMessage, generateMessageId, type StreamItem, type UserMessageImageAttachment, } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { PendingMessageSubmission } from "@/composer/submission/model"; const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -133,7 +134,7 @@ export function useDraftAgentCreateFlow({ const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; - const optimisticStreamItems = useMemo(() => { + const submittedStreamItems = useMemo(() => { if (machine.tag !== "creating") { return EMPTY_STREAM_ITEMS; } @@ -147,8 +148,8 @@ export function useDraftAgentCreateFlow({ } return [ - buildOptimisticUserMessage({ - id: machine.attempt.clientMessageId, + createUserMessage({ + clientMessageId: machine.attempt.clientMessageId, text: machine.attempt.text, timestamp: machine.attempt.timestamp, images: machine.attempt.images, @@ -156,6 +157,15 @@ export function useDraftAgentCreateFlow({ }), ]; }, [machine]); + const pendingMessageSubmissions = useMemo(() => { + if (machine.tag !== "creating") return []; + return [ + { + clientMessageId: machine.attempt.clientMessageId, + submittedAt: machine.attempt.timestamp, + }, + ]; + }, [machine]); const draftAgent = useMemo(() => { if (machine.tag !== "creating") { @@ -195,8 +205,8 @@ export function useDraftAgentCreateFlow({ handoffCreatedAgentUserMessage( pendingServerId, createResult.agentId, - buildOptimisticUserMessage({ - id: attempt.clientMessageId, + createUserMessage({ + clientMessageId: attempt.clientMessageId, text: attempt.text, timestamp: attempt.timestamp, images: attempt.images, @@ -326,7 +336,8 @@ export function useDraftAgentCreateFlow({ machine, formErrorMessage, isSubmitting, - optimisticStreamItems, + submittedStreamItems, + pendingMessageSubmissions, draftAgent, handleCreateFromInput, continueCreateFromAttempt, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 490b0a49510..a4aa272df30 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -479,7 +479,8 @@ export function WorkspaceDraftAgentTab({ const { formErrorMessage, isSubmitting, - optimisticStreamItems, + submittedStreamItems, + pendingMessageSubmissions, draftAgent, handleCreateFromInput, continueCreateFromAttempt, @@ -642,7 +643,8 @@ export function WorkspaceDraftAgentTab({ agentId={tabId} serverId={serverId} context={draftAgent} - streamItems={optimisticStreamItems} + streamItems={submittedStreamItems} + pendingMessageSubmissions={pendingMessageSubmissions} pendingPermissions={EMPTY_PENDING_PERMISSIONS} onOpenWorkspaceFile={onOpenWorkspaceFile} /> diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 35232cd023d..12be754227c 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -64,7 +64,6 @@ import { sendQueuedComposerMessageNow, toggleGithubAttachmentFromPicker, uploadFileAttachments, - type AgentStreamWriter, type QueueWriter, type QueuedComposerMessage, } from "@/composer/actions"; @@ -91,6 +90,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { submitAgentInput } from "@/composer/submit"; +import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; import { isWeb, isNative } from "@/constants/platform"; @@ -1079,8 +1079,6 @@ export function Composer({ const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); @@ -1283,12 +1281,6 @@ export function Composer({ if (!client) { throw new Error(t("workspace.terminal.hostDisconnected")); } - const stream: AgentStreamWriter = { - getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id), - getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id), - setHead: (updater) => setAgentStreamHead(serverId, updater), - setTail: (updater) => setAgentStreamTail(serverId, updater), - }; await dispatchComposerAgentMessage({ client, agentId: targetAgentId, @@ -1298,19 +1290,11 @@ export function Composer({ supportsForgeAttachments: supportsForgeSearch, }), encodeImages, - stream, + submission: createMessageSubmissionWriter(serverId), }); onAttentionPromptSend?.(); }; - }, [ - client, - onAttentionPromptSend, - serverId, - setAgentStreamTail, - setAgentStreamHead, - supportsForgeSearch, - t, - ]); + }, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; diff --git a/packages/app/src/composer/submission/model.test.ts b/packages/app/src/composer/submission/model.test.ts new file mode 100644 index 00000000000..cab44f8a3e2 --- /dev/null +++ b/packages/app/src/composer/submission/model.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + acceptMessageSubmission, + beginMessageSubmission, + getActiveMessageSubmissions, + getSendingClientMessageIds, + observeAcceptedMessageSubmissionsRunning, + observeMessageSubmissionCanonical, + rejectMessageSubmission, +} from "./model"; + +const submittedAt = new Date("2026-07-26T10:00:00.000Z"); + +describe("message submission transactions", () => { + it("tracks every in-flight submission independently", () => { + const first = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + const both = beginMessageSubmission(first, { + clientMessageId: "client-2", + submittedAt: new Date(submittedAt.getTime() + 1), + }); + + expect(getActiveMessageSubmissions(both).map((item) => item.clientMessageId)).toEqual([ + "client-1", + "client-2", + ]); + expect(getSendingClientMessageIds(both)).toEqual(["client-1", "client-2"]); + }); + + it("removes only the RPC-accepted transaction", () => { + const both = beginMessageSubmission( + beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }), + { clientMessageId: "client-2", submittedAt }, + ); + + expect(acceptMessageSubmission(both, "client-1", true, false)).toEqual([ + { + clientMessageId: "client-2", + submittedAt, + rpcAccepted: false, + providerAcknowledged: false, + }, + ]); + }); + + it("bridges an accepted RPC until the correlated running state is observed", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + const accepted = acceptMessageSubmission(sending, "client-1", false, false); + + expect(getActiveMessageSubmissions(accepted)).toHaveLength(1); + expect(accepted[0].rpcAccepted).toBe(true); + expect(observeAcceptedMessageSubmissionsRunning(accepted)).toEqual([]); + }); + + it("settles an accepted RPC when provider acknowledgement arrives after running was missed", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + const accepted = acceptMessageSubmission(sending, "client-1", false, false); + + expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]); + }); + + it("settles an explicitly out-of-band acceptance without lifecycle inference", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + + expect(acceptMessageSubmission(sending, "client-1", false, true)).toEqual([]); + }); + + it("settles an idle acceptance from a daemon without submission disposition", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + + expect(acceptMessageSubmission(sending, "client-1", false, undefined)).toEqual([]); + }); + + it("records provider acknowledgement without settling another transaction", () => { + const both = beginMessageSubmission( + beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }), + { clientMessageId: "client-2", submittedAt }, + ); + const observed = observeMessageSubmissionCanonical(both, ["client-1"]); + + expect(observed).toEqual([ + { + clientMessageId: "client-1", + submittedAt, + rpcAccepted: false, + providerAcknowledged: true, + }, + { + clientMessageId: "client-2", + submittedAt, + rpcAccepted: false, + providerAcknowledged: false, + }, + ]); + expect(getSendingClientMessageIds(observed)).toEqual(["client-2"]); + }); + + it("does not roll back a provider-acknowledged prompt on a later transport error", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + const observed = observeMessageSubmissionCanonical(sending, ["client-1"]); + + expect(rejectMessageSubmission(observed, "client-1")).toEqual({ + outcome: "accepted", + submissions: [], + }); + }); + + it("rejects an unacknowledged transaction", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + + expect(rejectMessageSubmission(sending, "client-1")).toEqual({ + outcome: "rejected", + submissions: [], + }); + }); + + it("does not create duplicate transaction identity", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }); + + expect(() => + beginMessageSubmission(sending, { clientMessageId: "client-1", submittedAt }), + ).toThrow("Message submission already exists"); + }); +}); diff --git a/packages/app/src/composer/submission/model.ts b/packages/app/src/composer/submission/model.ts new file mode 100644 index 00000000000..9efc5e66a06 --- /dev/null +++ b/packages/app/src/composer/submission/model.ts @@ -0,0 +1,108 @@ +export interface PendingMessageSubmission { + clientMessageId: string; + submittedAt: Date; +} + +export type MessageSubmissionRecord = PendingMessageSubmission & { + rpcAccepted: boolean; + providerAcknowledged: boolean; +}; + +const EMPTY_MESSAGE_SUBMISSIONS: readonly MessageSubmissionRecord[] = []; + +export function getActiveMessageSubmissions( + submissions: readonly MessageSubmissionRecord[] | null | undefined, +): readonly PendingMessageSubmission[] { + return submissions ?? EMPTY_MESSAGE_SUBMISSIONS; +} + +export function getSendingClientMessageIds( + submissions: readonly MessageSubmissionRecord[] | null | undefined, +): string[] { + return (submissions ?? []) + .filter((submission) => !submission.providerAcknowledged) + .map((submission) => submission.clientMessageId); +} + +export type MessageSubmissionRejectionOutcome = "rejected" | "accepted" | "unknown"; + +export interface MessageSubmissionRejectionResult { + submissions: MessageSubmissionRecord[]; + outcome: MessageSubmissionRejectionOutcome; +} + +export function beginMessageSubmission( + submissions: readonly MessageSubmissionRecord[], + input: PendingMessageSubmission, +): MessageSubmissionRecord[] { + if (submissions.some((submission) => submission.clientMessageId === input.clientMessageId)) { + throw new Error(`Message submission already exists: ${input.clientMessageId}`); + } + return [...submissions, { ...input, rpcAccepted: false, providerAcknowledged: false }]; +} + +export function acceptMessageSubmission( + submissions: readonly MessageSubmissionRecord[], + clientMessageId: string, + isAgentRunning: boolean, + outOfBand: boolean | undefined, +): MessageSubmissionRecord[] { + const index = submissions.findIndex( + (submission) => submission.clientMessageId === clientMessageId, + ); + if (index < 0) return submissions as MessageSubmissionRecord[]; + // COMPAT(messageSubmissionDisposition): daemons before v0.2.3 omitted outOfBand. + // Their normal-send response follows the ordered running/canonical events, while an + // out-of-band response arrives with the agent still idle. Remove after 2027-01-27. + const legacyOutOfBand = outOfBand === undefined && !isAgentRunning; + if ( + outOfBand === true || + legacyOutOfBand || + isAgentRunning || + submissions[index].providerAcknowledged + ) { + return submissions.filter((_, submissionIndex) => submissionIndex !== index); + } + if (submissions[index].rpcAccepted) return submissions as MessageSubmissionRecord[]; + const next = submissions.slice(); + next[index] = { ...next[index], rpcAccepted: true }; + return next; +} + +export function observeAcceptedMessageSubmissionsRunning( + submissions: readonly MessageSubmissionRecord[], +): MessageSubmissionRecord[] { + const next = submissions.filter((submission) => !submission.rpcAccepted); + return next.length === submissions.length ? (submissions as MessageSubmissionRecord[]) : next; +} + +export function observeMessageSubmissionCanonical( + submissions: readonly MessageSubmissionRecord[], + clientMessageIds: readonly string[], +): MessageSubmissionRecord[] { + if (clientMessageIds.length === 0) return submissions as MessageSubmissionRecord[]; + const observed = new Set(clientMessageIds); + let changed = false; + const next = submissions.flatMap((submission): MessageSubmissionRecord[] => { + if (submission.providerAcknowledged || !observed.has(submission.clientMessageId)) { + return [submission]; + } + changed = true; + return submission.rpcAccepted ? [] : [{ ...submission, providerAcknowledged: true }]; + }); + return changed ? next : (submissions as MessageSubmissionRecord[]); +} + +export function rejectMessageSubmission( + submissions: readonly MessageSubmissionRecord[], + clientMessageId: string, +): MessageSubmissionRejectionResult { + const submission = submissions.find((item) => item.clientMessageId === clientMessageId); + if (!submission) { + return { outcome: "unknown", submissions: submissions as MessageSubmissionRecord[] }; + } + return { + outcome: submission.providerAcknowledged || submission.rpcAccepted ? "accepted" : "rejected", + submissions: submissions.filter((item) => item.clientMessageId !== clientMessageId), + }; +} diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts new file mode 100644 index 00000000000..9b41c84e753 --- /dev/null +++ b/packages/app/src/composer/submission/writer.ts @@ -0,0 +1,20 @@ +import type { MessageSubmissionWriter } from "@/composer/actions"; +import { useSessionStore } from "@/stores/session-store"; + +/** + * Binds the submission lifecycle to a host session. Every path that sends a message to an + * agent — composer send, queued send-now, automatic queue drain — goes through this so a + * submitted row and its pending state are always created together. + */ +export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { + return { + begin: (agentId, message) => + useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message), + accept: (agentId, clientMessageId, outOfBand) => + useSessionStore + .getState() + .acceptAgentMessageSubmission(serverId, agentId, clientMessageId, outOfBand), + reject: (agentId, clientMessageId) => + useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId), + }; +} diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index fb892d98500..c0c39171c54 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -51,7 +51,7 @@ export async function submitAgentInput( return "queued"; } - // Clear immediately so optimistic stream updates and composer state stay in sync. + // Clear immediately so the submitted timeline row and composer state stay in sync. if (shouldClearOnSubmit) { input.setUserInput(""); input.setAttachments([]); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 14d36f183ad..8a627d6148f 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -53,6 +53,7 @@ import { } from "@/utils/agent-initialization"; import { encodeImages } from "@/utils/encode-images"; import { derivePendingPermissionKey } from "@/utils/agent-snapshots"; +import { getSendingClientMessageIds } from "@/composer/submission/model"; import type { AttachmentMetadata } from "@/attachments/types"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { useToast } from "@/contexts/toast-context"; @@ -191,9 +192,7 @@ type WorkspaceSetupProgressPayload = Extract< type SessionStoreActions = ReturnType; type SetInitializingAgents = SessionStoreActions["setInitializingAgents"]; -type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"]; -type SetAgentStreamHead = SessionStoreActions["setAgentStreamHead"]; -type ClearAgentStreamHead = SessionStoreActions["clearAgentStreamHead"]; +type SetAgentStreamState = SessionStoreActions["setAgentStreamState"]; type SetAgentTimelineCursor = SessionStoreActions["setAgentTimelineCursor"]; type MarkAgentHistorySynchronized = SessionStoreActions["markAgentHistorySynchronized"]; type SetAgentAuthoritativeHistoryApplied = @@ -236,9 +235,7 @@ function applyTimelineStreamPatches(input: { serverId: string; currentTail: StreamItem[]; currentHead: StreamItem[]; - setAgentStreamTail: SetAgentStreamTail; - setAgentStreamHead: SetAgentStreamHead; - clearAgentStreamHead: ClearAgentStreamHead; + setAgentStreamState: SetAgentStreamState; setAgentTimelineCursor: SetAgentTimelineCursor; }): void { const { @@ -247,32 +244,24 @@ function applyTimelineStreamPatches(input: { serverId, currentTail, currentHead, - setAgentStreamTail, - setAgentStreamHead, - clearAgentStreamHead, + setAgentStreamState, setAgentTimelineCursor, } = input; - if (result.tail !== currentTail) { - setAgentStreamTail(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, result.tail); - return next; + if ( + result.tail !== currentTail || + result.head !== currentHead || + result.acknowledgedClientMessageIds.length > 0 + ) { + setAgentStreamState(serverId, agentId, { + ...(result.tail !== currentTail ? { tail: result.tail } : {}), + ...(result.head !== currentHead ? { head: result.head } : {}), + ...(result.acknowledgedClientMessageIds.length > 0 + ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } + : {}), }); } - if (result.head !== currentHead) { - if (result.head.length === 0) { - clearAgentStreamHead(serverId, agentId); - } else { - setAgentStreamHead(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, result.head); - return next; - }); - } - } - if (result.cursorChanged) { setAgentTimelineCursor(serverId, (prev) => { const current = prev.get(agentId); @@ -658,6 +647,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const currentCursor = session?.agentTimelineCursor.get(agentId); const currentTail = session?.agentStreamTail.get(agentId) ?? []; const currentHead = session?.agentStreamHead.get(agentId) ?? []; + const sendingClientMessageIds = getSendingClientMessageIds( + session?.messageSubmissions.get(agentId), + ); setAgentTimelineHasOlder(serverId, (prev) => { if (prev.get(agentId) === payload.hasOlder) { @@ -677,6 +669,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider isInitializing, hasActiveInitDeferred, initRequestDirection: activeInitDeferred?.requestDirection ?? "tail", + sendingClientMessageIds, }); if (result.error) { @@ -696,9 +689,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId, currentTail, currentHead, - setAgentStreamTail, - setAgentStreamHead, - clearAgentStreamHead, + setAgentStreamState, setAgentTimelineCursor, }); @@ -720,13 +711,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); }, [ - clearAgentStreamHead, markAgentHistorySynchronized, recoverTimelineGap, serverId, setAgentAuthoritativeHistoryApplied, - setAgentStreamHead, - setAgentStreamTail, + setAgentStreamState, setAgentTimelineCursor, setAgentTimelineHasOlder, setInitializingAgents, @@ -808,7 +797,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId, setAgentStreamState, setAgentTimelineCursor, - setAgents, recoverTimelineGap, }); @@ -825,7 +813,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ) { voiceRuntime?.onTurnEvent(serverId, agentId, event.type); } - agentStreamReducerQueue.enqueue(agentId, { event: streamEvent, seq, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 240f300dbb3..7057169f110 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -25,6 +25,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { SidebarCallout } from "@/components/sidebar-callout"; import { Composer } from "@/composer"; +import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { getProviderIcon } from "@/components/provider-icons"; import { @@ -442,6 +443,7 @@ export function useDraftPanelDescriptor( } const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const EMPTY_MESSAGE_SUBMISSIONS = [] as const; const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = []; @@ -1288,6 +1290,11 @@ const AgentStreamSection = memo(function AgentStreamSection({ const streamItemsRaw = useSessionStore((state) => agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, ); + const pendingMessageSubmissions = useSessionStore((state) => + agentId + ? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId)) + : EMPTY_MESSAGE_SUBMISSIONS, + ); const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; const pendingPermissionList = useStoreWithEqualityFn( useSessionStore, @@ -1327,6 +1334,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} toast={toast} + pendingMessageSubmissions={pendingMessageSubmissions} onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 0f4946a4b92..6f73dd640f9 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -80,13 +80,16 @@ class FakeDaemonClient { this.setConnectionState({ status: "disconnected", reason: "client_closed" }); } - async sendAgentMessage(...args: Parameters): Promise { + async sendAgentMessage( + ...args: Parameters + ): ReturnType { this.sentAgentMessages.push(args); for (const waiter of this.sentMessageWaiters) waiter(); const response = this.sendAgentMessageResponses.shift(); if (response) await response; const failure = this.sendAgentMessageFailures.shift(); if (failure) throw failure; + return {}; } async waitForSentMessages(count: number): Promise { @@ -2259,6 +2262,66 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("submits an automatically drained message through the submission producer", async () => { + const host = makeHost({ serverId: "srv_drain_submission" }); + const fakeClient = new FakeDaemonClient(); + const send = new Deferred(); + fakeClient.sendAgentMessageResponses.push(send.promise); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_drain_submission", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + [ + "agent", + [ + { + id: "queued-with-attachment", + text: "read this file", + attachments: [ + { + kind: "workspace_file" as const, + path: "src/main.ts", + selection: { kind: "whole_file" as const }, + }, + ], + }, + ], + ], + ]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + await fakeClient.waitForSentMessages(1); + + // The row and the pending submission must exist while the RPC is still in flight — + // the user sees their message and the working footer immediately, exactly as when + // they press send. + const session = useSessionStore.getState().sessions[host.serverId]; + const tail = session?.agentStreamTail.get("agent") ?? []; + expect(tail).toHaveLength(1); + expect(tail[0]).toMatchObject({ + kind: "user_message", + text: "read this file", + attachments: [{ type: "text", title: "main.ts", text: "Workspace file: src/main.ts" }], + }); + expect(session?.messageSubmissions.get("agent")).toBeDefined(); + + send.resolve(); + useSessionStore.getState().clearSession(host.serverId); + }); + it("restores an automatically drained message when sending fails", async () => { const host = makeHost({ serverId: "srv_failed_queue_drain" }); const fakeClient = new FakeDaemonClient(); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 14463281775..d77682a2f80 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -49,11 +49,9 @@ import { } from "@/data/push-router"; import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; -import { sendQueuedComposerMessageNow } from "@/composer/actions"; -import { - resolveComposerAttachmentSubmitFormat, - splitComposerAttachmentsForSubmit, -} from "@/composer/attachments/submit"; +import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions"; +import { createMessageSubmissionWriter } from "@/composer/submission/writer"; +import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; import { encodeImages } from "@/utils/encode-images"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; import { ReplicaCache } from "@/runtime/replica-cache"; @@ -2071,14 +2069,16 @@ export class HostRuntimeStore { submitMessage: async ({ text, attachments }) => { const supportsForgeAttachments = useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true; - const wirePayload = splitComposerAttachmentsForSubmit(attachments, { - format: resolveComposerAttachmentSubmitFormat({ supportsForgeAttachments }), - }); - const images = await encodeImages(wirePayload.images); - await client.sendAgentMessage(agentId, text, { - messageId: next.id, - ...(images && images.length > 0 ? { images } : {}), - attachments: wirePayload.attachments, + await dispatchComposerAgentMessage({ + client, + agentId, + text, + attachments, + attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({ + supportsForgeAttachments, + }), + encodeImages, + submission: createMessageSubmissionWriter(serverId), }); }, }) diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 7f8582884ee..b9bfab5c0c2 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -19,9 +19,10 @@ import { } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { getSendingClientMessageIds } from "@/composer/submission/model"; const STORAGE_KEY = "@paseo:replica-cache"; -const CACHE_VERSION = 1; +const CACHE_VERSION = 2; const PERSIST_DELAY_MS = 750; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 1024 * 1024; @@ -370,7 +371,22 @@ export class ReplicaCache { (workspace) => workspace.workspaceDirectory === focusedAgent.cwd, )) : undefined; - const items = focusedAgentId ? session.agentStreamTail.get(focusedAgentId) : undefined; + const localSubmissionIds = new Set( + getSendingClientMessageIds( + focusedAgentId ? session.messageSubmissions.get(focusedAgentId) : undefined, + ), + ); + const items = focusedAgentId + ? session.agentStreamTail + .get(focusedAgentId) + ?.filter( + (item) => + item.kind !== "user_message" || + item.messageId !== undefined || + !item.clientMessageId || + !localSubmissionIds.has(item.clientMessageId), + ) + : undefined; const timeline = focusedAgent && items ? { diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 5ca6b953290..2056e42c0f0 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -109,7 +109,10 @@ export const useCreateFlowStore = create((set) => ({ set((state) => { const next = Object.fromEntries( Object.entries(state.pendingByDraftId).filter( - ([, pending]) => pending.serverId !== serverId || pending.agentId !== agentId, + ([, pending]) => + pending.lifecycle !== "sent" || + pending.serverId !== serverId || + pending.agentId !== agentId, ), ); if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 1d61867f267..3a5504310e2 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -5,10 +5,21 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { + appendSubmittedUserMessage, handoffCreatedAgentUserMessageToStream, + removeSubmittedUserMessage, type StreamItem, type UserMessageItem, } from "@/types/stream"; +import { + acceptMessageSubmission, + beginMessageSubmission, + observeAcceptedMessageSubmissionsRunning, + observeMessageSubmissionCanonical, + rejectMessageSubmission, + type MessageSubmissionRecord, + type MessageSubmissionRejectionOutcome, +} from "@/composer/submission/model"; import type { PendingPermission } from "@/types/shared"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; @@ -368,6 +379,7 @@ export interface SessionState { // Stream state (head/tail model) agentStreamTail: Map; agentStreamHead: Map; + messageSubmissions: Map; agentTimelineCursor: Map; agentTimelineHasOlder: Map; agentTimelineOlderFetchInFlight: Map; @@ -459,8 +471,28 @@ interface SessionStoreActions { setAgentStreamState: ( serverId: string, agentId: string, - state: { tail?: StreamItem[]; head?: StreamItem[] }, + state: { + tail?: StreamItem[]; + head?: StreamItem[]; + acknowledgedClientMessageIds?: readonly string[]; + }, + ) => void; + beginAgentMessageSubmission: ( + serverId: string, + agentId: string, + message: UserMessageItem, + ) => void; + acceptAgentMessageSubmission: ( + serverId: string, + agentId: string, + clientMessageId: string, + outOfBand: boolean | undefined, ) => void; + rejectAgentMessageSubmission: ( + serverId: string, + agentId: string, + clientMessageId: string, + ) => MessageSubmissionRejectionOutcome; handoffCreatedAgentUserMessage: ( serverId: string, agentId: string, @@ -567,6 +599,27 @@ type SessionStore = SessionStoreState & SessionStoreActions; const agentLastActivityCoalescer = createAgentLastActivityCoalescer(); +function applyRunningAgentsToAcceptedSubmissions(input: { + previousAgents: Map; + nextAgents: Map; + submissions: Map; +}): Map { + let nextSubmissions = input.submissions; + for (const [agentId, submissions] of input.submissions) { + const previousAgent = input.previousAgents.get(agentId); + const nextAgent = input.nextAgents.get(agentId); + if (!nextAgent || previousAgent?.status === "running" || nextAgent.status !== "running") { + continue; + } + const remaining = observeAcceptedMessageSubmissionsRunning(submissions); + if (remaining === submissions) continue; + if (nextSubmissions === input.submissions) nextSubmissions = new Map(input.submissions); + if (remaining.length > 0) nextSubmissions.set(agentId, remaining); + else nextSubmissions.delete(agentId); + } + return nextSubmissions; +} + // Helper to create initial session state function createInitialSessionState( serverId: string, @@ -588,6 +641,7 @@ function createInitialSessionState( currentAssistantMessage: "", agentStreamTail: new Map(), agentStreamHead: new Map(), + messageSubmissions: new Map(), agentTimelineCursor: new Map(), agentTimelineHasOlder: new Map(), agentTimelineOlderFetchInFlight: new Map(), @@ -1047,10 +1101,27 @@ export const useSessionStore = create()( } } - if (!changedTail && !changedHead) { + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const observedSubmissions = observeMessageSubmissionCanonical( + currentSubmissions, + state.acknowledgedClientMessageIds ?? [], + ); + const changedSubmissions = observedSubmissions !== currentSubmissions; + + if (!changedTail && !changedHead && !changedSubmissions) { return prev; } + let messageSubmissions = session.messageSubmissions; + if (changedSubmissions) { + messageSubmissions = new Map(session.messageSubmissions); + if (observedSubmissions.length > 0) { + messageSubmissions.set(agentId, observedSubmissions); + } else { + messageSubmissions.delete(agentId); + } + } + return { ...prev, sessions: { @@ -1059,12 +1130,129 @@ export const useSessionStore = create()( ...session, agentStreamTail: nextTail, agentStreamHead: nextHead, + messageSubmissions, + }, + }, + }; + }); + }, + + beginAgentMessageSubmission: (serverId, agentId, message) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + if (!message.clientMessageId) { + throw new Error("Beginning a message submission requires client identity"); + } + const currentTail = session.agentStreamTail.get(agentId) ?? []; + const currentHead = session.agentStreamHead.get(agentId) ?? []; + const stream = appendSubmittedUserMessage({ + tail: currentTail, + head: currentHead, + message, + }); + const submissions = beginMessageSubmission( + session.messageSubmissions.get(agentId) ?? [], + { clientMessageId: message.clientMessageId, submittedAt: message.timestamp }, + ); + const messageSubmissions = new Map(session.messageSubmissions); + messageSubmissions.set(agentId, submissions); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: + stream.tail === currentTail + ? session.agentStreamTail + : new Map(session.agentStreamTail).set(agentId, stream.tail), + agentStreamHead: + stream.head === currentHead + ? session.agentStreamHead + : new Map(session.agentStreamHead).set(agentId, stream.head), + messageSubmissions, }, }, }; }); }, + acceptAgentMessageSubmission: (serverId, agentId, clientMessageId, outOfBand) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const submissions = acceptMessageSubmission( + currentSubmissions, + clientMessageId, + session.agents.get(agentId)?.status === "running", + outOfBand, + ); + if (submissions === currentSubmissions) return prev; + const messageSubmissions = new Map(session.messageSubmissions); + if (submissions.length > 0) { + messageSubmissions.set(agentId, submissions); + } else { + messageSubmissions.delete(agentId); + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, messageSubmissions }, + }, + }; + }); + }, + + rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => { + let outcome: MessageSubmissionRejectionOutcome = "unknown"; + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const currentTail = session.agentStreamTail.get(agentId) ?? []; + const currentHead = session.agentStreamHead.get(agentId) ?? []; + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const result = rejectMessageSubmission(currentSubmissions, clientMessageId); + outcome = result.outcome; + if (outcome === "unknown") return prev; + const stream = + outcome === "rejected" + ? removeSubmittedUserMessage({ + tail: currentTail, + head: currentHead, + clientMessageId, + }) + : { tail: currentTail, head: currentHead }; + const messageSubmissions = new Map(session.messageSubmissions); + if (result.submissions.length > 0) { + messageSubmissions.set(agentId, result.submissions); + } else { + messageSubmissions.delete(agentId); + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: + stream.tail === currentTail + ? session.agentStreamTail + : new Map(session.agentStreamTail).set(agentId, stream.tail), + agentStreamHead: + stream.head === currentHead + ? session.agentStreamHead + : new Map(session.agentStreamHead).set(agentId, stream.head), + messageSubmissions, + }, + }, + }; + }); + return outcome; + }, + handoffCreatedAgentUserMessage: (serverId, agentId, message) => { let didHandoff = false; set((prev) => { @@ -1298,7 +1486,12 @@ export const useSessionStore = create()( return prev; } const nextAgents = typeof agents === "function" ? agents(session.agents) : agents; - if (session.agents === nextAgents) { + const messageSubmissions = applyRunningAgentsToAcceptedSubmissions({ + previousAgents: session.agents, + nextAgents, + submissions: session.messageSubmissions, + }); + if (session.agents === nextAgents && session.messageSubmissions === messageSubmissions) { return prev; } return { @@ -1308,10 +1501,11 @@ export const useSessionStore = create()( [serverId]: { ...session, agents: nextAgents, - workspaceAgentActivity: buildWorkspaceAgentActivityIndex( - nextAgents, - session.workspaceAgentActivity, - ), + messageSubmissions, + workspaceAgentActivity: + nextAgents === session.agents + ? session.workspaceAgentActivity + : buildWorkspaceAgentActivityIndex(nextAgents, session.workspaceAgentActivity), }, }, }; diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 491da676364..10eed0188cb 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import { - buildOptimisticUserMessage, + createUserMessage, hydrateStreamState, type AgentToolCallItem, type StreamItem, @@ -119,12 +119,12 @@ function makeAssistantItem( }; } -function makeOptimisticUserMessage( +function makeSubmittedUserMessage( text: string, - id = `optimistic-${text.length}`, + id = `submitted-${text.length}`, ): Extract { - return buildOptimisticUserMessage({ - id, + return createUserMessage({ + clientMessageId: id, text, timestamp: new Date(1000), }); @@ -172,6 +172,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = { isInitializing: false, hasActiveInitDeferred: false, initRequestDirection: "tail", + sendingClientMessageIds: [], }; const baseStreamInput: ProcessAgentStreamEventInput = { @@ -181,7 +182,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, timestamp: new Date(2000), }; @@ -293,6 +293,75 @@ describe("processTimelineResponse", () => { expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true); }); + it("keeps a live assistant and submitted head prompt in one lane during replacement", () => { + const submitted = makeSubmittedUserMessage("New prompt", "client-new-prompt"); + const liveAssistant = { + ...makeAssistantItem("Live answer", "answer-1"), + messageId: "answer-1", + }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [], + currentHead: [liveAssistant, submitted], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: ["client-new-prompt"], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + { + ...makeTimelineEntry(1, "Live", "assistant_message"), + item: { + type: "assistant_message", + text: "Live", + messageId: "answer-1", + }, + }, + ], + }, + }); + + expect(result.tail).toEqual([]); + expect(result.head).toEqual([{ ...liveAssistant, text: "Live" }, submitted]); + }); + + it("preserves newer live head items when canonical replacement ends in a tool call", () => { + const liveThought: StreamItem = { + kind: "thought", + id: "live-thought", + text: "newer reasoning", + timestamp: new Date(3000), + status: "loading", + }; + const liveAssistant = makeAssistantItem("newer answer", "live-answer"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: [liveThought, liveAssistant], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + makeToolCallTimelineEntry(1, "canonical-call", "completed", { + type: "read", + filePath: "/tmp/older.ts", + }), + ], + }, + }); + + expect(result.tail.map((item) => item.kind)).toEqual(["tool_call"]); + expect(result.head).toEqual([liveThought, liveAssistant]); + }); + it("uses the timeline entry timestamp as canonical", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -321,12 +390,12 @@ describe("processTimelineResponse", () => { expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z"); }); - it("reconciles an optimistic user message during tail replacement", () => { + it("reconciles a submitted user message during tail replacement", () => { const image = { - id: "optimistic-image", + id: "submitted-image", mimeType: "image/png", storageType: "web-indexeddb" as const, - storageKey: "optimistic-image", + storageKey: "submitted-image", createdAt: 1000, }; const attachment = { @@ -335,8 +404,8 @@ describe("processTimelineResponse", () => { text: "attached context", title: "context.txt", }; - const optimistic = buildOptimisticUserMessage({ - id: "optimistic-create-user", + const submitted = createUserMessage({ + clientMessageId: "submitted-create-user", text: "Analyze this", timestamp: new Date(1000), images: [image], @@ -345,7 +414,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], payload: { ...baseTimelineInput.payload, reset: true, @@ -358,6 +427,7 @@ describe("processTimelineResponse", () => { type: "user_message", text: "server-rendered attachment text", messageId: "canonical-create-user", + clientMessageId: "submitted-create-user", }, }, ], @@ -367,13 +437,14 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(1); expect(userMessages[0]).toMatchObject({ - id: "canonical-create-user", + id: "submitted-create-user", + clientMessageId: "submitted-create-user", + messageId: "canonical-create-user", text: "Analyze this", timestamp: new Date(1000), images: [image], attachments: [attachment], }); - expect(userMessages[0]?.optimistic).toBeUndefined(); const repeated = processTimelineResponse({ ...baseTimelineInput, @@ -390,6 +461,7 @@ describe("processTimelineResponse", () => { type: "user_message", text: "server-rendered attachment text", messageId: "canonical-create-user", + clientMessageId: "submitted-create-user", }, }, ], @@ -399,24 +471,93 @@ describe("processTimelineResponse", () => { expect(repeated.tail.filter((item) => item.kind === "user_message")).toEqual(userMessages); }); - it("keeps an unmatched optimistic user message during tail replacement", () => { - const optimistic = makeOptimisticUserMessage("still sending", "optimistic-unmatched"); + it("keeps an unmatched submitted user message during tail replacement", () => { + const submitted = makeSubmittedUserMessage("still sending", "submitted-unmatched"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [submitted], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: ["submitted-unmatched"], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-2", + entries: [], + }, + }); + + expect(result.tail).toEqual([submitted]); + }); + + it("keeps every unresolved submission during replacement", () => { + const first = makeSubmittedUserMessage("first pending", "client-first"); + const second = makeSubmittedUserMessage("second pending", "client-second"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [first, second], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: ["client-first", "client-second"], payload: { ...baseTimelineInput.payload, reset: true, + epoch: "epoch-2", entries: [], }, }); - expect(result.tail).toEqual([optimistic]); + expect(result.tail).toEqual([first, second]); }); - it("does not move an unmatched submission during timeline replacement", () => { - const unmatched = makeOptimisticUserMessage("first submission", "client-first"); + it("drops an acknowledged local row omitted by a same-epoch replacement", () => { + const acknowledged = createUserMessage({ + clientMessageId: "client-local-only", + text: "provider may not echo this", + timestamp: new Date(1000), + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [acknowledged], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: [], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-1", + entries: [], + }, + }); + + expect(result.tail).toEqual([]); + }); + + it("drops an acknowledged local row omitted by a known epoch change", () => { + const acknowledged = createUserMessage({ + clientMessageId: "client-prior-epoch", + text: "prior prompt", + timestamp: new Date(1000), + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [acknowledged], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: [], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-2", + entries: [], + }, + }); + + expect(result.tail).toEqual([]); + }); + + it("keeps an unmatched submission after the canonical replacement range", () => { + const unmatched = makeSubmittedUserMessage("first submission", "client-first"); const acknowledged: StreamItem[] = [ { kind: "user_message", @@ -437,6 +578,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, currentTail: [unmatched, ...acknowledged], + sendingClientMessageIds: ["client-first"], payload: { ...baseTimelineInput.payload, reset: true, @@ -462,10 +604,10 @@ describe("processTimelineResponse", () => { }, }, { - ...makeTimelineEntry(4, "response to all three submissions"), + ...makeTimelineEntry(4, "response to canonical submissions"), item: { type: "assistant_message", - text: "response to all three submissions", + text: "response to canonical submissions", messageId: "assistant-response", }, }, @@ -480,14 +622,14 @@ describe("processTimelineResponse", () => { text: "text" in item ? item.text : undefined, })), ).toEqual([ - { kind: "user_message", id: "client-first", text: "first submission" }, { kind: "user_message", id: "provider-second", text: "second submission" }, { kind: "user_message", id: "provider-third", text: "third submission" }, { kind: "assistant_message", id: "assistant-response", - text: "response to all three submissions", + text: "response to canonical submissions", }, + { kind: "user_message", id: "client-first", text: "first submission" }, ]); }); @@ -620,11 +762,11 @@ describe("processTimelineResponse", () => { startSeq: 1, endSeq: 1, }; - const optimistic = makeOptimisticUserMessage("sent while catching up", "optimistic-after"); + const submitted = makeSubmittedUserMessage("sent while catching up", "submitted-after"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], currentCursor: existingCursor, payload: { ...baseTimelineInput.payload, @@ -644,16 +786,19 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(1); - expect(userMessages[0]?.id).toBe("canonical-after"); - expect(userMessages[0]?.optimistic).toBeUndefined(); + expect(userMessages[0]).toMatchObject({ + id: "submitted-after", + clientMessageId: "submitted-after", + messageId: "canonical-after", + }); }); - it("reconciles an optimistic user message by client message id", () => { - const optimistic = makeOptimisticUserMessage("local presentation", "client-message"); + it("reconciles a submitted user message by client message id", () => { + const submitted = makeSubmittedUserMessage("local presentation", "client-message"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, payload: { ...baseTimelineInput.payload, @@ -675,20 +820,21 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toEqual([ expect.objectContaining({ - id: "provider-message", + id: "client-message", clientMessageId: "client-message", + messageId: "provider-message", text: "local presentation", }), ]); - expect(userMessages[0]?.optimistic).toBeUndefined(); + expect(result.acknowledgedClientMessageIds).toEqual(["client-message"]); }); - it("reconciles multiple optimistic user messages in canonical order", () => { + it("reconciles multiple submitted user messages in canonical order", () => { const result = processTimelineResponse({ ...baseTimelineInput, currentTail: [ - makeOptimisticUserMessage("first prompt", "optimistic-first"), - makeOptimisticUserMessage("second prompt", "optimistic-second"), + makeSubmittedUserMessage("first prompt", "submitted-first"), + makeSubmittedUserMessage("second prompt", "submitted-second"), ], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, payload: { @@ -699,14 +845,14 @@ describe("processTimelineResponse", () => { entries: [ { ...makeTimelineEntry(2, "first prompt", "user_message"), - item: { type: "user_message", text: "first prompt", messageId: "optimistic-first" }, + item: { type: "user_message", text: "first prompt", messageId: "submitted-first" }, }, { ...makeTimelineEntry(3, "second prompt", "user_message"), item: { type: "user_message", text: "second prompt", - messageId: "optimistic-second", + messageId: "submitted-second", }, }, ], @@ -716,15 +862,15 @@ describe("processTimelineResponse", () => { expect( result.tail .filter((item) => item.kind === "user_message") - .map((item) => ({ id: item.id, text: item.text, optimistic: item.optimistic })), + .map((item) => ({ id: item.id, text: item.text, messageId: item.messageId })), ).toEqual([ - { id: "optimistic-first", text: "first prompt", optimistic: undefined }, - { id: "optimistic-second", text: "second prompt", optimistic: undefined }, + { id: "submitted-first", text: "first prompt", messageId: "submitted-first" }, + { id: "submitted-second", text: "second prompt", messageId: "submitted-second" }, ]); }); - it("keeps a tail optimistic prompt before a reconciled live assistant head", () => { - const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt"); + it("keeps a tail submitted prompt before a reconciled live assistant head", () => { + const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -763,8 +909,8 @@ describe("processTimelineResponse", () => { ).toEqual(["new prompt", "Hello"]); }); - it("keeps a tail optimistic prompt before a live head flushed by catch-up", () => { - const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt"); + it("keeps a tail submitted prompt before a live head flushed by catch-up", () => { + const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -835,7 +981,6 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); const result = processTimelineResponse({ @@ -913,7 +1058,6 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - currentAgent: null, }); expect(getAssistantTexts(live.tail)).toHaveLength(1); expect(getAssistantTexts(live.head)).toHaveLength(1); @@ -961,7 +1105,6 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); const result = processTimelineResponse({ @@ -1000,7 +1143,7 @@ describe("processTimelineResponse", () => { }); it("does not move a submitted prompt when catch-up history arrives", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1024,7 +1167,7 @@ describe("processTimelineResponse", () => { }); it("does not move an unmatched head prompt when catch-up history arrives", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1052,8 +1195,8 @@ describe("processTimelineResponse", () => { ]); }); - it("acknowledges a head prompt in place while catch-up history arrives", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + it("moves an acknowledged head prompt to its catch-up sequence position", () => { + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1084,18 +1227,18 @@ describe("processTimelineResponse", () => { expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ "assistant_message", - "user_message", "tool_call", + "user_message", ]); expect( [...result.tail, ...result.head] .filter((item) => item.kind === "user_message") - .map((item) => item.optimistic), - ).toEqual([undefined]); + .map((item) => item.clientMessageId), + ).toEqual(["new-prompt"]); }); it("does not move a prompt around unrelated catch-up history", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1141,7 +1284,7 @@ describe("processTimelineResponse", () => { }); it("does not move a prompt or its live answer around catch-up history", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -1152,7 +1295,6 @@ describe("processTimelineResponse", () => { currentTail: [prompt], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - currentAgent: null, }); const result = processTimelineResponse({ @@ -1186,7 +1328,7 @@ describe("processTimelineResponse", () => { }); it("does not move a prompt or its live answer around catch-up tool history", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -1197,7 +1339,6 @@ describe("processTimelineResponse", () => { currentTail: [prompt], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - currentAgent: null, }); const result = processTimelineResponse({ @@ -1228,7 +1369,7 @@ describe("processTimelineResponse", () => { }); it("never moves submitted messages behind a later assistant response", () => { - const unmatched = makeOptimisticUserMessage("first submission", "client-first"); + const unmatched = makeSubmittedUserMessage("first submission", "client-first"); const acknowledged: StreamItem[] = [ { kind: "user_message", @@ -1286,8 +1427,8 @@ describe("processTimelineResponse", () => { ]); }); - it("acknowledges a local prompt in place when a remote user row also arrives", () => { - const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt"); + it("places a local prompt after an earlier remote canonical row", () => { + const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1320,17 +1461,12 @@ describe("processTimelineResponse", () => { }); expect( - result.tail - .filter((item) => item.kind === "user_message") - .map((item) => ({ text: item.text, optimistic: item.optimistic })), - ).toEqual([ - { text: "Local prompt", optimistic: undefined }, - { text: "Remote prompt", optimistic: undefined }, - ]); + result.tail.filter((item) => item.kind === "user_message").map((item) => item.text), + ).toEqual(["Remote prompt", "Local prompt"]); }); - it("keeps an unmatched optimistic prompt when catch-up contains only a remote user row", () => { - const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt"); + it("keeps an unmatched submitted prompt when catch-up contains only a remote user row", () => { + const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1355,17 +1491,12 @@ describe("processTimelineResponse", () => { }); expect( - result.tail - .filter((item) => item.kind === "user_message") - .map((item) => ({ text: item.text, optimistic: item.optimistic })), - ).toEqual([ - { text: "Local prompt", optimistic: true }, - { text: "Remote prompt", optimistic: undefined }, - ]); + result.tail.filter((item) => item.kind === "user_message").map((item) => item.text), + ).toEqual(["Local prompt", "Remote prompt"]); }); it("does not match equal prompt text when canonical client message ids differ", () => { - const prompt = makeOptimisticUserMessage("continue", "local-prompt"); + const prompt = makeSubmittedUserMessage("continue", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1393,10 +1524,10 @@ describe("processTimelineResponse", () => { expect( result.tail .filter((item) => item.kind === "user_message") - .map((item) => ({ id: item.id, optimistic: item.optimistic })), + .map((item) => ({ id: item.id, messageId: item.messageId })), ).toEqual([ - { id: "local-prompt", optimistic: true }, - { id: "remote-prompt", optimistic: undefined }, + { id: "local-prompt", messageId: undefined }, + { id: "remote-prompt", messageId: "remote-prompt" }, ]); }); @@ -1639,8 +1770,8 @@ describe("processTimelineResponse", () => { }); }); - it("does not reconcile an active optimistic user message from a before-page response", () => { - const optimistic = makeOptimisticUserMessage("active prompt", "optimistic-active"); + it("does not reconcile an active submitted user message from a before-page response", () => { + const submitted = makeSubmittedUserMessage("active prompt", "submitted-active"); const existingCursor: TimelineCursor = { epoch: "epoch-1", startSeq: 3, @@ -1649,7 +1780,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], currentCursor: existingCursor, payload: { ...baseTimelineInput.payload, @@ -1672,8 +1803,8 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(2); - expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "optimistic-active"]); - expect(userMessages[1]?.optimistic).toBe(true); + expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "submitted-active"]); + expect(userMessages[1]?.clientMessageId).toBe("submitted-active"); }); it("leaves the cursor alone when a before page makes no progress", () => { @@ -1813,6 +1944,68 @@ describe("processTimelineResponse", () => { ]); }); + it("removes a reconciled submitted prompt before coalescing a tool call at the pagination seam", () => { + const clientMessageId = "client-boundary-prompt"; + const callId = "toolu_submitted_boundary"; + const currentTail = [ + makeSubmittedUserMessage("Inspect the file", clientMessageId), + ...hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "claude", + item: makeToolCallTimelineEntry(3, callId, "completed", { + type: "read", + filePath: "/tmp/example.ts", + }).item, + } as AgentStreamEventPayload, + timestamp: new Date(3000), + }, + ], + { source: "canonical" }, + ), + ]; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentCursor: { epoch: "epoch-1", startSeq: 3, endSeq: 5 }, + payload: { + ...baseTimelineInput.payload, + direction: "before", + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 2 }, + entries: [ + { + ...makeTimelineEntry(1, "Inspect the file", "user_message"), + item: { + type: "user_message", + text: "Inspect the file", + messageId: "provider-boundary-prompt", + clientMessageId, + }, + }, + makeToolCallTimelineEntry(2, callId, "running", { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }), + ], + }, + }); + + expect(result.tail.filter((item) => item.kind === "user_message")).toHaveLength(1); + expect(getAgentToolCalls(result.tail)).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => { const callId = "toolu_not_boundary"; const currentTail = hydrateStreamState( @@ -2224,152 +2417,6 @@ describe("processAgentStreamEvent", () => { endSeq: 1, }); }); - - it("derives optimistic idle status on turn_completed for running agent", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent).not.toBe(null); - expect(result.agent!.status).toBe("idle"); - expect(result.agent!.updatedAt.getTime()).toBe(2000); - expect(result.agent!.lastActivityAt.getTime()).toBe(2000); - }); - - it("derives optimistic error status on turn_failed for running agent", () => { - const turnFailedEvent: AgentStreamEventPayload = { - type: "turn_failed", - provider: "claude", - error: "something broke", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnFailedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent!.status).toBe("error"); - }); - - it("does not derive optimistic idle status on turn_canceled for running agent", () => { - const turnCanceledEvent: AgentStreamEventPayload = { - type: "turn_canceled", - provider: "codex", - reason: "interrupted", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCanceledEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("does not change agent when status is not running", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "idle", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("does not change agent when no agent is provided", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: null, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("preserves updatedAt when agent timestamp is newer than event", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(5000), - lastActivityAt: new Date(5000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent!.updatedAt.getTime()).toBe(5000); - expect(result.agent!.lastActivityAt.getTime()).toBe(5000); - }); - - it("does not produce agent patch for non-terminal events", () => { - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("just text"), - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - seq: 1, - epoch: "epoch-1", - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); }); describe("processAgentStreamEvents", () => { @@ -2382,7 +2429,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(false); @@ -2410,7 +2456,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(false); @@ -2433,7 +2478,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2451,7 +2495,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2469,7 +2512,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2501,7 +2543,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2527,7 +2568,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2552,7 +2592,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2644,7 +2683,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ @@ -2655,7 +2693,7 @@ describe("processAgentStreamEvents", () => { ]); }); - it("returns the final optimistic lifecycle patch across a batch", () => { + it("does not derive lifecycle state from a terminal event in a batch", () => { const result = processAgentStreamEvents({ events: [ makeStreamReducerEvent(makeTimelineEvent("Done"), 1), @@ -2669,21 +2707,10 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, }); expect(result.head).toEqual([]); expect(result.tail).toHaveLength(1); - expect(result.agentChanged).toBe(true); - expect(result.agent).toMatchObject({ - status: "idle", - updatedAt: new Date(3000), - lastActivityAt: new Date(3000), - }); }); it("keeps a live Claude assistant paragraph contiguous when init tail hydration lands mid-stream", () => { @@ -2832,7 +2859,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail, currentHead, currentCursor: undefined, - currentAgent: null, }), commit: (agentId, result) => { currentTail = result.tail; @@ -2874,7 +2900,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }), commit: (agentId, result) => { commits.push( @@ -2904,7 +2929,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail, currentHead, currentCursor, - currentAgent: null, }), commit: (_agentId, result) => { currentTail = result.tail; @@ -2954,7 +2978,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }), commit: (agentId, result) => { commits.push( diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 7458e855d34..2690bfb5009 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,15 +1,15 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; -import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; -import type { Agent } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; -import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream"; +import type { AssistantMessageItem, StreamItem } from "@/types/stream"; import { applyStreamEvent, flushHeadToTail, hydrateStreamState, isAgentToolCallItem, mergeAgentToolCallItem, + replaceWithCanonicalStream, reduceStreamUpdate, + upsertUserMessageAcrossStream, } from "@/types/stream"; const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3; @@ -88,6 +88,7 @@ export interface ProcessTimelineResponseInput { isInitializing: boolean; hasActiveInitDeferred: boolean; initRequestDirection: InitRequestDirection; + sendingClientMessageIds: readonly string[]; } export interface ProcessTimelineResponseOutput { @@ -99,6 +100,7 @@ export interface ProcessTimelineResponseOutput { clearInitializing: boolean; error: string | null; sideEffects: TimelineReducerSideEffect[]; + acknowledgedClientMessageIds: string[]; } interface TimelineUnit { @@ -115,6 +117,7 @@ interface TimelinePathResult { cursor: TimelineCursor | null | undefined; cursorChanged: boolean; sideEffects: TimelineReducerSideEffect[]; + acknowledgedClientMessageIds: string[]; } function classifySessionTimelineSeq({ @@ -201,76 +204,35 @@ function shouldResolveTimelineInit({ return responseDirection === initRequestDirection; } -function deriveOptimisticLifecycleStatus( - currentStatus: AgentLifecycleStatus, - event: AgentStreamEventPayload, -): AgentLifecycleStatus | null { - if (currentStatus !== "running") { - return null; - } - switch (event.type) { - case "turn_completed": - return "idle"; - case "turn_failed": - return "error"; - case "turn_canceled": - // A canceled turn can be either a final user cancel or an interrupt before - // a replacement turn starts. The daemon snapshot is authoritative here. - return null; - default: - return null; - } -} - -function preserveReplacePathAssistantHead(params: { - tail: StreamItem[]; - currentHead: StreamItem[]; -}): { - tail: StreamItem[]; - head: StreamItem[]; -} { - const { tail, currentHead } = params; - const liveAssistant = currentHead.findLast( - (item): item is Extract => - item.kind === "assistant_message", - ); - if (!liveAssistant) { - return { tail, head: [] }; - } - const tailAssistant = tail.at(-1); - if (!tailAssistant || tailAssistant.kind !== "assistant_message") { - return { tail, head: currentHead }; - } - if (!liveAssistant.text.startsWith(tailAssistant.text)) { - return { tail, head: [] }; - } - return { - tail: tail.slice(0, -1), - head: [{ ...liveAssistant, text: tailAssistant.text }], - }; -} - function applyTimelineReplacePath(args: { timelineUnits: TimelineUnit[]; payload: ProcessTimelineResponseInput["payload"]; bootstrapPolicy: ReturnType; currentTail: StreamItem[]; currentHead: StreamItem[]; + sendingClientMessageIds: readonly string[]; + preserveLiveHead: boolean; toHydratedEvents: ( units: TimelineUnit[], ) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>; }): TimelinePathResult { - const { timelineUnits, payload, bootstrapPolicy, currentTail, currentHead, toHydratedEvents } = - args; + const { + timelineUnits, + payload, + bootstrapPolicy, + currentTail, + currentHead, + sendingClientMessageIds, + preserveLiveHead, + toHydratedEvents, + } = args; const hydratedTail = hydrateStreamState(toHydratedEvents(timelineUnits), { source: "canonical" }); - const reconciledTail = reconcileLocalUserPresentationAfterReplace({ - canonicalTail: hydratedTail, + const { tail, head, acknowledgedClientMessageIds } = replaceWithCanonicalStream({ + canonical: hydratedTail, previousTail: currentTail, previousHead: currentHead, - }); - const { tail, head } = preserveReplacePathAssistantHead({ - tail: reconciledTail, - currentHead, + sendingClientMessageIds, + preserveLiveHead, }); const cursor: TimelineCursor | null = payload.startCursor && payload.endCursor @@ -284,136 +246,16 @@ function applyTimelineReplacePath(args: { if (bootstrapPolicy.catchUpCursor) { sideEffects.push({ type: "catch_up", cursor: bootstrapPolicy.catchUpCursor }); } - return { tail, head, cursor, cursorChanged: true, sideEffects }; -} - -function collectLocallyPresentedUserMessages(items: StreamItem[]): Array<{ - ordinal: number; - item: UserMessageItem; -}> { - const localUsers: Array<{ ordinal: number; item: UserMessageItem }> = []; - let ordinal = 0; - for (const item of items) { - if (item.kind !== "user_message") { - continue; - } - if (item.optimistic || item.images?.length || item.attachments?.length) { - localUsers.push({ ordinal, item }); - } - ordinal += 1; - } - return localUsers; -} - -function mergeCanonicalUserWithLocalPresentation( - canonical: UserMessageItem, - local: UserMessageItem, -): UserMessageItem { return { - kind: "user_message", - id: canonical.id, - ...(canonical.clientMessageId ? { clientMessageId: canonical.clientMessageId } : {}), - text: local.text, - timestamp: local.timestamp, - ...(local.images && local.images.length > 0 ? { images: local.images } : {}), - ...(local.attachments && local.attachments.length > 0 - ? { attachments: local.attachments } - : {}), + tail, + head, + cursor, + cursorChanged: true, + sideEffects, + acknowledgedClientMessageIds, }; } -interface CanonicalUserMessageIdentity { - messageId?: string; - clientMessageId?: string; - text: string; -} - -function matchesLocalUserMessageIdentity( - canonical: CanonicalUserMessageIdentity, - optimistic: UserMessageItem, -): boolean { - if (canonical.clientMessageId !== undefined) { - return canonical.clientMessageId === optimistic.id; - } - if (canonical.messageId === optimistic.id) { - return true; - } - // COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once - // the supported daemon floor emits clientMessageId on submitted user messages. - return canonical.text.length > 0 && canonical.text === optimistic.text; -} - -function reconcileLocalUserPresentationAfterReplace(params: { - canonicalTail: StreamItem[]; - previousTail: StreamItem[]; - previousHead: StreamItem[]; -}): StreamItem[] { - const localUsers = collectLocallyPresentedUserMessages([ - ...params.previousTail, - ...params.previousHead, - ]); - if (localUsers.length === 0) { - return params.canonicalTail; - } - - const canonicalUserIndexes: number[] = []; - params.canonicalTail.forEach((item, index) => { - if (item.kind === "user_message") { - canonicalUserIndexes.push(index); - } - }); - - const nextTail = [...params.canonicalTail]; - const claimedCanonicalIndexes = new Set(); - const unmatched: UserMessageItem[] = []; - - for (const local of localUsers) { - const exactIndex = canonicalUserIndexes.find((index) => { - if (claimedCanonicalIndexes.has(index)) return false; - const canonical = params.canonicalTail[index]; - return ( - canonical?.kind === "user_message" && - matchesLocalUserMessageIdentity( - { - messageId: canonical.id, - clientMessageId: canonical.clientMessageId, - text: canonical.text, - }, - local.item, - ) - ); - }); - const ordinalIndex = canonicalUserIndexes[local.ordinal]; - const ordinalItem = ordinalIndex === undefined ? undefined : params.canonicalTail[ordinalIndex]; - const canonicalIndex = - exactIndex ?? - (ordinalIndex !== undefined && - !claimedCanonicalIndexes.has(ordinalIndex) && - ordinalItem?.kind === "user_message" && - ordinalItem.clientMessageId === undefined - ? ordinalIndex - : undefined); - const canonicalItem = canonicalIndex === undefined ? undefined : nextTail[canonicalIndex]; - if (canonicalIndex === undefined || !canonicalItem || canonicalItem.kind !== "user_message") { - if (local.item.optimistic) { - unmatched.push(local.item); - } - continue; - } - nextTail[canonicalIndex] = mergeCanonicalUserWithLocalPresentation(canonicalItem, local.item); - claimedCanonicalIndexes.add(canonicalIndex); - } - - for (const item of unmatched) { - const insertionIndex = nextTail.findIndex( - (canonical) => canonical.timestamp.getTime() > item.timestamp.getTime(), - ); - nextTail.splice(insertionIndex < 0 ? nextTail.length : insertionIndex, 0, item); - } - - return nextTail; -} - interface IncrementalAcceptResult { acceptedUnits: TimelineUnit[]; cursor: TimelineCursor | undefined; @@ -515,6 +357,34 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea return olderTail; } + const remainingOlder: StreamItem[] = []; + let reconciledCurrent = currentTail; + for (const item of olderTail) { + if (item.kind !== "user_message") { + remainingOlder.push(item); + continue; + } + const result = upsertUserMessageAcrossStream({ + tail: reconciledCurrent, + head: [], + message: item, + insert: "prepend-tail", + presentation: "existing", + }); + if (result.location?.matched) { + remainingOlder.push(result.location.message); + reconciledCurrent = [ + ...result.tail.slice(0, result.location.index), + ...result.tail.slice(result.location.index + 1), + ]; + } else { + remainingOlder.push(item); + } + } + olderTail = remainingOlder; + currentTail = reconciledCurrent; + if (olderTail.length === 0) return currentTail; + const olderLast = olderTail.at(-1); const currentFirst = currentTail[0]; @@ -745,9 +615,24 @@ function applyCanonicalForwardUnit(params: { head: StreamItem[]; unit: TimelineUnit; epoch: string; -}): { tail: StreamItem[]; head: StreamItem[] } { +}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { const { event, timestamp, seqEnd } = params.unit; const timelineCursor = { epoch: params.epoch, seq: seqEnd }; + if (event.type === "timeline" && event.item.type === "user_message") { + const applied = applyStreamEvent({ + tail: params.tail, + head: params.head, + event, + timestamp, + source: "canonical", + timelineCursor, + }); + return { + tail: applied.tail, + head: applied.head, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], + }; + } if (params.head.length === 0) { return { tail: reduceStreamUpdate(params.tail, event, timestamp, { @@ -755,6 +640,7 @@ function applyCanonicalForwardUnit(params: { timelineCursor, }), head: params.head, + acknowledgedClientMessageIds: [], }; } const replacedHead = replaceLiveAssistantWithProjectedText({ @@ -763,7 +649,9 @@ function applyCanonicalForwardUnit(params: { timestamp, timelineCursor, }); - if (replacedHead) return { tail: params.tail, head: replacedHead }; + if (replacedHead) { + return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] }; + } const activeAssistant = params.head.findLast( (item): item is Extract => @@ -781,6 +669,7 @@ function applyCanonicalForwardUnit(params: { source: "canonical", timelineCursor, }), + acknowledgedClientMessageIds: [], }; } @@ -792,7 +681,11 @@ function applyCanonicalForwardUnit(params: { source: "canonical", timelineCursor, }); - return { tail: applied.tail, head: applied.head }; + return { + tail: applied.tail, + head: applied.head, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], + }; } function applyAcceptedForwardTimelineUnits(params: { @@ -801,7 +694,7 @@ function applyAcceptedForwardTimelineUnits(params: { currentTail: StreamItem[]; currentHead: StreamItem[]; currentEndSeq: number | undefined; -}): { tail: StreamItem[]; head: StreamItem[] } { +}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { const reconciled = reconcileOverlappingProjectedStreamItems({ tail: params.currentTail, head: params.currentHead, @@ -811,15 +704,19 @@ function applyAcceptedForwardTimelineUnits(params: { }); let tail = reconciled.tail; let head = reconciled.head; + const acknowledgedClientMessageIds = new Set(); for (const unit of params.units) { if (reconciled.reconciledUnits.has(unit)) continue; const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch }); tail = applied.tail; head = applied.head; + for (const clientMessageId of applied.acknowledgedClientMessageIds) { + acknowledgedClientMessageIds.add(clientMessageId); + } } - return { tail, head }; + return { tail, head, acknowledgedClientMessageIds: [...acknowledgedClientMessageIds] }; } function applyTimelineIncrementalPath(args: { @@ -835,9 +732,17 @@ function applyTimelineIncrementalPath(args: { let nextCursor: TimelineCursor | null | undefined = currentCursor; let cursorChanged = false; const sideEffects: TimelineReducerSideEffect[] = []; + let acknowledgedClientMessageIds: string[] = []; if (timelineUnits.length === 0) { - return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects }; + return { + tail: nextTail, + head: nextHead, + cursor: nextCursor, + cursorChanged, + sideEffects, + acknowledgedClientMessageIds, + }; } const { acceptedUnits, cursor, gapCursor } = @@ -874,6 +779,7 @@ function applyTimelineIncrementalPath(args: { }); nextTail = applied.tail; nextHead = applied.head; + acknowledgedClientMessageIds = applied.acknowledgedClientMessageIds; } } @@ -892,7 +798,14 @@ function applyTimelineIncrementalPath(args: { sideEffects.push({ type: "catch_up", cursor: gapCursor }); } - return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects }; + return { + tail: nextTail, + head: nextHead, + cursor: nextCursor, + cursorChanged, + sideEffects, + acknowledgedClientMessageIds, + }; } export function processTimelineResponse( @@ -906,6 +819,7 @@ export function processTimelineResponse( isInitializing, hasActiveInitDeferred, initRequestDirection, + sendingClientMessageIds, } = input; // ------------------------------------------------------------------ @@ -921,6 +835,7 @@ export function processTimelineResponse( clearInitializing: isInitializing, error: payload.error, sideEffects: [], + acknowledgedClientMessageIds: [], }; } @@ -967,7 +882,6 @@ export function processTimelineResponse( hasActiveInitDeferred, }); const replace = bootstrapPolicy.replace; - const sideEffects: TimelineReducerSideEffect[] = []; const timelineResult = replace ? applyTimelineReplacePath({ @@ -976,6 +890,8 @@ export function processTimelineResponse( bootstrapPolicy, currentTail, currentHead, + sendingClientMessageIds, + preserveLiveHead: currentCursor?.epoch === payload.epoch, toHydratedEvents, }) : applyTimelineIncrementalPath({ @@ -1024,6 +940,7 @@ export function processTimelineResponse( clearInitializing, error: null, sideEffects, + acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds, }; } @@ -1038,20 +955,9 @@ export interface ProcessAgentStreamEventInput { currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - currentAgent: { - status: AgentLifecycleStatus; - updatedAt: Date; - lastActivityAt: Date; - } | null; timestamp: Date; } -export interface AgentPatch { - status: AgentLifecycleStatus; - updatedAt: Date; - lastActivityAt: Date; -} - export interface ProcessAgentStreamEventOutput { tail: StreamItem[]; head: StreamItem[]; @@ -1059,8 +965,7 @@ export interface ProcessAgentStreamEventOutput { changedHead: boolean; cursor: TimelineCursor | null; cursorChanged: boolean; - agent: AgentPatch | null; - agentChanged: boolean; + acknowledgedClientMessageIds: string[]; sideEffects: AgentStreamReducerSideEffect[]; } @@ -1079,18 +984,11 @@ interface TimelineSequencingGateResult { sideEffects: AgentStreamReducerSideEffect[]; } -export interface AgentStreamReducerAgentSnapshot { - status: AgentLifecycleStatus; - updatedAt: Date; - lastActivityAt: Date; -} - export interface ProcessAgentStreamEventsInput { events: AgentStreamReducerEvent[]; currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - currentAgent: AgentStreamReducerAgentSnapshot | null; } export type AgentStreamReducerSnapshot = Omit; @@ -1114,20 +1012,6 @@ export interface CreateAgentStreamReducerQueueInput { cancelFlush: (id: number) => void; } -function applyAgentPatch( - currentAgent: AgentStreamReducerAgentSnapshot | null, - patch: AgentPatch | null, -): AgentStreamReducerAgentSnapshot | null { - if (!currentAgent || !patch) { - return currentAgent; - } - return { - status: patch.status, - updatedAt: patch.updatedAt, - lastActivityAt: patch.lastActivityAt, - }; -} - function processTimelineSequencingGate(input: { event: AgentStreamEventPayload; seq: number | undefined; @@ -1201,8 +1085,7 @@ function processTimelineSequencingGate(input: { export function processAgentStreamEvent( input: ProcessAgentStreamEventInput, ): ProcessAgentStreamEventOutput { - const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } = - input; + const { event, seq, epoch, currentTail, currentHead, currentCursor, timestamp } = input; const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor }); const timelineCursor = @@ -1213,7 +1096,7 @@ export function processAgentStreamEvent( // ------------------------------------------------------------------ // Apply stream event to tail/head // ------------------------------------------------------------------ - const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent + const applied = sequencing.shouldApplyStreamEvent ? applyStreamEvent({ tail: sequencing.resetLiveTimeline ? [] : currentTail, head: sequencing.resetLiveTimeline ? [] : currentHead, @@ -1229,43 +1112,14 @@ export function processAgentStreamEvent( changedHead: false, }; - // ------------------------------------------------------------------ - // Optimistic lifecycle status - // ------------------------------------------------------------------ - let agentPatch: AgentPatch | null = null; - let agentChanged = false; - - if ( - currentAgent && - (event.type === "turn_completed" || - event.type === "turn_canceled" || - event.type === "turn_failed") - ) { - const optimisticStatus = deriveOptimisticLifecycleStatus(currentAgent.status, event); - if (optimisticStatus) { - const nextUpdatedAtMs = Math.max(currentAgent.updatedAt.getTime(), timestamp.getTime()); - const nextLastActivityAtMs = Math.max( - currentAgent.lastActivityAt.getTime(), - timestamp.getTime(), - ); - agentPatch = { - status: optimisticStatus, - updatedAt: new Date(nextUpdatedAtMs), - lastActivityAt: new Date(nextLastActivityAtMs), - }; - agentChanged = true; - } - } - return { - tail, - head, - changedTail, - changedHead, + tail: applied.tail, + head: applied.head, + changedTail: applied.changedTail, + changedHead: applied.changedHead, cursor: sequencing.nextTimelineCursor, cursorChanged: sequencing.cursorChanged, - agent: agentPatch, - agentChanged, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], sideEffects: sequencing.sideEffects, }; } @@ -1276,12 +1130,10 @@ export function processAgentStreamEvents( let tail = input.currentTail; let head = input.currentHead; let cursor = input.currentCursor; - let agent = input.currentAgent; let changedTail = false; let changedHead = false; let cursorChanged = false; - let agentPatch: AgentPatch | null = null; - let agentChanged = false; + const acknowledgedClientMessageIds = new Set(); const sideEffects: AgentStreamReducerSideEffect[] = []; for (const reducerEvent of input.events) { @@ -1292,7 +1144,6 @@ export function processAgentStreamEvents( currentTail: tail, currentHead: head, currentCursor: cursor, - currentAgent: agent, timestamp: reducerEvent.timestamp, }); @@ -1301,17 +1152,14 @@ export function processAgentStreamEvents( changedTail = changedTail || result.changedTail; changedHead = changedHead || result.changedHead; sideEffects.push(...result.sideEffects); + for (const clientMessageId of result.acknowledgedClientMessageIds) { + acknowledgedClientMessageIds.add(clientMessageId); + } if (result.cursorChanged) { cursor = result.cursor ?? undefined; cursorChanged = true; } - - if (result.agentChanged) { - agentPatch = result.agent; - agentChanged = true; - agent = applyAgentPatch(agent, result.agent); - } } return { @@ -1321,8 +1169,7 @@ export function processAgentStreamEvents( changedHead, cursor: cursor ?? null, cursorChanged, - agent: agentPatch, - agentChanged, + acknowledgedClientMessageIds: [...acknowledgedClientMessageIds], sideEffects, }; } @@ -1405,6 +1252,7 @@ export function createAgentStreamReducerQueue( interface StreamStatePatch { tail?: StreamItem[]; head?: StreamItem[]; + acknowledgedClientMessageIds?: readonly string[]; } export interface CreateSessionAgentStreamReducerQueueInput { @@ -1414,7 +1262,6 @@ export interface CreateSessionAgentStreamReducerQueueInput { serverId: string, state: (prev: Map) => Map, ) => void; - setAgents: (serverId: string, state: (prev: Map) => Map) => void; recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; } @@ -1429,31 +1276,29 @@ function cancelAgentStreamReducerFlush(id: number) { export function createSessionAgentStreamReducerQueue( input: CreateSessionAgentStreamReducerQueueInput, ): AgentStreamReducerQueue { - const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } = - input; + const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input; return createAgentStreamReducerQueue({ getSnapshot: (agentId) => { const session = useSessionStore.getState().sessions[serverId]; - const currentAgentEntry = session?.agents.get(agentId); return { currentTail: session?.agentStreamTail.get(agentId) ?? [], currentHead: session?.agentStreamHead.get(agentId) ?? [], currentCursor: session?.agentTimelineCursor.get(agentId), - currentAgent: currentAgentEntry - ? { - status: currentAgentEntry.status, - updatedAt: currentAgentEntry.updatedAt, - lastActivityAt: currentAgentEntry.lastActivityAt, - } - : null, }; }, commit: (agentId, result, events) => { - if (result.changedTail || result.changedHead) { + if ( + result.changedTail || + result.changedHead || + result.acknowledgedClientMessageIds.length > 0 + ) { setAgentStreamState(serverId, agentId, { ...(result.changedTail ? { tail: result.tail } : {}), ...(result.changedHead ? { head: result.head } : {}), + ...(result.acknowledgedClientMessageIds.length > 0 + ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } + : {}), }); } @@ -1486,24 +1331,6 @@ export function createSessionAgentStreamReducerQueue( return next; }); } - - if (result.agentChanged && result.agent) { - const nextAgent = result.agent; - setAgents(serverId, (prev) => { - const current = prev.get(agentId); - if (!current) { - return prev; - } - const next = new Map(prev); - next.set(agentId, { - ...current, - status: nextAgent.status, - updatedAt: nextAgent.updatedAt, - lastActivityAt: nextAgent.lastActivityAt, - }); - return next; - }); - } }, handleSideEffects: (agentId, sideEffects) => { for (const effect of sideEffects) { diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index d63d2de7d53..3945edbcb7d 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -22,34 +22,16 @@ function assistant(id: string, timestamp: Date): StreamItem { } describe("deriveStreamTurnTiming", () => { - it("reserves a running footer for an optimistic prompt before the host starts the turn", () => { - const optimisticPrompt = { - ...user("optimistic", new Date("2026-05-15T00:00:00.000Z")), - optimistic: true as const, - }; - - const timing = deriveStreamTurnTiming({ - agentStatus: "idle", - tail: [], - head: [optimisticPrompt], - }); - - assert.equal(timing.isActive, true); - }); - - it("does not start elapsed time from an optimistic prompt", () => { - const optimisticPrompt = { - ...user("optimistic", new Date("2026-05-15T00:00:00.000Z")), - optimistic: true as const, - }; + it("starts elapsed time from the submitted prompt", () => { + const submittedAt = new Date("2026-05-15T00:00:00.000Z"); const timing = deriveStreamTurnTiming({ agentStatus: "running", tail: [], - head: [optimisticPrompt], + head: [user("submitted", submittedAt)], }); - assert.equal(timing.runningStartedAt, null); + assert.equal(timing.runningStartedAt, submittedAt); }); it("uses the last user message as the running turn start", () => { diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index 4356945243f..7ebd77c2143 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -9,7 +9,6 @@ export interface TurnTiming { export interface StreamTurnTiming { byAssistantId: Map; runningStartedAt: Date | null; - isActive: boolean; } export function deriveStreamTurnTiming(params: { @@ -19,8 +18,6 @@ export function deriveStreamTurnTiming(params: { }): StreamTurnTiming { const byAssistantId = new Map(); let currentUserAt: Date | null = null; - let currentAuthoritativeUserAt: Date | null = null; - let currentUserIsOptimistic = false; let currentLastItemAt: Date | null = null; let currentAssistantIds: string[] = []; @@ -42,8 +39,6 @@ export function deriveStreamTurnTiming(params: { if (item.kind === "user_message") { flushCompletedTurn(); currentUserAt = item.timestamp; - currentAuthoritativeUserAt = item.optimistic ? null : item.timestamp; - currentUserIsOptimistic = item.optimistic === true; currentLastItemAt = null; currentAssistantIds = []; return; @@ -65,7 +60,7 @@ export function deriveStreamTurnTiming(params: { } const isRunning = params.agentStatus === "running"; - const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null; + const runningStartedAt = isRunning ? currentUserAt : null; if (params.agentStatus !== "running") { flushCompletedTurn(); } @@ -73,6 +68,5 @@ export function deriveStreamTurnTiming(params: { return { byAssistantId, runningStartedAt, - isActive: isRunning || currentUserIsOptimistic, }; } diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index fb68d7a020d..00d73b7f65f 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -4,9 +4,7 @@ import { describe, expect, it } from "vitest"; import { applyStreamEvent, - appendOptimisticUserMessageToStream, - buildOptimisticUserMessage, - clearOptimisticUserMessages, + createUserMessage, handoffCreatedAgentUserMessageToStream, hydrateStreamState, mergeToolCallDetail, @@ -14,6 +12,8 @@ import { type AgentToolCallItem, type StreamItem, isAgentToolCallItem, + upsertUserMessage, + upsertUserMessageAcrossStream, } from "./stream"; import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; @@ -21,6 +21,109 @@ import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display" type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled"; +describe("user message identity", () => { + it("adds provider identity without replacing local presentation", () => { + const timestamp = new Date("2026-07-26T10:00:00.000Z"); + const local = createUserMessage({ + clientMessageId: "client-1", + text: "local text", + timestamp, + images: [ + { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-1.png", + createdAt: timestamp.getTime(), + }, + ], + attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }], + }); + const canonical = createUserMessage({ + id: "provider-1", + messageId: "provider-1", + clientMessageId: "client-1", + text: "provider text", + timestamp: new Date("2026-07-26T10:00:01.000Z"), + }); + + const first = upsertUserMessage([local], canonical); + const second = upsertUserMessage(first, canonical); + + expect(first).toEqual([ + { + ...local, + messageId: "provider-1", + clientMessageId: "client-1", + }, + ]); + expect(first[0]).toBe(second[0]); + }); + + it("keeps local presentation when a later canonical row omits provider identity", () => { + const timestamp = new Date("2026-07-27T10:00:00.000Z"); + const local = createUserMessage({ + clientMessageId: "client-1", + messageId: "provider-1", + text: "local text", + timestamp, + images: [ + { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-1.png", + createdAt: timestamp.getTime(), + }, + ], + attachments: [{ type: "text", mimeType: "text/plain", text: "local attachment" }], + }); + const canonicalWithoutProviderIdentity = createUserMessage({ + id: "canonical-page-row", + clientMessageId: "client-1", + text: "provider-shaped text", + timestamp: new Date("2026-07-27T10:00:01.000Z"), + }); + + const result = upsertUserMessage([local], canonicalWithoutProviderIdentity); + + expect(result).toEqual([local]); + }); + + it("matches a submitted message against a legacy canonical row that has no client identity", () => { + // Daemons before v0.2.0 do not echo clientMessageId. During agent creation the + // legacy canonical row can land before the local submission is handed off, so the + // submitted row arrives as `incoming` and must still match by text. + const timestamp = new Date("2026-07-27T11:00:00.000Z"); + const legacyCanonical = createUserMessage({ + id: "provider-1", + messageId: "provider-1", + text: "review this", + timestamp, + }); + const submitted = createUserMessage({ + clientMessageId: "client-1", + text: "review this", + timestamp: new Date("2026-07-27T11:00:01.000Z"), + attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }], + }); + + const result = handoffCreatedAgentUserMessageToStream({ + tail: [legacyCanonical], + head: [], + message: submitted, + }); + + expect(result.tail).toEqual([ + { + ...submitted, + id: "client-1", + messageId: "provider-1", + }, + ]); + }); +}); + function assistantTimeline( text: string, provider: AgentProvider = "claude", @@ -895,15 +998,15 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(todos.items[0]?.text, "Task 1"); }); - it("preserves optimistic user message images when authoritative user message arrives", () => { + it("preserves submitted user message images when authoritative user message arrives", () => { const messageId = "msg-user-images"; - const optimisticTimestamp = new Date("2025-01-01T11:10:00Z"); - const optimisticImages = [ + const submittedTimestamp = new Date("2025-01-01T11:10:00Z"); + const submittedImages = [ { - id: "att-optimistic", + id: "att-submitted", mimeType: "image/jpeg", storageType: "native-file" as const, - storageKey: "/tmp/optimistic.jpg", + storageKey: "/tmp/submitted.jpg", createdAt: Date.now(), }, ]; @@ -911,10 +1014,10 @@ describe("stream reducer canonical tool calls", () => { { kind: "user_message", id: messageId, + clientMessageId: messageId, text: "Analyze this image", - timestamp: optimisticTimestamp, - optimistic: true, - images: optimisticImages, + timestamp: submittedTimestamp, + images: submittedImages, }, ]; const event: AgentStreamEventPayload = { @@ -933,9 +1036,9 @@ describe("stream reducer canonical tool calls", () => { assert.ok(message); assert.strictEqual(message.id, messageId); - assert.deepStrictEqual(message.images, optimisticImages); + assert.deepStrictEqual(message.images, submittedImages); assert.strictEqual(message.text, "Analyze this image"); - assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime()); + assert.strictEqual(message.timestamp.getTime(), submittedTimestamp.getTime()); }); it("keeps canonical assistant/user/assistant order during replay", () => { @@ -981,7 +1084,7 @@ describe("stream reducer canonical tool calls", () => { ); }); - it("keeps live optimistic assistant merge behavior", () => { + it("keeps live submitted assistant merge behavior", () => { const state: StreamItem[] = [ { kind: "assistant_message", @@ -1117,23 +1220,23 @@ describe("turn lifecycle events", () => { }); it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])( - "replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching", + "replaces a submitted user message when a live %s provider-owned id echo arrives without text matching", (provider) => { - const optimisticTimestamp = new Date("2025-01-01T15:02:00Z"); + const submittedTimestamp = new Date("2025-01-01T15:02:00Z"); const serverTimestamp = new Date("2025-01-01T15:02:01Z"); - const optimistic: StreamItem = { + const submitted: StreamItem = { kind: "user_message", - id: "msg_optimistic", + id: "msg_submitted", + clientMessageId: "msg_submitted", text: "same user text", - timestamp: optimisticTimestamp, - optimistic: true, + timestamp: submittedTimestamp, images: [ { id: "image-1", mimeType: "image/png", storageType: "web-indexeddb", storageKey: "image-1", - createdAt: optimisticTimestamp.getTime(), + createdAt: submittedTimestamp.getTime(), }, ], attachments: [ @@ -1147,7 +1250,7 @@ describe("turn lifecycle events", () => { }; const state = reduceStreamUpdate( - [optimistic], + [submitted], { type: "timeline", provider, @@ -1155,6 +1258,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "server-owned rendered text", messageId: "provider-owned-id", + clientMessageId: "msg_submitted", }, }, serverTimestamp, @@ -1165,28 +1269,28 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - assert.strictEqual(userMessage.id, "provider-owned-id"); - assert.strictEqual(userMessage.text, optimistic.text); - assert.strictEqual(userMessage.timestamp.getTime(), optimistic.timestamp.getTime()); - assert.strictEqual(userMessage.optimistic, undefined); - assert.deepStrictEqual(userMessage.images, optimistic.images); - assert.deepStrictEqual(userMessage.attachments, optimistic.attachments); + assert.strictEqual(userMessage.id, "msg_submitted"); + assert.strictEqual(userMessage.messageId, "provider-owned-id"); + assert.strictEqual(userMessage.text, submitted.text); + assert.strictEqual(userMessage.timestamp.getTime(), submitted.timestamp.getTime()); + assert.deepStrictEqual(userMessage.images, submitted.images); + assert.deepStrictEqual(userMessage.attachments, submitted.attachments); }, ); - it("replaces one optimistic plain-text user message with the next live server user message", () => { - const optimisticTimestamp = new Date("2025-01-01T15:03:00Z"); + it("replaces one submitted plain-text user message with the next live server user message", () => { + const submittedTimestamp = new Date("2025-01-01T15:03:00Z"); const serverTimestamp = new Date("2025-01-01T15:03:01Z"); - const optimistic: StreamItem = { + const submitted: StreamItem = { kind: "user_message", - id: "msg_optimistic", + id: "msg_submitted", + clientMessageId: "msg_submitted", text: "typed plain text", - timestamp: optimisticTimestamp, - optimistic: true, + timestamp: submittedTimestamp, }; const state = reduceStreamUpdate( - [optimistic], + [submitted], { type: "timeline", provider: "opencode", @@ -1204,20 +1308,20 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - assert.strictEqual(userMessage.id, "msg_opencode_provider_owned"); + assert.strictEqual(userMessage.id, "msg_submitted"); + assert.strictEqual(userMessage.messageId, "msg_opencode_provider_owned"); assert.strictEqual(userMessage.text, "typed plain text"); - assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime()); - assert.strictEqual(userMessage.optimistic, undefined); + assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime()); }); - it("replaces an optimistic image user message with the next canonical server user message", () => { - const optimisticTimestamp = new Date("2025-01-01T15:03:10Z"); + it("replaces a submitted image user message with the next canonical server user message", () => { + const submittedTimestamp = new Date("2025-01-01T15:03:10Z"); const image = { id: "image-canonical", mimeType: "image/png", storageType: "web-indexeddb" as const, storageKey: "image-canonical", - createdAt: optimisticTimestamp.getTime(), + createdAt: submittedTimestamp.getTime(), }; const attachment = { type: "text" as const, @@ -1225,16 +1329,16 @@ describe("turn lifecycle events", () => { text: "context", title: "context.txt", }; - const optimistic = buildOptimisticUserMessage({ - id: "msg_optimistic_canonical", + const submitted = createUserMessage({ + clientMessageId: "msg_submitted_canonical", text: "Analyze this", - timestamp: optimisticTimestamp, + timestamp: submittedTimestamp, images: [image], attachments: [attachment], }); const state = reduceStreamUpdate( - [optimistic], + [submitted], { type: "timeline", provider: "claude", @@ -1242,7 +1346,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "server-rendered attachment text", messageId: "provider-owned-canonical", - clientMessageId: optimistic.id, + clientMessageId: submitted.id, }, }, new Date("2025-01-01T15:03:11Z"), @@ -1253,17 +1357,17 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - assert.strictEqual(userMessage.id, "provider-owned-canonical"); + assert.strictEqual(userMessage.id, "msg_submitted_canonical"); + assert.strictEqual(userMessage.messageId, "provider-owned-canonical"); assert.strictEqual(userMessage.text, "Analyze this"); - assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime()); - assert.strictEqual(userMessage.optimistic, undefined); + assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime()); assert.deepStrictEqual(userMessage.images, [image]); assert.deepStrictEqual(userMessage.attachments, [attachment]); }); - it("places optimistic user messages through one append helper", () => { - const optimistic = buildOptimisticUserMessage({ - id: "msg_append_once", + it("places submitted user messages through the identity producer", () => { + const submitted = createUserMessage({ + clientMessageId: "msg_append_once", text: "append once", timestamp: new Date("2025-01-01T15:03:20Z"), }); @@ -1274,28 +1378,30 @@ describe("turn lifecycle events", () => { timestamp: new Date("2025-01-01T15:03:19Z"), }; - const first = appendOptimisticUserMessageToStream({ + const first = upsertUserMessageAcrossStream({ tail: [], head: [headItem], - message: optimistic, - placement: "active-head", + message: submitted, + insert: "head", + presentation: "existing", }); - const second = appendOptimisticUserMessageToStream({ + const second = upsertUserMessageAcrossStream({ tail: first.tail, head: first.head, - message: optimistic, - placement: "active-head", + message: submitted, + insert: "head", + presentation: "existing", }); assert.deepStrictEqual(first.tail, []); - assert.deepStrictEqual(first.head, [headItem, optimistic]); + assert.deepStrictEqual(first.head, [headItem, submitted]); assert.strictEqual(second.changedHead, false); assert.strictEqual(second.head, first.head); }); - it("hands rich optimistic content to an authoritative create message without duplicating it", () => { + it("hands rich submitted content to its create message without overwriting an earlier user row", () => { const timestamp = new Date("2025-01-01T15:03:20Z"); - const optimistic = buildOptimisticUserMessage({ - id: "client-user", + const submitted = createUserMessage({ + clientMessageId: "client-user", text: "", timestamp, images: [ @@ -1317,32 +1423,44 @@ describe("turn lifecycle events", () => { }, ], }); + const precedingProviderRow: StreamItem = { + kind: "user_message", + id: "provider-system-user", + messageId: "provider-system-user", + text: "provider setup prompt", + timestamp: new Date("2025-01-01T15:03:20.500Z"), + }; const canonical: StreamItem = { kind: "user_message", id: "provider-user", + messageId: "provider-user", + clientMessageId: "client-user", text: "server-rendered attachment text", timestamp: new Date("2025-01-01T15:03:21Z"), }; const handedOff = handoffCreatedAgentUserMessageToStream({ - tail: [canonical], + tail: [precedingProviderRow, canonical], head: [], - message: optimistic, + message: submitted, }); const repeated = handoffCreatedAgentUserMessageToStream({ tail: handedOff.tail, head: handedOff.head, - message: optimistic, + message: submitted, }); assert.deepStrictEqual(handedOff.tail, [ + precedingProviderRow, { kind: "user_message", - id: "provider-user", - text: optimistic.text, - timestamp: optimistic.timestamp, - images: optimistic.images, - attachments: optimistic.attachments, + id: "client-user", + clientMessageId: "client-user", + messageId: "provider-user", + text: submitted.text, + timestamp: submitted.timestamp, + images: submitted.images, + attachments: submitted.attachments, }, ]); assert.deepStrictEqual(handedOff.head, []); @@ -1364,22 +1482,22 @@ describe("turn lifecycle events", () => { ); assert.deepStrictEqual( afterNextUser.filter((item) => item.kind === "user_message").map((item) => item.id), - ["provider-user", "provider-next-user"], + ["provider-system-user", "client-user", "provider-next-user"], ); }); - it("reconciles an optimistic user message that was pending in the streaming head", () => { - const optimistic: StreamItem = { + it("flushes an interrupted head when its submitted prompt becomes canonical", () => { + const submitted: StreamItem = { kind: "user_message", - id: "msg_head_optimistic", + id: "msg_head_submitted", + clientMessageId: "msg_head_submitted", text: "plain text in head", timestamp: new Date("2025-01-01T15:03:02Z"), - optimistic: true, }; const result = applyStreamEvent({ tail: [], - head: [optimistic], + head: [submitted], event: { type: "timeline", provider: "opencode", @@ -1393,33 +1511,80 @@ describe("turn lifecycle events", () => { source: "live", }); - assert.strictEqual(result.head.length, 0); + assert.deepStrictEqual(result.head, []); const userMessages = result.tail.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 1); - assert.strictEqual(userMessages[0]?.id, "provider-owned-head"); - assert.strictEqual(userMessages[0]?.optimistic, undefined); + assert.strictEqual(userMessages[0]?.id, "msg_head_submitted"); + assert.strictEqual(userMessages[0]?.messageId, "provider-owned-head"); + }); + + it("keeps a replacement assistant separate after an interrupted prompt is reconciled", () => { + const interruptedAssistant: StreamItem = { + kind: "assistant_message", + id: "interrupted", + text: "old answer", + timestamp: new Date("2025-01-01T15:03:01Z"), + }; + const submitted = createUserMessage({ + clientMessageId: "msg_interrupt", + text: "replacement prompt", + timestamp: new Date("2025-01-01T15:03:02Z"), + }); + const reconciled = applyStreamEvent({ + tail: [], + head: [interruptedAssistant, submitted], + event: { + type: "timeline", + provider: "opencode", + item: { + type: "user_message", + text: submitted.text, + messageId: "provider-prompt", + clientMessageId: submitted.clientMessageId, + }, + }, + timestamp: new Date("2025-01-01T15:03:03Z"), + }); + const replacement = applyStreamEvent({ + tail: reconciled.tail, + head: reconciled.head, + event: { + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: "new answer" }, + }, + timestamp: new Date("2025-01-01T15:03:04Z"), + }); + + expect(replacement.tail.map((item) => item.kind)).toEqual([ + "assistant_message", + "user_message", + ]); + expect(replacement.head).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "new answer" }), + ]); }); - it("replaces multiple optimistic user messages in FIFO order", () => { - const optimisticTimestamp = new Date("2025-01-01T15:04:00Z"); + it("replaces multiple submitted user messages in FIFO order", () => { + const submittedTimestamp = new Date("2025-01-01T15:04:00Z"); const serverTimestamp = new Date("2025-01-01T15:04:01Z"); - const firstOptimistic: StreamItem = { + const firstSubmitted: StreamItem = { kind: "user_message", - id: "msg_optimistic_1", + id: "msg_submitted_1", + clientMessageId: "msg_submitted_1", text: "first typed text", - timestamp: optimisticTimestamp, - optimistic: true, + timestamp: submittedTimestamp, }; - const secondOptimistic: StreamItem = { + const secondSubmitted: StreamItem = { kind: "user_message", - id: "msg_optimistic_2", + id: "msg_submitted_2", + clientMessageId: "msg_submitted_2", text: "second typed text", timestamp: new Date("2025-01-01T15:04:00.500Z"), - optimistic: true, }; const afterFirstEcho = reduceStreamUpdate( - [firstOptimistic, secondOptimistic], + [firstSubmitted, secondSubmitted], { type: "timeline", provider: "opencode", @@ -1427,6 +1592,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "first server text", messageId: "provider-owned-first", + clientMessageId: "msg_submitted_1", }, }, serverTimestamp, @@ -1441,6 +1607,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "second server text", messageId: "provider-owned-second", + clientMessageId: "msg_submitted_2", }, }, new Date("2025-01-01T15:04:02Z"), @@ -1450,30 +1617,30 @@ describe("turn lifecycle events", () => { const userMessages = state.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 2); assert.deepStrictEqual( - userMessages.map((item) => [item.id, item.text, item.optimistic]), + userMessages.map((item) => [item.id, item.text, item.messageId]), [ - ["provider-owned-first", "first typed text", undefined], - ["provider-owned-second", "second typed text", undefined], + ["msg_submitted_1", "first typed text", "provider-owned-first"], + ["msg_submitted_2", "second typed text", "provider-owned-second"], ], ); }); - it("does not shift later prompts when an earlier optimistic prompt has no canonical echo", () => { + it("does not shift later prompts when an earlier submitted prompt has no canonical echo", () => { const staleTimestamp = new Date("2025-01-01T15:04:00Z"); const submittedTimestamp = new Date("2025-01-01T15:04:01Z"); const stalePrompt: StreamItem = { kind: "user_message", id: "msg_stale", + clientMessageId: "msg_stale", text: "first prompt without an echo", timestamp: staleTimestamp, - optimistic: true, }; const submittedPrompt: StreamItem = { kind: "user_message", id: "msg_submitted", + clientMessageId: "msg_submitted", text: "later submitted prompt", timestamp: submittedTimestamp, - optimistic: true, }; const state = reduceStreamUpdate( @@ -1496,15 +1663,16 @@ describe("turn lifecycle events", () => { stalePrompt, { kind: "user_message", - id: "provider-owned-submitted", + id: "msg_submitted", clientMessageId: submittedPrompt.id, + messageId: "provider-owned-submitted", text: submittedPrompt.text, timestamp: submittedPrompt.timestamp, }, ]); }); - it("appends a live server user message when no optimistic user message is pending", () => { + it("appends a live server user message when no submitted user message is pending", () => { const state = reduceStreamUpdate( [], { @@ -1523,21 +1691,11 @@ describe("turn lifecycle events", () => { const userMessages = state.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 1); assert.strictEqual(userMessages[0]?.id, "provider-owned-resume"); - assert.strictEqual(userMessages[0]?.optimistic, undefined); }); - it("does not match a server user message to an optimistic from a rewound turn after pending optimistics are cleared", () => { - const optimistic: StreamItem = { - kind: "user_message", - id: "msg_rewound_optimistic", - text: "rewound text", - timestamp: new Date("2025-01-01T15:04:04Z"), - optimistic: true, - }; - const cleared = clearOptimisticUserMessages([optimistic]); - + it("appends a server user message after a rewound local row was removed", () => { const state = reduceStreamUpdate( - cleared, + [], { type: "timeline", provider: "opencode", @@ -1555,7 +1713,6 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); assert.strictEqual(userMessages[0]?.id, "provider-owned-after-rewind"); assert.strictEqual(userMessages[0]?.text, "future server echo"); - assert.strictEqual(userMessages[0]?.optimistic, undefined); }); it("keeps canonical repeated user messages distinct during hydration", () => { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 76407d84cb7..8b66ab80bd8 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -87,22 +87,440 @@ export interface UserMessageItem { kind: "user_message"; id: string; clientMessageId?: string; + messageId?: string; text: string; timestamp: Date; - optimistic?: true; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; } -export interface OptimisticUserMessageInput { - id: string; +export interface UserMessageInput { + id?: string; + clientMessageId?: string; + messageId?: string; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; } -export type OptimisticUserMessagePlacement = "tail" | "active-head"; +export function createUserMessage(input: UserMessageInput): UserMessageItem { + const id = input.id ?? input.clientMessageId ?? input.messageId; + if (!id) { + throw new Error("User message identity is required"); + } + return { + kind: "user_message", + id, + ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), + ...(input.messageId ? { messageId: input.messageId } : {}), + text: input.text, + timestamp: input.timestamp, + ...(input.images && input.images.length > 0 ? { images: input.images } : {}), + ...(input.attachments && input.attachments.length > 0 + ? { attachments: input.attachments } + : {}), + }; +} + +export function appendSubmittedUserMessage(input: { + tail: StreamItem[]; + head: StreamItem[]; + message: UserMessageItem; +}): { tail: StreamItem[]; head: StreamItem[] } { + const clientMessageId = input.message.clientMessageId; + if (!clientMessageId) { + throw new Error("Submitted user message requires client identity"); + } + const alreadyExists = [...input.tail, ...input.head].some( + (item) => item.kind === "user_message" && item.clientMessageId === clientMessageId, + ); + if (alreadyExists) { + throw new Error(`Submitted user message already exists: ${clientMessageId}`); + } + return input.head.length > 0 + ? { tail: input.tail, head: [...input.head, input.message] } + : { tail: [...input.tail, input.message], head: input.head }; +} + +export function removeSubmittedUserMessage(input: { + tail: StreamItem[]; + head: StreamItem[]; + clientMessageId: string; +}): { tail: StreamItem[]; head: StreamItem[] } { + const remove = (items: StreamItem[]) => { + const next = items.filter( + (item) => item.kind !== "user_message" || item.clientMessageId !== input.clientMessageId, + ); + return next.length === items.length ? items : next; + }; + return { tail: remove(input.tail), head: remove(input.head) }; +} + +// COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once the +// supported daemon floor emits clientMessageId on submitted user messages. Until then a +// locally submitted row (clientMessageId, no messageId) and its canonical twin from an +// old daemon (messageId, no clientMessageId) share no identifier, so canonical ingestion +// may match an explicit local candidate by the id supplied over the wire or by text. +function matchesLegacyCanonicalUserMessage( + submitted: UserMessageItem, + canonical: UserMessageItem, +): boolean { + if (submitted.clientMessageId === undefined || submitted.messageId !== undefined) return false; + if (canonical.messageId === undefined) return false; + return canonical.messageId === submitted.clientMessageId || canonical.text === submitted.text; +} + +type UserMessageMatchPolicy = "canonical-incoming" | "handoff"; + +function matchesUserMessage( + existing: UserMessageItem, + incoming: UserMessageItem, + policy: UserMessageMatchPolicy, +): boolean { + if (existing.clientMessageId && incoming.clientMessageId) { + return existing.clientMessageId === incoming.clientMessageId; + } + if (existing.messageId && incoming.messageId) { + return existing.messageId === incoming.messageId; + } + if (matchesLegacyCanonicalUserMessage(existing, incoming)) return true; + return policy === "handoff" && matchesLegacyCanonicalUserMessage(incoming, existing); +} + +export function upsertUserMessage( + items: StreamItem[], + incoming: UserMessageItem, + insertAt = items.length, +): StreamItem[] { + return produceUserMessage(items, incoming, insertAt, "existing").items; +} + +type UserMessagePresentationPolicy = "existing" | "incoming"; + +interface UserMessageProductionResult { + items: StreamItem[]; + index: number; + message: UserMessageItem; + matched: boolean; +} + +function produceUserMessage( + items: StreamItem[], + incoming: UserMessageItem, + insertAt: number | null, + presentationPolicy: UserMessagePresentationPolicy, + matchPolicy: UserMessageMatchPolicy = "canonical-incoming", +): UserMessageProductionResult { + const index = items.findIndex( + (item) => item.kind === "user_message" && matchesUserMessage(item, incoming, matchPolicy), + ); + if (index < 0) { + if (insertAt === null) { + return { items, index: -1, message: incoming, matched: false }; + } + return { + items: [...items.slice(0, insertAt), incoming, ...items.slice(insertAt)], + index: insertAt, + message: incoming, + matched: false, + }; + } + + const existing = items[index]; + if (!existing || existing.kind !== "user_message") { + throw new Error("User message upsert matched a non-user row"); + } + const presentation = presentationPolicy === "incoming" ? incoming : existing; + const merged = createUserMessage({ + ...presentation, + clientMessageId: incoming.clientMessageId ?? existing.clientMessageId, + messageId: incoming.messageId ?? existing.messageId, + }); + if ( + existing.id === merged.id && + existing.clientMessageId === merged.clientMessageId && + existing.messageId === merged.messageId && + existing.text === merged.text && + existing.timestamp === merged.timestamp && + existing.images === merged.images && + existing.attachments === merged.attachments + ) { + return { items, index, message: existing, matched: true }; + } + const next = [...items]; + next[index] = merged; + return { items: next, index, message: merged, matched: true }; +} + +export interface UserMessageStreamUpsertInput { + tail: StreamItem[]; + head: StreamItem[]; + message: UserMessageItem; + insert: "tail" | "head" | "prepend-tail" | "none"; + presentation: UserMessagePresentationPolicy; + matchPolicy?: UserMessageMatchPolicy; +} + +export interface UserMessageStreamUpsertResult extends ApplyStreamEventResult { + location: { + lane: "tail" | "head"; + index: number; + message: UserMessageItem; + matched: boolean; + } | null; +} + +export function upsertUserMessageAcrossStream( + input: UserMessageStreamUpsertInput, +): UserMessageStreamUpsertResult { + const tailResult = produceUserMessage( + input.tail, + input.message, + null, + input.presentation, + input.matchPolicy, + ); + if (tailResult.matched) { + return { + tail: tailResult.items, + head: input.head, + changedTail: tailResult.items !== input.tail, + changedHead: false, + location: { + lane: "tail", + index: tailResult.index, + message: tailResult.message, + matched: true, + }, + }; + } + const headResult = produceUserMessage( + input.head, + input.message, + null, + input.presentation, + input.matchPolicy, + ); + if (headResult.matched) { + return { + tail: input.tail, + head: headResult.items, + changedTail: false, + changedHead: headResult.items !== input.head, + location: { + lane: "head", + index: headResult.index, + message: headResult.message, + matched: true, + }, + }; + } + if (input.insert === "none") { + return { + tail: input.tail, + head: input.head, + changedTail: false, + changedHead: false, + location: null, + }; + } + if (input.insert === "head") { + const inserted = produceUserMessage( + input.head, + input.message, + input.head.length, + input.presentation, + input.matchPolicy, + ); + return { + tail: input.tail, + head: inserted.items, + changedTail: false, + changedHead: true, + location: { + lane: "head", + index: inserted.index, + message: inserted.message, + matched: false, + }, + }; + } + const inserted = produceUserMessage( + input.tail, + input.message, + input.insert === "prepend-tail" ? 0 : input.tail.length, + input.presentation, + input.matchPolicy, + ); + return { + tail: inserted.items, + head: input.head, + changedTail: true, + changedHead: false, + location: { + lane: "tail", + index: inserted.index, + message: inserted.message, + matched: false, + }, + }; +} + +function placeCanonicalUserMessageAtTail( + tail: StreamItem[], + message: UserMessageItem, + insertWhenUnmatched: boolean, +): Pick { + const produced = produceUserMessage(tail, message, null, "existing"); + if (!produced.matched && !insertWhenUnmatched) { + return produced; + } + const preceding = produced.matched + ? [...produced.items.slice(0, produced.index), ...produced.items.slice(produced.index + 1)] + : produced.items; + return { + items: [...preceding, produced.message], + message: produced.message, + matched: produced.matched, + }; +} + +export interface CanonicalStreamReplacementInput { + canonical: StreamItem[]; + previousTail: StreamItem[]; + previousHead: StreamItem[]; + sendingClientMessageIds: readonly string[]; + preserveLiveHead: boolean; +} + +export interface CanonicalStreamReplacementResult { + tail: StreamItem[]; + head: StreamItem[]; + acknowledgedClientMessageIds: string[]; +} + +function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessageItem[] { + return [...items.slice(0, index), ...items.slice(index + 1)]; +} + +function preserveReplacementHead( + tail: StreamItem[], + currentHead: StreamItem[], + preserveLiveHead: boolean, + sendingClientMessageIds: ReadonlySet, +): CanonicalStreamReplacementResult { + const retainedHead = preserveLiveHead + ? currentHead + : currentHead.filter( + (item) => + item.kind === "user_message" && + item.clientMessageId !== undefined && + sendingClientMessageIds.has(item.clientMessageId), + ); + const tailIds = new Set(tail.map((item) => item.id)); + const unreconciledHead = retainedHead.filter( + (item) => item.kind === "assistant_message" || !tailIds.has(item.id), + ); + const liveAssistantIndex = unreconciledHead.findLastIndex( + (item) => item.kind === "assistant_message", + ); + if (liveAssistantIndex < 0) { + return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + } + + const liveAssistant = unreconciledHead[liveAssistantIndex]; + const tailAssistant = tail.at(-1); + if ( + liveAssistant.kind !== "assistant_message" || + !tailAssistant || + tailAssistant.kind !== "assistant_message" || + !liveAssistant.text.startsWith(tailAssistant.text) + ) { + return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + } + + const head = [ + ...unreconciledHead.slice(0, liveAssistantIndex), + { ...liveAssistant, text: tailAssistant.text }, + ...unreconciledHead.slice(liveAssistantIndex + 1), + ]; + return { tail: tail.slice(0, -1), head, acknowledgedClientMessageIds: [] }; +} + +export function replaceWithCanonicalStream( + input: CanonicalStreamReplacementInput, +): CanonicalStreamReplacementResult { + const sendingClientMessageIds = new Set(input.sendingClientMessageIds); + let unmatchedTailMessages = input.previousTail.filter( + (item): item is UserMessageItem => + item.kind === "user_message" && item.clientMessageId !== undefined, + ); + let nextHead = input.previousHead; + const nextTail: StreamItem[] = []; + const acknowledgedClientMessageIds = new Set(); + + for (const item of input.canonical) { + if (item.kind !== "user_message") { + nextTail.push(item); + continue; + } + + const tailResult = produceUserMessage(unmatchedTailMessages, item, null, "existing"); + if (tailResult.matched) { + unmatchedTailMessages = removeUserMessageAt(unmatchedTailMessages, tailResult.index); + nextTail.push(tailResult.message); + if ( + tailResult.message.clientMessageId && + sendingClientMessageIds.has(tailResult.message.clientMessageId) + ) { + acknowledgedClientMessageIds.add(tailResult.message.clientMessageId); + } + continue; + } + + const headResult = produceUserMessage(nextHead, item, null, "existing"); + if (headResult.matched) { + nextHead = [ + ...headResult.items.slice(0, headResult.index), + ...headResult.items.slice(headResult.index + 1), + ]; + nextTail.push(headResult.message); + if ( + headResult.message.clientMessageId && + sendingClientMessageIds.has(headResult.message.clientMessageId) + ) { + acknowledgedClientMessageIds.add(headResult.message.clientMessageId); + } + continue; + } + + nextTail.push(item); + } + + for (const local of unmatchedTailMessages) { + if (!local.clientMessageId || !sendingClientMessageIds.has(local.clientMessageId)) { + continue; + } + nextTail.push(local); + } + + nextHead = nextHead.filter((item) => { + if (item.kind !== "user_message" || !item.clientMessageId) return true; + return sendingClientMessageIds.has(item.clientMessageId); + }); + + const replacement = preserveReplacementHead( + nextTail, + nextHead, + input.preserveLiveHead, + sendingClientMessageIds, + ); + return { + ...replacement, + acknowledgedClientMessageIds: [...acknowledgedClientMessageIds], + }; +} export interface AssistantMessageItem { kind: "assistant_message"; @@ -237,124 +655,24 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem { }; } -function buildUserMessageItem(input: { - id: string; - clientMessageId?: string; - text: string; - timestamp: Date; - optimistic?: UserMessageItem | null; -}): UserMessageItem { - if (input.optimistic) { - return { - kind: "user_message", - id: input.id, - ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), - text: input.optimistic.text, - timestamp: input.optimistic.timestamp, - ...(input.optimistic.images && input.optimistic.images.length > 0 - ? { images: input.optimistic.images } - : {}), - ...(input.optimistic.attachments && input.optimistic.attachments.length > 0 - ? { attachments: input.optimistic.attachments } - : {}), - }; - } - - return { - kind: "user_message", - id: input.id, - ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), - text: input.text, - timestamp: input.timestamp, - }; -} - -export function buildOptimisticUserMessage(input: OptimisticUserMessageInput): UserMessageItem { - return { - kind: "user_message", - id: input.id, - text: input.text, - timestamp: input.timestamp, - optimistic: true, - ...(input.images && input.images.length > 0 ? { images: input.images } : {}), - ...(input.attachments && input.attachments.length > 0 - ? { attachments: input.attachments } - : {}), - }; -} - -export function appendOptimisticUserMessageToStream(params: { - tail: StreamItem[]; - head: StreamItem[]; - message: UserMessageItem; - placement: OptimisticUserMessagePlacement; -}): ApplyStreamEventResult { - const { tail, head, message, placement } = params; - if (tail.some((item) => item.id === message.id) || head.some((item) => item.id === message.id)) { - return { tail, head, changedTail: false, changedHead: false }; - } - - if (placement === "active-head" && head.length > 0) { - return { - tail, - head: [...head, message], - changedTail: false, - changedHead: true, - }; - } - - return { - tail: [...tail, message], - head, - changedTail: true, - changedHead: false, - }; -} - export function handoffCreatedAgentUserMessageToStream(params: { tail: StreamItem[]; head: StreamItem[]; message: UserMessageItem; }): ApplyStreamEventResult { - const { tail, head, message } = params; - const items = [...tail, ...head]; - const userIndex = items.findIndex((item) => item.kind === "user_message"); - if (userIndex < 0) { - return appendOptimisticUserMessageToStream({ - tail, - head, - message, - placement: "tail", - }); - } - - const userMessage = items[userIndex]; - if (!userMessage || userMessage.kind !== "user_message" || userMessage.optimistic) { - return { tail, head, changedTail: false, changedHead: false }; - } - - const handedOffMessage = buildUserMessageItem({ - id: userMessage.id, - text: message.text, - timestamp: message.timestamp, - optimistic: message, + return upsertUserMessageAcrossStream({ + ...params, + insert: "tail", + presentation: "incoming", + matchPolicy: "handoff", }); - if (userIndex < tail.length) { - const nextTail = [...tail]; - nextTail[userIndex] = handedOffMessage; - return { tail: nextTail, head, changedTail: true, changedHead: false }; - } - - const nextHead = [...head]; - nextHead[userIndex - tail.length] = handedOffMessage; - return { tail, head: nextHead, changedTail: false, changedHead: true }; } function appendUserMessage( state: StreamItem[], text: string, timestamp: Date, - source: StreamUpdateSource, + _source: StreamUpdateSource, messageId?: string, clientMessageId?: string, ): StreamItem[] { @@ -364,37 +682,14 @@ function appendUserMessage( } const chunkSeed = chunk.trim() || chunk; - const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp); - const optimisticIndex = state.findIndex( - (entry) => - entry.kind === "user_message" && - entry.optimistic && - (clientMessageId !== undefined - ? entry.id === clientMessageId - : source === "live" || entry.id === messageId || entry.text === chunk), - ); - const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null; - - const nextItem = buildUserMessageItem({ - id: entryId, + const nextItem = createUserMessage({ + id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp), clientMessageId, + messageId, text: chunk, timestamp, - optimistic, }); - - if (optimisticIndex >= 0) { - const next = [...state]; - next[optimisticIndex] = nextItem; - return next; - } - - return [...state, nextItem]; -} - -export function clearOptimisticUserMessages(state: StreamItem[]): StreamItem[] { - const next = state.filter((item) => item.kind !== "user_message" || !item.optimistic); - return next.length === state.length ? state : next; + return upsertUserMessage(state, nextItem); } function appendAssistantMessage( @@ -426,8 +721,8 @@ function appendAssistantMessage( return [...state.slice(0, -1), updated]; } - // If the last item is a user_message (optimistic append to head during - // interrupt), look one further back for the streaming assistant_message. + // A submitted user row can follow the streaming assistant during interrupt. + // In that case, look one row further back for the assistant to extend. const secondLast = state[state.length - 2]; if ( source === "live" && @@ -1149,7 +1444,6 @@ export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamI if (newItems.length === 0) { return tail; } - return [...tail, ...newItems]; } @@ -1177,8 +1471,7 @@ function shouldFlushHead(input: { return true; } - // Find the last streamable item in head (skip trailing non-streamable - // items like an optimistic user_message appended during interrupt). + // Find the last streamable item in head (skip trailing non-streamable items). let lastStreamable: StreamItem | undefined; for (let i = head.length - 1; i >= 0; i--) { if (isStreamableKind(head[i].kind)) { @@ -1209,6 +1502,41 @@ export interface ApplyStreamEventResult { head: StreamItem[]; changedTail: boolean; changedHead: boolean; + acknowledgedClientMessageIds?: string[]; +} + +function applyCanonicalUserMessageEvent(params: { + tail: StreamItem[]; + head: StreamItem[]; + event: AgentStreamEventPayload; + timestamp: Date; +}): ApplyStreamEventResult | null { + const { tail, head, event, timestamp } = params; + if (event.type !== "timeline" || event.item.type !== "user_message") return null; + const normalized = normalizeChunk(event.item.text); + + const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail; + const flushedHead = head.length > 0 ? [] : head; + const canonical = createUserMessage({ + id: + event.item.messageId ?? + createUniqueTimelineId([...tail, ...head], "user", normalized.chunk.trim(), timestamp), + messageId: event.item.messageId, + clientMessageId: event.item.clientMessageId, + text: normalized.chunk, + timestamp, + }); + const reconciled = placeCanonicalUserMessageAtTail(flushedTail, canonical, normalized.hasContent); + return { + tail: reconciled.items, + head: flushedHead, + changedTail: flushedTail !== tail || reconciled.items !== flushedTail, + changedHead: flushedHead !== head, + acknowledgedClientMessageIds: + reconciled.matched && reconciled.message.clientMessageId + ? [reconciled.message.clientMessageId] + : [], + }; } /** @@ -1231,12 +1559,13 @@ export function applyStreamEvent(params: { timelineCursor?: TimelinePosition; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; + const canonicalUserResult = applyCanonicalUserMessageEvent({ tail, head, event, timestamp }); + if (canonicalUserResult) return canonicalUserResult; const source = params.source ?? "live"; let nextTail = tail; let nextHead = head; let changedTail = false; let changedHead = false; - const flushHead = () => { if (nextHead.length === 0) { return; diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 1ec16bde262..5330f8aa2a1 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -7,6 +7,7 @@ import { useSessionStore } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; +import { createUserMessage } from "@/types/stream"; import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync"; function createAgentPayload( @@ -64,6 +65,111 @@ function permission(id: string): AgentPermissionRequest { return { id, provider: "codex", name: id, kind: "tool", title: id }; } +function beginPendingSubmission(serverId: string, agentId: string): string { + const clientMessageId = `client-${agentId}`; + useSessionStore.getState().beginAgentMessageSubmission( + serverId, + agentId, + createUserMessage({ + clientMessageId, + text: "Run this", + timestamp: new Date("2026-07-27T10:00:00.000Z"), + }), + ); + return clientMessageId; +} + +function applyAgentStatus(input: { + serverId: string; + agentId: string; + status: AgentSnapshotPayload["status"]; + updatedAt: string; +}): void { + const agent = createAgentPayload({ + id: input.agentId, + status: input.status, + updatedAt: input.updatedAt, + }); + applyAgentDirectoryDelta({ + serverId: input.serverId, + delta: { kind: "upsert", agent, project: createEntry(agent).project }, + }); +} + +describe("message submission authority", () => { + it("does not settle a submission from an unrelated running transition", () => { + const serverId = "server-running-is-not-submission-ack"; + const agentId = "agent-1"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + applyAgentStatus({ + serverId, + agentId, + status: "idle", + updatedAt: "2026-07-27T10:00:00.000Z", + }); + const clientMessageId = beginPendingSubmission(serverId, agentId); + + applyAgentStatus({ + serverId, + agentId, + status: "running", + updatedAt: "2026-07-27T10:00:01.000Z", + }); + + expect(useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)).toEqual([ + { + clientMessageId, + submittedAt: new Date("2026-07-27T10:00:00.000Z"), + rpcAccepted: false, + providerAcknowledged: false, + }, + ]); + store.clearSession(serverId); + }); + + it("settles provider acknowledgement only when timeline ingestion reports it", () => { + const serverId = "server-explicit-provider-ack"; + const agentId = "agent-1"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + applyAgentStatus({ + serverId, + agentId, + status: "idle", + updatedAt: "2026-07-27T10:00:00.000Z", + }); + const clientMessageId = beginPendingSubmission(serverId, agentId); + store.setAgentStreamState(serverId, agentId, { + tail: [ + createUserMessage({ + id: "provider-message", + messageId: "provider-message", + clientMessageId, + text: "Run this", + timestamp: new Date("2026-07-27T10:00:01.000Z"), + }), + ], + head: [], + }); + + expect( + useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] + ?.providerAcknowledged, + ).toBe(false); + + store.setAgentStreamState(serverId, agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); + + expect( + useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] + ?.providerAcknowledged, + ).toBe(true); + store.clearSession(serverId); + }); +}); + describe("replaceFetchedAgentDirectory", () => { it("preserves timeline initialization while replacing directory state", () => { const serverId = "server-initializing"; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 8e67ea4b5a9..bed840c0e95 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -324,6 +324,11 @@ export interface SendMessageOptions { attachments?: SendAgentMessageRequest["attachments"]; } +export interface SendMessageResult { + /** Undefined when connected to a daemon predating message submission disposition. */ + outOfBand?: boolean; +} + export interface AgentAttentionRequiredNotification { agentId: string; reason: "finished" | "error" | "permission"; @@ -2859,7 +2864,7 @@ export class DaemonClient { agentId: string, text: string, options?: SendMessageOptions, - ): Promise { + ): Promise { const requestId = this.createRequestId(); const messageId = options?.messageId ?? crypto.randomUUID(); const message = SessionInboundMessageSchema.parse({ @@ -2888,6 +2893,7 @@ export class DaemonClient { if (!payload.accepted) { throw new Error(payload.error ?? "sendAgentMessage rejected"); } + return payload.outOfBand === undefined ? {} : { outOfBand: payload.outOfBand }; } async sendMessage(agentId: string, text: string, options?: SendMessageOptions): Promise { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d7f21f12463..a0fdbd5e773 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -478,7 +478,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor latest = result?.agent ?? null; return result; }, - send: (text, options) => daemonClient.sendAgentMessage(id, text, options), + send: async (text, options) => { + await daemonClient.sendAgentMessage(id, text, options); + }, archive: async () => { const result = await daemonClient.archiveAgent(id); if (latest) { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 31f33685dbb..5efa8f6a7d9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3708,6 +3708,8 @@ export const SendAgentMessageResponseMessageSchema = z.object({ agentId: z.string(), accepted: z.boolean(), error: z.string().nullable(), + // COMPAT(messageSubmissionDisposition): added in v0.2.3, remove optional parsing after 2027-01-27. + outOfBand: z.boolean().optional(), }), }); 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 98d904076ba..cacdda38e12 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 @@ -49,6 +49,25 @@ describe("MockLoadTestAgentClient", () => { }); }); + test("rejects the configured number of prompts before starting a retry", async () => { + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + featureValues: { mockPromptRejections: 1 }, + }); + + await expect(session.startTurn("Reject this prompt.")).rejects.toThrow( + "Requested mock prompt rejection", + ); + + await expect(session.startTurn("Accept this retry.")).resolves.toEqual({ + turnId: expect.any(String), + }); + await session.interrupt(); + }); + test("returns schema-shaped JSON for structured branch-name generation", 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 33b9ddfacef..ffab16c79f7 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 @@ -583,6 +583,7 @@ export class MockLoadTestAgentSession implements AgentSession { private modeId: string | null; private modelId: string | null; private readonly rewindError: string | null; + private remainingPromptRejections: number; constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) { this.id = options.sessionId; @@ -593,6 +594,13 @@ export class MockLoadTestAgentSession implements AgentSession { typeof options.config.featureValues?.mockRewindError === "string" ? options.config.featureValues.mockRewindError : null; + const requestedPromptRejections = options.config.featureValues?.mockPromptRejections; + this.remainingPromptRejections = + typeof requestedPromptRejections === "number" && + Number.isSafeInteger(requestedPromptRejections) && + requestedPromptRejections > 0 + ? requestedPromptRejections + : 0; } async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { @@ -611,6 +619,10 @@ export class MockLoadTestAgentSession implements AgentSession { if (this.activeTurn) { throw new Error("Mock load-test provider already has an active turn"); } + if (this.remainingPromptRejections > 0) { + this.remainingPromptRejections -= 1; + throw new Error("Requested mock prompt rejection"); + } const profile = resolveModelProfile(this.modelId); const turnId = randomUUID(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7cf2f24c333..7164c6de686 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -6344,6 +6344,7 @@ export class Session { agentId, accepted: true, error: null, + outOfBand: true, }, }); return; @@ -6371,6 +6372,7 @@ export class Session { agentId, accepted: true, error: null, + outOfBand: false, }, }); } catch (error) { From c596e058cd215ba96639b104d7163e248013d82e Mon Sep 17 00:00:00 2001 From: Aditya Borakati <41518783+ABorakati@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:52:26 +0100 Subject: [PATCH 098/420] fix(dev): make worktree setup run on Windows (#2431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `worktree.setup` ran two POSIX-only command strings, but lifecycle commands go through PowerShell on Windows, so worktree creation failed at: PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=... ./scripts/dev-home.sh -> PASEO_DEV_MANAGED_HOME=1 : The term ... is not recognized PowerShell has no `VAR=value cmd` prefix syntax. The `cp` entry was broken the same way: `$PASEO_SOURCE_CHECKOUT_PATH` is an undefined *PowerShell* variable, not an env var, so it expanded to empty and the copy resolved to `/packages/server/.env`. Neither entry can be expressed portably in a single shell string, and `bash` is not guaranteed on Windows, so move both steps into a Node script that reads its inputs from `process.env` — matching the existing `node ./scripts/seed-ios-native-cache.mjs` entry. One code path, no platform branching. `scripts/dev-home.sh` is unchanged and still sourced by the bash service scripts; only the setup-time seeding is ported. One behavior change: a missing `packages/server/.env` in the source checkout is now skipped with a log instead of aborting setup. It is untracked local config, and the old `cp` hard-failed worktree creation for anyone without one. Co-authored-by: ABorakati Co-authored-by: Claude Opus 5 --- docs/development.md | 8 +++ paseo.json | 5 +- scripts/seed-worktree-dev-state.mjs | 95 +++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 scripts/seed-worktree-dev-state.mjs diff --git a/docs/development.md b/docs/development.md index 9eed78a00ec..cd96239bac4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -242,6 +242,14 @@ commands use the same non-login Bash behavior on macOS/Linux, but preserve their existing `cmd.exe /c` string semantics on Windows. Service scripts are separate: they launch in a terminal and receive the service environment described below. +Because the shell differs per platform, a lifecycle command that must run +everywhere cannot use POSIX-only syntax — `VAR=1 cmd` env prefixes, `$VAR` +expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell, +and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script +that reads what it needs from `process.env` and invoke it as +`node ./scripts/.mjs`. This repo's own setup does exactly that in +`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`. + ```json { "worktree": { diff --git a/paseo.json b/paseo.json index ab984058ef4..076d1f2c589 100644 --- a/paseo.json +++ b/paseo.json @@ -3,10 +3,9 @@ "setup": [ "npm ci", "node ./scripts/seed-ios-native-cache.mjs", - "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=\"$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home\" PASEO_HOME=\"$PWD/.dev/paseo-home\" ./scripts/dev-home.sh", + "node ./scripts/seed-worktree-dev-state.mjs", "npm run build:server", - "npm run build --workspace=@getpaseo/expo-two-way-audio", - "cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PWD/packages/server/.env\"" + "npm run build --workspace=@getpaseo/expo-two-way-audio" ] }, "scripts": { diff --git a/scripts/seed-worktree-dev-state.mjs b/scripts/seed-worktree-dev-state.mjs new file mode 100644 index 00000000000..d645789596f --- /dev/null +++ b/scripts/seed-worktree-dev-state.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; + +// Worktree setup runs through a stable script shell: bash on macOS/Linux, but PowerShell +// on Windows. POSIX-only command strings (`VAR=1 cmd` env prefixes, `$VAR` expansion, `cp`, +// `./scripts/*.sh`) cannot express this step portably, so the seeding lives in Node — the +// one interpreter every Paseo checkout already depends on. + +const sourceRoot = process.env.PASEO_SOURCE_CHECKOUT_PATH; +const targetRoot = process.env.PASEO_WORKTREE_PATH || process.cwd(); + +if (!sourceRoot || sourceRoot === targetRoot) { + process.exit(0); +} + +seedPaseoHome(); +copyServerEnv(); + +function seedPaseoHome() { + const source = process.env.PASEO_DEV_SEED_HOME || join(sourceRoot, ".dev/paseo-home"); + const target = join(targetRoot, ".dev/paseo-home"); + + if (!existsSync(source)) { + console.log(` Seed: skipped (${source} missing)`); + return; + } + + if (source === target) { + console.log(" Seed: skipped (source is target)"); + return; + } + + if (process.env.PASEO_DEV_RESET_HOME === "1") { + rmSync(target, { recursive: true, force: true }); + } else if (hasEntries(target)) { + console.log(` Seed: skipped (${target} already has data)`); + return; + } + + mkdirSync(target, { recursive: true }); + console.log(` Seed: copying metadata from ${source}`); + copyJsonTree(join(source, "agents"), join(target, "agents")); + copyJsonTree(join(source, "projects"), join(target, "projects")); + + const config = join(source, "config.json"); + if (existsSync(config)) { + cpSync(config, join(target, "config.json")); + } + + console.log(` Seed: copied metadata from ${source}`); +} + +// Durable JSON metadata only. Runtime files (pid files, sockets, logs) must not be copied. +function copyJsonTree(source, target) { + if (!existsSync(source)) { + return; + } + + cpSync(source, target, { + recursive: true, + filter: (path) => isDirectory(path) || path.endsWith(".json"), + }); +} + +function copyServerEnv() { + const source = join(sourceRoot, "packages/server/.env"); + const target = join(targetRoot, "packages/server/.env"); + + // Untracked local config. A checkout without one is normal, so this is not a setup failure. + if (!existsSync(source)) { + console.log(` Env: skipped (${source} missing)`); + return; + } + + mkdirSync(dirname(target), { recursive: true }); + cpSync(source, target); + console.log(` Env: copied ${source}`); +} + +function hasEntries(path) { + try { + return readdirSync(path).length > 0; + } catch { + return false; + } +} + +function isDirectory(path) { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} From 869edcbf11485e17dcbd66a333afdea72b3d5ffa Mon Sep 17 00:00:00 2001 From: Li Mu Zhi Date: Tue, 28 Jul 2026 04:00:46 +0800 Subject: [PATCH 099/420] fix(forge): preserve non-default port in forge web URLs (#2478) "Open in browser" links for a self-hosted forge served on a non-standard port (e.g. Forgejo/Gitea on :60443) dropped the port, producing https://host/owner/repo/... instead of https://host:60443/owner/repo/..., which 404s or hits the wrong service. parseGitRemoteLocation discarded parsed.port (GitRemoteLocation had no port field), and buildForgeBranchTreeUrl / buildForgeBlobUrl rebuilt the origin from the portless host. Preserve the port on GitRemoteLocation and reattach it in the web-URL builders, only for self-hosted http(s) origins (an SSH port isn't the web port; a canonicalized cloud host uses the default port). Host-identity matching (forge detection, cloud-host checks) stays port-agnostic. Co-authored-by: Claude Opus 4.8 --- packages/app/src/git/forge-url.test.ts | 30 ++++++++++++++++++++++++ packages/app/src/git/forge-url.ts | 28 +++++++++++++++------- packages/protocol/src/git-remote.test.ts | 22 +++++++++++++++++ packages/protocol/src/git-remote.ts | 9 ++++++- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/app/src/git/forge-url.test.ts b/packages/app/src/git/forge-url.test.ts index aa647998af8..46a2dfe6cf6 100644 --- a/packages/app/src/git/forge-url.test.ts +++ b/packages/app/src/git/forge-url.test.ts @@ -44,6 +44,24 @@ describe("buildForgeBranchTreeUrl", () => { ).toBe("https://codeberg.org/acme/repo/src/branch/main"); }); + it("preserves a non-default port for a self-hosted https remote", () => { + expect( + buildForgeBranchTreeUrl("forgejo", { + remoteUrl: "https://home-git.example.com:60443/team/repo.git", + branch: "master", + }), + ).toBe("https://home-git.example.com:60443/team/repo/src/branch/master"); + }); + + it("omits the port for a self-hosted remote on the default port", () => { + expect( + buildForgeBranchTreeUrl("forgejo", { + remoteUrl: "https://home-git.example.com/team/repo.git", + branch: "master", + }), + ).toBe("https://home-git.example.com/team/repo/src/branch/master"); + }); + it("returns null when the current branch is unavailable", () => { expect( buildForgeBranchTreeUrl("github", { @@ -131,6 +149,18 @@ describe("buildForgeBlobUrl", () => { ).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts"); }); + it("preserves a non-default port for a self-hosted https remote", () => { + expect( + buildForgeBlobUrl("forgejo", { + remoteUrl: "https://home-git.example.com:60443/team/repo.git", + branch: "master", + path: "src/index.ts", + lineStart: 12, + lineEnd: 20, + }), + ).toBe("https://home-git.example.com:60443/team/repo/src/branch/master/src/index.ts#L12-L20"); + }); + it("canonicalizes the github.com SSH-alias host to the web host", () => { expect( buildForgeBlobUrl("github", { diff --git a/packages/app/src/git/forge-url.ts b/packages/app/src/git/forge-url.ts index 79090d92346..a19190d82c8 100644 --- a/packages/app/src/git/forge-url.ts +++ b/packages/app/src/git/forge-url.ts @@ -29,6 +29,8 @@ export interface ForgeBranchTreeUrlInput { interface ForgeWebLocation { host: string; + /** Non-default port for a self-hosted http(s) origin, or undefined. */ + port?: string; repo: string; } @@ -58,18 +60,28 @@ function resolveForgeWebLocation( if (!location || !isValidRepoPath(location.path)) { return null; } - const cloudHosts = getForgeDefinition(forge)?.cloudHosts; - const webHost = - cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host) - ? normalizeHost(cloudHosts[0]) - : location.host; - return { host: webHost, repo: location.path }; + const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost); + const isCloudHost = cloudHosts.includes(location.host); + const webHost = isCloudHost ? cloudHosts[0] : location.host; + // Carry a non-default port only for a self-hosted http(s) origin (e.g. + // `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the + // web port, and a canonicalized cloud host always serves on the default port. + const port = + !isCloudHost && (location.transport === "http" || location.transport === "https") + ? location.port + : undefined; + return { host: webHost, port, repo: location.path }; } function encodeBranch(branch: string): string { return branch.split("/").map(encodeURIComponent).join("/"); } +/** Host, plus `:port` when the remote pins a non-default port. */ +function forgeAuthority(location: ForgeWebLocation): string { + return location.port ? `${location.host}:${location.port}` : location.host; +} + function normalizeBlobPath(path: string | null | undefined): string | null { const segments: string[] = []; const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, ""); @@ -102,7 +114,7 @@ export function buildForgeBranchTreeUrl( if (!grammar || !location || !branch || branch === "HEAD") { return null; } - return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`; + return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`; } export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null { @@ -114,7 +126,7 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri return null; } const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); - let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`; + let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`; if (input.lineStart && input.lineStart > 0) { url += grammar.lineAnchor(input.lineStart, input.lineEnd); } diff --git a/packages/protocol/src/git-remote.test.ts b/packages/protocol/src/git-remote.test.ts index c098d03405b..26d70f3dbfe 100644 --- a/packages/protocol/src/git-remote.test.ts +++ b/packages/protocol/src/git-remote.test.ts @@ -27,3 +27,25 @@ describe("isCompleteGitRemote", () => { } }); }); + +describe("parseGitRemoteLocation port", () => { + it("preserves an explicit non-default port from an https remote", () => { + expect(parseGitRemoteLocation("https://home-git.example.com:60443/team/repo.git")?.port).toBe( + "60443", + ); + }); + + it("preserves a port from a plain http remote", () => { + expect(parseGitRemoteLocation("http://internal.example.com:3000/team/repo.git")?.port).toBe( + "3000", + ); + }); + + it("omits the port for a default-port remote", () => { + expect(parseGitRemoteLocation("https://github.com/acme/repo.git")?.port).toBeUndefined(); + }); + + it("has no port for an scp-form remote", () => { + expect(parseGitRemoteLocation("git@host.example.com:team/repo.git")?.port).toBeUndefined(); + }); +}); diff --git a/packages/protocol/src/git-remote.ts b/packages/protocol/src/git-remote.ts index 30110180b70..bc88ba0b726 100644 --- a/packages/protocol/src/git-remote.ts +++ b/packages/protocol/src/git-remote.ts @@ -11,6 +11,13 @@ const TRANSPORT_BY_PROTOCOL: Record = { export interface GitRemoteLocation { transport: "scp" | "ssh" | "http" | "https"; host: string; + /** + * Explicit non-default port from the remote (e.g. a self-hosted forge on + * `:60443`), or undefined for a default-port or scp-form remote. Kept separate + * from `host` so host-identity matching (forge detection, cloud-host checks) + * stays port-agnostic; only consumers that reconstruct a URL (web links) use it. + */ + port?: string; path: string; } @@ -71,7 +78,7 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n const normalizedPath = normalizeRemotePath(path); if (!isValidRemoteHost(host) || !normalizedPath) return null; - return { transport, host, path: normalizedPath }; + return { transport, host, port: parsed.port || undefined, path: normalizedPath }; } export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null { From 717f195f0cd123336ee262d70fb42d6fbce0604e Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 27 Jul 2026 13:01:43 -0700 Subject: [PATCH 100/420] fix(app): render HTML in PR comments (#2432) --- .../src/components/markdown/html-ish.test.ts | 10 +++ .../app/src/components/markdown/html-ish.ts | 90 +++++++++++++++---- 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/packages/app/src/components/markdown/html-ish.test.ts b/packages/app/src/components/markdown/html-ish.test.ts index c47a718fe09..37e3aeddb58 100644 --- a/packages/app/src/components/markdown/html-ish.test.ts +++ b/packages/app/src/components/markdown/html-ish.test.ts @@ -296,6 +296,16 @@ describe("splitHtmlishMarkdown", () => { ); }); + it("normalizes HTML table cells and inline formatting into markdown", () => { + expect( + normalizeHtmlishMarkdown( + "
Score: 78
No security concerns identified
Recommended focus areas for reviewBearer authentication
", + ), + ).toBe( + "\n- **Score**: 78\n- **No security concerns identified**\n- **Recommended focus areas for review**: Bearer authentication\n", + ); + }); + it("leaves complex code tags inert instead of parsing HTML", () => { expect(normalizeHtmlishMarkdown('')).toBe( '', diff --git a/packages/app/src/components/markdown/html-ish.ts b/packages/app/src/components/markdown/html-ish.ts index 7a92f0d20aa..93a88cd4b88 100644 --- a/packages/app/src/components/markdown/html-ish.ts +++ b/packages/app/src/components/markdown/html-ish.ts @@ -30,6 +30,15 @@ const BACKTICK_RUN_RE = /`+/g; const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i; const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i; const VOID_HTML_TAGS = new Set(["br", "img"]); +const MARKDOWN_TAG_WRAPPERS: Readonly> = { + b: ["**", "**"], + del: ["~~", "~~"], + em: ["*", "*"], + i: ["*", "*"], + s: ["~~", "~~"], + strike: ["~~", "~~"], + strong: ["**", "**"], +}; interface ProtectedMarkdownRange { start: number; @@ -313,34 +322,81 @@ function renderInlineTokens(tokens: HtmlToken[]): string { } const children = tokens.slice(index + 1, closeIndex); - if (token.name === "a") { - output += renderLinkToken(token, children); - index = closeIndex; + output += renderHtmlTag(token, children); + index = closeIndex; + } + + return output; +} + +function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string { + if (token.name === "a") { + return renderLinkToken(token, children); + } + if (token.name === "sub" || isHeadingTagName(token.name)) { + return renderInlineTokens(children); + } + if (token.name === "code" && children.every((child) => child.kind === "text")) { + return `\`${renderInlineTokens(children)}\``; + } + const wrapper = MARKDOWN_TAG_WRAPPERS[token.name]; + if (wrapper) { + return `${wrapper[0]}${renderInlineTokens(children)}${wrapper[1]}`; + } + if (token.name === "table") { + return renderTableTokens(children); + } + if (token.name === "p" || token.name === "div") { + return `\n\n${renderInlineTokens(children).trim()}\n\n`; + } + return `${token.raw}${renderInlineTokens(children)}`; +} + +function renderTableTokens(tokens: HtmlToken[]): string { + const rows: string[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + if (!isOpenTag(tokens[index], "tr")) { continue; } - if (token.name === "sub") { - output += renderInlineTokens(children); - index = closeIndex; + const closeIndex = findMatchingClose(tokens, index, "tr"); + if (closeIndex === null) { continue; } - if (token.name === "code" && children.every((child) => child.kind === "text")) { - output += `\`${renderInlineTokens(children)}\``; - index = closeIndex; + const cells = renderTableCells(tokens.slice(index + 1, closeIndex)); + if (cells.length === 1) { + rows.push(`- ${cells[0]}`); + } else if (cells.length > 1) { + const label = + cells[0].startsWith("**") && cells[0].endsWith("**") ? cells[0] : `**${cells[0]}**`; + rows.push(`- ${label}: ${cells.slice(1).join(" ")}`); + } + index = closeIndex; + } + + return rows.length > 0 ? `\n${rows.join("\n")}\n` : ""; +} + +function renderTableCells(tokens: HtmlToken[]): string[] { + const cells: string[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!isOpenTag(token, "td") && !isOpenTag(token, "th")) { continue; } - const rawTag = token.raw; - const tagName = token.name; - if (isHeadingTag(token)) { - output += renderInlineTokens(children); - index = closeIndex; + const closeIndex = findMatchingClose(tokens, index, token.name); + if (closeIndex === null) { continue; } - - output += `${rawTag}${renderInlineTokens(children)}`; + const cell = renderInlineTokens(tokens.slice(index + 1, closeIndex)).trim(); + if (cell) { + cells.push(cell); + } index = closeIndex; } - return output; + return cells; } function renderImageToken(token: HtmlTagToken): string { From 1c8fabd293f27b2faf20ed2b6ac0dc6d16ae8b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=B4=E5=A5=B9=E5=91=BD=40?= <908280968@qq.com> Date: Tue, 28 Jul 2026 04:12:22 +0800 Subject: [PATCH 101/420] fix(server): discover Codex project skills from cwd (#2423) --- .../providers/codex-app-server-agent.test.ts | 28 +++++++++++++++++-- .../agent/providers/codex-app-server-agent.ts | 2 +- 2 files changed, 26 insertions(+), 4 deletions(-) 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 50c3baaae23..2a4783534ac 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 @@ -271,14 +271,19 @@ async function listCommandsFromFakeCodex(skills: unknown[]): Promise { const message = JSON.parse(line); if (typeof message.id !== "number") continue; try { - process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n"); + process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method, message.params) }) + "\\n"); } catch (error) { process.stdout.write(JSON.stringify({ id: message.id, error: { message: error.message } }) + "\\n"); } @@ -1498,6 +1503,23 @@ describe("Codex app-server provider", () => { ); }); + test("lists project skill commands when app-server receives the project cwd in cwds", async () => { + const commands = await listCommandsFromFakeCodex([ + { + name: "project-skill-discovery-regression", + description: "A skill discovered from this project.", + path: "/tmp/codex-question-test/.agents/skills/project-skill-discovery-regression/SKILL.md", + }, + ]); + + expect(commands).toContainEqual({ + name: "project-skill-discovery-regression", + description: "A skill discovered from this project.", + argumentHint: "", + kind: "skill", + }); + }); + test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => { const commands = await listCommandsFromFakeCodex([ { 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 19f6911066b..fc26e7a47ef 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 @@ -3306,7 +3306,7 @@ export class CodexAppServerAgentSession implements AgentSession { try { const response = toObjectRecord( await this.client.request("skills/list", { - cwd: [this.config.cwd], + cwds: [this.config.cwd], }), ); const entries = Array.isArray(response?.data) ? response.data : []; From e59c94812b0dd6ceaf10a24eded08610e18caec7 Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 27 Jul 2026 13:12:39 -0700 Subject: [PATCH 102/420] perf(build): parallelize server dependencies (#2434) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a477e4ac551..ec45bed3878 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,9 @@ "build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay", "build:protocol": "npm run build --workspace=@getpaseo/protocol", "build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol", - "build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client", + "build:client": "npm run build --workspace=@getpaseo/client", "build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client", - "build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client", + "build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"", "build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean", "build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli", "build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli", From 5bd317205c1e2283b84273452433a48fcc3e12ba Mon Sep 17 00:00:00 2001 From: Derek Perez Date: Mon, 27 Jul 2026 14:44:25 -0700 Subject: [PATCH 103/420] fix(omp): expose injected Paseo tools directly (#2418) --- docs/providers.md | 2 +- .../server/agent/providers/omp/host-tools.test.ts | 14 +++++++++++++- .../src/server/agent/providers/omp/host-tools.ts | 1 + .../src/server/agent/providers/omp/rpc-types.ts | 1 + 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index a87fbfc4136..5bee5b40b2c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -34,7 +34,7 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. -OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory. +OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory. Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them. diff --git a/packages/server/src/server/agent/providers/omp/host-tools.test.ts b/packages/server/src/server/agent/providers/omp/host-tools.test.ts index 7b3d4fac834..70a795a5eef 100644 --- a/packages/server/src/server/agent/providers/omp/host-tools.test.ts +++ b/packages/server/src/server/agent/providers/omp/host-tools.test.ts @@ -128,7 +128,7 @@ class OmpHostToolHarness { } describe("OMP host tools", () => { - test("serializes the caller-scoped Paseo catalog for set_host_tools", () => { + test("marks every caller-scoped Paseo tool essential for direct invocation", () => { const catalog = createCatalog([ { name: "create_agent", @@ -137,6 +137,11 @@ describe("OMP host tools", () => { inputSchema: { initialPrompt: z.string().describe("Prompt for the new agent.") }, handler: async () => ({ content: [] }), }, + { + name: "browser_list_tabs", + description: "List browser tabs.", + handler: async () => ({ content: [] }), + }, ]); expect(serializeOmpHostTools(catalog)).toEqual([ @@ -144,8 +149,15 @@ describe("OMP host tools", () => { name: "create_agent", label: "Create agent", description: "Create a Paseo agent.", + loadMode: "essential", parameters: expect.objectContaining({ type: "object", required: ["initialPrompt"] }), }, + { + name: "browser_list_tabs", + description: "List browser tabs.", + loadMode: "essential", + parameters: expect.objectContaining({ type: "object" }), + }, ]); }); diff --git a/packages/server/src/server/agent/providers/omp/host-tools.ts b/packages/server/src/server/agent/providers/omp/host-tools.ts index 865bb4594c9..9850c654dd7 100644 --- a/packages/server/src/server/agent/providers/omp/host-tools.ts +++ b/packages/server/src/server/agent/providers/omp/host-tools.ts @@ -35,6 +35,7 @@ export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostTool const definition: OmpRpcHostToolDefinition = { name: tool.name, description: tool.description, + loadMode: "essential", parameters: serializePaseoToolInputParameters(tool), }; if (tool.title) { diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index e6e53031747..33760d4805a 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -178,6 +178,7 @@ export const OmpRpcHostToolDefinitionSchema = z name: z.string(), label: z.string().optional(), description: z.string(), + loadMode: z.enum(["essential", "discoverable"]).optional(), parameters: z.record(z.string(), z.unknown()), hidden: z.boolean().optional(), }) From a6fdcb469cad68fdef4f7f84eb348d7c97e66d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=BB=84=E6=B1=AA?= <50308467+huangguang1999@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:08:25 +0800 Subject: [PATCH 104/420] docs: list paseo-skins as a community project (#2343) --- README.ja.md | 1 + README.md | 1 + README.zh-CN.md | 1 + 3 files changed, 3 insertions(+) diff --git a/README.ja.md b/README.ja.md index d5881563ad0..288d773b5f1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -154,6 +154,7 @@ npm run typecheck ## 関連プロジェクト - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー +- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能 ## ライセンス diff --git a/README.md b/README.md index 86f7ab10452..f84b4514f4b 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ npm run typecheck ## Related projects - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir +- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 35e3569c32f..e2b36c0ac96 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -154,6 +154,7 @@ npm run typecheck ## 相关项目 - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写 +- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill - [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展 ### 自托管 relay TLS From b55acfc60f559d0f1fcec5c6a71a60642bc8ec9e Mon Sep 17 00:00:00 2001 From: Dmitry Sinev Date: Tue, 28 Jul 2026 01:28:30 +0300 Subject: [PATCH 105/420] fix(omp): accept nullable model context windows (#2406) --- .../agent/providers/omp/cli-runtime.test.ts | 34 +++++++++++++++++++ .../server/agent/providers/omp/rpc-types.ts | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index cfa8745442d..da195f057f2 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -186,6 +186,40 @@ describe("OMP CLI runtime", () => { ]); }); + test("accepts model catalogs with null contextWindow from NVIDIA", async () => { + const child = createOmpChild(); + replyToCommands(child, () => ({ + models: [ + { + provider: "nvidia", + id: "minimaxai/minimax-m3", + name: "MiniMax-M3", + contextWindow: null, + }, + { + provider: "zai", + id: "glm-5.2", + name: "GLM-5.2", + contextWindow: 131_072, + }, + ], + })); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + await expect(session.getAvailableModels()).resolves.toEqual([ + expect.objectContaining({ + provider: "nvidia", + id: "minimaxai/minimax-m3", + contextWindow: null, + }), + expect.objectContaining({ + provider: "zai", + id: "glm-5.2", + contextWindow: 131_072, + }), + ]); + }); + test("wraps OMP subagent RPC commands", async () => { const child = createOmpChild(); const commands: Record[] = []; diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 33760d4805a..63aaa7d2659 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -103,7 +103,7 @@ export const OmpModelSchema = z name: z.string().optional(), reasoning: z.boolean().optional(), thinking: OmpModelThinkingSchema.optional(), - contextWindow: z.number().optional(), + contextWindow: z.number().nullable().optional(), maxTokens: z.number().nullable().optional(), api: z.string().optional(), baseUrl: z.string().optional(), From f8dd0fc2e05778070004f89e4f89babefdcaf14d Mon Sep 17 00:00:00 2001 From: Saravjeet 'Aman' Singh Date: Tue, 28 Jul 2026 04:02:41 +0530 Subject: [PATCH 106/420] =?UTF-8?q?Switch=20projects=20from=20New=20Worksp?= =?UTF-8?q?ace=20with=20=E2=8C=98P/Ctrl+P=20(#2110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): add ⌘P shortcut to switch project on New Workspace screen Opens the existing project picker with its search focused so the project can be switched from the keyboard (type + Enter) instead of clicking the badge and then the project. Wires a new "workspace.project.pick" action through the standard keyboard pipeline (binding -> route passthrough -> dispatcher), handled by a screen-scoped handler on the New Workspace screen that is only registered while the screen is mounted and there are projects to pick. Because preventDefault only fires when a handler handles the key, ⌘P/Ctrl+P still triggers native print everywhere else. Adds a Settings -> Shortcuts help row (rebindable) and the "Switch project" label across all locales. * test(app): cover project picker shortcut --------- Co-authored-by: Mohamed Boudra --- packages/app/e2e/helpers/new-workspace.ts | 8 ++++++ packages/app/e2e/new-workspace-entry.spec.ts | 15 +++++++++++ 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/keyboard/actions.ts | 1 + .../keyboard/keyboard-action-dispatcher.ts | 2 ++ .../src/keyboard/keyboard-shortcuts.test.ts | 22 +++++++++++++++ .../app/src/keyboard/keyboard-shortcuts.ts | 27 +++++++++++++++++++ .../app/src/keyboard/route-shortcut.test.ts | 1 + packages/app/src/keyboard/route-shortcut.ts | 1 + .../app/src/screens/new-workspace-screen.tsx | 20 ++++++++++++++ 17 files changed, 105 insertions(+) diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 9e330541bf8..20bbd2c81b8 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -177,6 +177,14 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise }); } +export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise { + await page.keyboard.press("Control+P"); + + const searchInput = page.getByPlaceholder("Search projects"); + await expect(searchInput).toBeVisible({ timeout: 30_000 }); + await expect(searchInput).toBeFocused(); +} + export async function expectNewWorkspaceProjectSelected( page: Page, projectDisplayName: string, diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index 90a7e4623c2..b2ad1c88403 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -5,6 +5,7 @@ import { expectNewWorkspaceProjectSelected, openGlobalNewWorkspaceComposer, openNewWorkspaceComposer, + openNewWorkspaceProjectPickerWithShortcut, } from "./helpers/new-workspace"; import { getE2EDaemonPort } from "./helpers/daemon-port"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; @@ -106,6 +107,20 @@ test.describe("New workspace entry points", () => { } }); + test("Ctrl+P opens the project picker with search focused", async ({ page }) => { + const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + + await openNewWorkspaceProjectPickerWithShortcut(page); + } finally { + await seeded.cleanup(); + } + }); + test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({ page, }) => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index ee1ecbaba88..4acb2fb3662 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1831,6 +1831,7 @@ export const ar: TranslationResources = { sendMessage: "أرسل رسالة", queueMessage: "رسالة قائمة الانتظار", muteUnmuteVoiceMode: "كتم وضع الصوت /unmute", + switchProject: "تبديل المشروع", }, helpNotes: { showKeyboardShortcuts: "متاح عندما لا يكون التركيز في حقل نص أو محطة طرفية.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 63f6a4f7ee5..8f3d54de9b5 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1841,6 +1841,7 @@ export const en = { sendMessage: "Send message", queueMessage: "Queue message", muteUnmuteVoiceMode: "Mute/unmute voice mode", + switchProject: "Switch project", }, helpNotes: { showKeyboardShortcuts: "Available when focus is not in a text field or terminal.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index fc4d6ea60e1..def00ef9ae1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1879,6 +1879,7 @@ export const es: TranslationResources = { sendMessage: "enviar mensaje", queueMessage: "mensaje de cola", muteUnmuteVoiceMode: "Silenciar el modo de voz/unmute", + switchProject: "Cambiar proyecto", }, helpNotes: { showKeyboardShortcuts: "Disponible cuando el foco no está en un campo de texto o terminal.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 7a69a199603..5cf4578d16a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1881,6 +1881,7 @@ export const fr: TranslationResources = { sendMessage: "Envoyer un message", queueMessage: "Message de file d'attente", muteUnmuteVoiceMode: "Mode vocal/unmutemuet", + switchProject: "Changer de projet", }, helpNotes: { showKeyboardShortcuts: diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 888d8864b48..9b81dd8a9f7 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1847,6 +1847,7 @@ export const ja: TranslationResources = { sendMessage: "メッセージを送信", queueMessage: "メッセージをキューに追加", muteUnmuteVoiceMode: "音声モードのミュートを切り替え", + switchProject: "プロジェクトを切り替え", }, helpNotes: { showKeyboardShortcuts: diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index c1d6827e525..1027e88dcf3 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1862,6 +1862,7 @@ export const ptBR: TranslationResources = { sendMessage: "Enviar mensagem", queueMessage: "Enfileirar mensagem", muteUnmuteVoiceMode: "Silenciar/ativar modo de voz", + switchProject: "Trocar projeto", }, helpNotes: { showKeyboardShortcuts: diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index a46d91034e9..b8ec048deb2 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1869,6 +1869,7 @@ export const ru: TranslationResources = { sendMessage: "Отправить сообщение", queueMessage: "Сообщение в очереди", muteUnmuteVoiceMode: "Отключить голосовой режим /unmute", + switchProject: "Сменить проект", }, helpNotes: { showKeyboardShortcuts: "Доступно, когда фокус находится не в текстовом поле или терминале.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 63f4b97301e..8f5ee66d86c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1810,6 +1810,7 @@ export const zhCN: TranslationResources = { sendMessage: "发送消息", queueMessage: "消息排队", muteUnmuteVoiceMode: "静音/取消静音语音模式", + switchProject: "切换项目", }, helpNotes: { showKeyboardShortcuts: "焦点不在文本输入框或终端内时可用。", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 7f776cd0a41..81308525780 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -44,6 +44,7 @@ export type KeyboardActionId = | "shortcuts.dialog.toggle" | "workspace.terminal.new" | "workspace.new" + | "workspace.project.pick" | "worktree.new" | "workspace.archive" | "workspace.pin" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 0e2b35efd31..a5d1abe0d89 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -29,6 +29,7 @@ export type KeyboardActionId = | "workspace.terminal.new" | "sidebar.toggle.right" | "workspace.new" + | "workspace.project.pick" | "worktree.new" | "workspace.archive" | "workspace.pin"; @@ -62,6 +63,7 @@ export type KeyboardActionDefinition = | { id: "workspace.terminal.new"; scope: KeyboardActionScope } | { id: "sidebar.toggle.right"; scope: KeyboardActionScope } | { id: "workspace.new"; scope: KeyboardActionScope } + | { id: "workspace.project.pick"; scope: KeyboardActionScope } | { id: "worktree.new"; scope: KeyboardActionScope } | { id: "workspace.archive"; scope: KeyboardActionScope } | { id: "workspace.pin"; scope: KeyboardActionScope }; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 496288bb84f..e0bd6830510 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -142,6 +142,18 @@ describe("keyboard-shortcuts", () => { context: { isMac: false, commandCenterOpen: false, focusScope: "other" }, action: "workspace.new", }, + { + name: "matches Cmd+P to switch project on mac", + event: { key: "p", code: "KeyP", metaKey: true }, + context: { isMac: true, commandCenterOpen: false }, + action: "workspace.project.pick", + }, + { + name: "matches Ctrl+P to switch project on non-mac", + event: { key: "p", code: "KeyP", ctrlKey: true }, + context: { isMac: false, commandCenterOpen: false, focusScope: "other" }, + action: "workspace.project.pick", + }, { name: "matches question-mark shortcut to toggle the shortcuts dialog", event: { key: "?", code: "Slash", shiftKey: true }, @@ -410,6 +422,16 @@ describe("keyboard-shortcuts", () => { event: { key: "?", code: "Slash", shiftKey: true }, context: { focusScope: "message-input" }, }, + { + name: "does not switch project with Ctrl+P on non-mac while terminal is focused", + event: { key: "p", code: "KeyP", ctrlKey: true }, + context: { isMac: false, focusScope: "terminal" }, + }, + { + name: "does not switch project with Cmd+P while the command center is open", + event: { key: "p", code: "KeyP", metaKey: true }, + context: { isMac: true, commandCenterOpen: true }, + }, { name: "does not close tab with Ctrl+W on mac desktop (Cmd+W only)", event: { key: "w", code: "KeyW", ctrlKey: true }, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 60d0d2f7750..2ab7f5234af 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -131,6 +131,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record = { const SHORTCUT_HELP_LABEL_KEYS: Record = { "new-agent": "settings.shortcuts.help.openProject", "new-workspace": "settings.shortcuts.help.newWorkspace", + "switch-project": "settings.shortcuts.help.switchProject", "archive-workspace": "settings.shortcuts.help.archiveWorkspace", "workspace-tab-new": "settings.shortcuts.help.newTab", "workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab", @@ -231,6 +232,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + // --- Switch project (New Workspace screen) --- + { + id: "workspace-project-pick-cmd-p-mac", + action: "workspace.project.pick", + combo: "Cmd+P", + when: { mac: true, commandCenter: false }, + help: { + id: "switch-project", + section: "projects", + label: "Switch project", + keys: ["mod", "P"], + }, + }, + { + id: "workspace-project-pick-ctrl-p-non-mac", + action: "workspace.project.pick", + combo: "Ctrl+P", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "switch-project", + section: "projects", + label: "Switch project", + keys: ["mod", "P"], + }, + }, + // --- Archive workspace --- { // COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index a65d428edf4..5ace809b91c 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -30,6 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => { ["agent.interrupt", { id: "agent.interrupt", scope: "global" }], ["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }], ["workspace.new", { id: "workspace.new", scope: "sidebar" }], + ["workspace.project.pick", { id: "workspace.project.pick", scope: "workspace" }], ["workspace.archive", { id: "workspace.archive", scope: "sidebar" }], ["workspace.pin", { id: "workspace.pin", scope: "sidebar" }], ["worktree.new", { id: "worktree.new", scope: "sidebar" }], diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index 4f79ba2e328..71b4395ccaa 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -42,6 +42,7 @@ const PASSTHROUGH_DISPATCH: Record = { "agent.interrupt": { id: "agent.interrupt", scope: "global" }, "workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" }, "workspace.new": { id: "workspace.new", scope: "sidebar" }, + "workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" }, "workspace.archive": { id: "workspace.archive", scope: "sidebar" }, "workspace.pin": { id: "workspace.pin", scope: "sidebar" }, "worktree.new": { id: "worktree.new", scope: "sidebar" }, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index d846cafd00d..11875ccaffa 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -55,6 +55,8 @@ import { type PendingWorkspaceDraftSetup, } from "@/stores/workspace-draft-submission-store"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { getForgePresentation } from "@/git/forge"; @@ -181,6 +183,8 @@ interface PickerOptionData { const BRANCH_OPTION_PREFIX = "branch:"; const PR_OPTION_PREFIX = "github-pr:"; const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; +// Stable reference so the keyboard-action handler doesn't re-register each render. +const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"]; // Height of a single picker-trigger badge. The Base-row spacer reserves exactly // this so toggling Isolation to Local hides the row without shifting the form. const BADGE_HEIGHT = 28; @@ -1788,6 +1792,22 @@ export function NewWorkspaceScreen({ setProjectPickerOpen(true); }, []); + // Cmd/Ctrl+P opens the project picker with its search focused so the user can + // switch projects from the keyboard. Registered only while this screen is + // mounted, so the shortcut doesn't swallow the browser's native print + // elsewhere; gated on having projects to pick. + const handleProjectPick = useCallback(() => { + openProjectPicker(); + return true; + }, [openProjectPicker]); + useKeyboardActionHandler({ + handlerId: "new-workspace-project-pick", + actions: PROJECT_PICK_ACTIONS, + enabled: projectPickerOptions.length > 0, + priority: 0, + handle: handleProjectPick, + }); + const openIsolationPicker = useCallback(() => { setIsolationPickerOpen(true); }, []); From 89c2fac3c6262ee57db35431b6dc44319af8dd2d Mon Sep 17 00:00:00 2001 From: Kamil Date: Tue, 28 Jul 2026 00:48:29 +0200 Subject: [PATCH 107/420] Open project and workspace folders from the sidebar (#2491) * feat(app): open project folder from project context menu (#2487) * refactor(app): give file manager action a home --------- Co-authored-by: Mohamed Boudra --- .../src/components/sidebar-workspace-list.tsx | 7 +++ .../sidebar/sidebar-workspace-menu.tsx | 7 +++ 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 + .../open-in-file-manager/menu-item.tsx | 54 +++++++++++++++++++ 11 files changed, 84 insertions(+) create mode 100644 packages/app/src/workspace/open-in-file-manager/menu-item.tsx diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c349487db72..d988b880ac2 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -131,6 +131,7 @@ import { getIsElectron, } from "@/constants/platform"; import { getDesktopHost } from "@/desktop/host"; +import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item"; const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey; @@ -609,6 +610,10 @@ function ProjectKebabMenu({ {t("sidebar.project.actions.openNewWindow")} ) : null} + void; }) { + const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath; const { t } = useTranslation(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); @@ -698,6 +704,7 @@ function WorkspaceRowRightGroup({ archiveShortcutKeys={archiveShortcutKeys} isPinned={isPinned} onTogglePin={onTogglePin} + openInFileManagerPath={workspacePath} /> ) : null} diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index e04fc7ab6d6..f31489d7bc4 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -13,6 +13,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Shortcut } from "@/components/ui/shortcut"; +import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item"; const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -58,6 +59,7 @@ interface SidebarWorkspaceMenuProps { archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; + openInFileManagerPath?: string | null; } export function SidebarWorkspaceMenu({ @@ -73,6 +75,7 @@ export function SidebarWorkspaceMenu({ archiveShortcutKeys, isPinned, onTogglePin, + openInFileManagerPath, }: SidebarWorkspaceMenuProps) { const { t } = useTranslation(); const archiveTrailing = useMemo( @@ -137,6 +140,10 @@ export function SidebarWorkspaceMenu({ {isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")} ) : null} + ({ + color: theme.colors.foregroundMuted, +}); + +const leadingIcon = ; + +export function OpenInFileManagerMenuItem({ path, testID }: OpenInFileManagerMenuItemProps) { + const { t } = useTranslation(); + const toast = useToast(); + const isElectron = getIsElectron(); + const workspacePath = path?.trim() ?? ""; + const { targets } = useDesktopOpenTargets({ + isLocalExecution: isElectron && workspacePath.length > 0, + }); + const fileManagerTarget = targets.find((target) => target.kind === "file-manager"); + + const openInFileManager = useCallback(() => { + if (!fileManagerTarget || workspacePath.length === 0) return; + void openDesktopTarget({ + editorId: fileManagerTarget.id, + workspacePath, + }).catch((error) => { + console.warn("[open-in-file-manager] open failed", error); + toast.error(t("sidebar.project.actions.openFolderFailed")); + }); + }, [fileManagerTarget, t, toast, workspacePath]); + + if (!isElectron || !fileManagerTarget || workspacePath.length === 0) { + return null; + } + + return ( + + {t("sidebar.project.actions.openFolder")} + + ); +} From cbbf6c1684fb0415b7949e684d152f5f7453e769 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 27 Jul 2026 20:18:40 -0500 Subject: [PATCH 108/420] Carry local files and shared state into new worktrees (#2419) * feat(server): support symlink worktree includes * refactor(server): simplify worktree include handling * fix(server): constrain worktree include traversal * fix(server): clean up failed worktree branches * fix(server): skip missing worktree include entries * fix(server): make worktree includes best effort * fix(server): skip failed worktree includes * fix(server): harden worktree include planning * fix(server): preserve reused worktree result shape Materialization reports describe one creation attempt, not durable worktree identity. Carry them beside the worktree so newly created and reused results keep the same stable shape. * fix(server): harden worktree include boundaries Replace directory snapshots exactly, keep canonical Git metadata protected, report recovery skips, and only roll back branches owned before worktree creation. * fix(server): follow safe include aliases Traverse canonical in-checkout directory links during glob planning and treat coded revalidation failures as per-entry skips. * fix(server): make include preflight race safe Create fetched checkout refs atomically, retain overlapping copy entries for independent fallback, and protect the full managed-worktree base. * fix(server): preserve staged recovery state Validate completed directory snapshots, retain backups after failed restoration, and derive atomic ref guards from the repository object ID width. * fix(server): preserve partial include progress Keep safe glob matches, enforce recursive-directory types, validate staged roots, and retain OID guards through rollback. --------- Co-authored-by: Mohamed Boudra --- SECURITY.md | 6 + docs/development.md | 1 + .../src/server/paseo-worktree-service.ts | 3 + packages/server/src/server/session.ts | 1 + .../workspace-recovery-service.test.ts | 14 + .../workspace-recovery-service.ts | 12 + packages/server/src/server/worktree-core.ts | 20 +- .../server/src/server/worktree-session.ts | 11 + .../server/src/utils/worktree-include.test.ts | 635 ++++++++ packages/server/src/utils/worktree-include.ts | 1359 +++++++++++++++++ .../server/src/utils/worktree.posix.test.ts | 298 ++++ packages/server/src/utils/worktree.ts | 452 ++++-- public-docs/worktrees.md | 40 + 13 files changed, 2716 insertions(+), 136 deletions(-) create mode 100644 packages/server/src/utils/worktree-include.test.ts create mode 100644 packages/server/src/utils/worktree-include.ts diff --git a/SECURITY.md b/SECURITY.md index dacb14c41a1..4acb376b48b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,6 +50,12 @@ The daemon also supports an optional shared-secret password (set via `auth.passw Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary. +An explicit `symlink ` entry in a repository's .worktreeinclude intentionally gives a +Paseo-created worktree live access to that source-checkout file or directory. It is useful for +local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle +scripts can modify the source through the link. Paseo validates entries and refuses traversal or +destination-link escapes, but the linked source is a deliberate shared-data boundary. + If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case. In Docker, the official image runs the daemon and agents as the non-root diff --git a/docs/development.md b/docs/development.md index cd96239bac4..31b35d01206 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,6 +29,7 @@ Root checkout dev is intentionally split across terminals: - **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app. - **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint. - **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied. +- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy a snapshot into the new worktree; `symlink ` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs. - **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so. Override knobs: diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index f09a8133803..fa2e61cf022 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -28,6 +28,7 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent. import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import type { FirstAgentContext } from "@getpaseo/protocol/messages"; +import type { WorktreeIncludeSummary } from "../utils/worktree-include.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { projectId?: string; @@ -36,6 +37,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { export interface CreatePaseoWorktreeResult { worktree: WorktreeConfig; + worktreeIncludeSummary?: WorktreeIncludeSummary; intent: WorktreeCreationIntent; workspace: PersistedWorkspaceRecord; repoRoot: string; @@ -98,6 +100,7 @@ export async function createPaseoWorktree( return { worktree: createdWorktree.worktree, + worktreeIncludeSummary: createdWorktree.worktreeIncludeSummary, intent: createdWorktree.intent, workspace, repoRoot: createdWorktree.repoRoot, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7164c6de686..51059637a4f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -745,6 +745,7 @@ export class Session { logger: this.sessionLogger, }); this.workspaceRecovery = createWorkspaceRecoveryService({ + logger: this.sessionLogger, paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 91631a5631a..2203e307283 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -76,6 +76,7 @@ function createHarness(input?: { const directories = new Set(input?.directories ?? ["/repo"]); const unarchived: string[] = []; const service = createWorkspaceRecoveryService({ + logger: { warn: () => undefined } as never, paseoHome: input?.paseoHome ?? "/paseo-home", worktreesRoot: input?.worktreesRoot ?? "/worktrees", getWorkspace: async (workspaceId) => @@ -135,6 +136,7 @@ describe("workspace recovery", () => { const sourceSubdirectory = join(repoDir, "packages", "app"); mkdirSync(sourceSubdirectory, { recursive: true }); writeFileSync(join(sourceSubdirectory, "README.md"), "app\n"); + writeFileSync(join(repoDir, ".worktreeinclude"), "missing.local\n"); execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); @@ -170,7 +172,9 @@ describe("workspace recovery", () => { mainRepoRoot: repoDir, }); const unarchived: string[] = []; + const warnings: unknown[][] = []; const service = createWorkspaceRecoveryService({ + logger: { warn: (...args: unknown[]) => warnings.push(args) } as never, paseoHome, worktreesRoot, getWorkspace: async (workspaceId) => @@ -189,6 +193,15 @@ describe("workspace recovery", () => { expect(existsSync(worktreeRoot)).toBe(true); expect(existsSync(workspaceCwd)).toBe(true); expect(unarchived).toEqual([workspace.workspaceId]); + expect(warnings).toEqual([ + [ + expect.objectContaining({ + materialized: 0, + skipped: [expect.objectContaining({ raw: "missing.local", reason: "missing" })], + }), + "Worktree include completed with skipped entries during workspace recovery", + ], + ]); }); test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => { @@ -220,6 +233,7 @@ describe("workspace recovery", () => { }); const unarchived: string[] = []; const service = createWorkspaceRecoveryService({ + logger: { warn: () => undefined } as never, paseoHome, worktreesRoot, getWorkspace: async (workspaceId) => diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index 1d98a9128cb..539dc218fca 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -1,4 +1,5 @@ import { basename } from "node:path"; +import type { Logger } from "pino"; import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; import { runGitCommand } from "../../../utils/run-git-command.js"; @@ -59,6 +60,7 @@ type RecoveryPlan = type UnavailableRecoveryState = Extract; export function createWorkspaceRecoveryService(deps: { + logger: Logger; paseoHome: string; worktreesRoot?: string; getWorkspace: (workspaceId: string) => Promise; @@ -195,6 +197,16 @@ export function createWorkspaceRecoveryService(deps: { worktreesRoot: deps.worktreesRoot, }); recreatedWorktreePath = result.worktreePath; + if (result.worktreeIncludeSummary.skipped.length > 0) { + deps.logger.warn( + { + materialized: result.worktreeIncludeSummary.materialized, + skipped: result.worktreeIncludeSummary.skipped, + worktreePath: result.worktreePath, + }, + "Worktree include completed with skipped entries during workspace recovery", + ); + } } catch (error) { throw toWorktreeRequestError(error); } diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 90d07889053..c83fdf7b6ef 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -8,6 +8,7 @@ import { validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; +import type { WorktreeIncludeSummary } from "../utils/worktree-include.js"; import { resolveWorktreeCreationIntent, type ResolveWorktreeCreationIntentInput, @@ -42,6 +43,7 @@ export interface CreateWorktreeCoreDeps { export interface CreateWorktreeCoreResult { worktree: WorktreeConfig; + worktreeIncludeSummary?: WorktreeIncludeSummary; intent: WorktreeCreationIntent; repoRoot: string; created: boolean; @@ -120,15 +122,17 @@ export async function createWorktreeCore( return { worktree: existingWorktree, intent, repoRoot, created: false }; } + const { worktreeIncludeSummary, ...worktree } = await createWorktree({ + cwd: repoRoot, + worktreeSlug: normalizedSlug, + source: intent, + runSetup: input.runSetup ?? true, + paseoHome: input.paseoHome, + worktreesRoot: input.worktreesRoot, + }); return { - worktree: await createWorktree({ - cwd: repoRoot, - worktreeSlug: normalizedSlug, - source: intent, - runSetup: input.runSetup ?? true, - paseoHome: input.paseoHome, - worktreesRoot: input.worktreesRoot, - }), + worktree, + worktreeIncludeSummary, intent, repoRoot, created: true, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 5573a945f8a..3b558affc0d 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -605,6 +605,17 @@ export async function createPaseoWorktreeWorkflow( const workspace = createdWorktree.workspace; const setupContinuation = options?.setupContinuation ?? { kind: "workspace" }; + if (createdWorktree.created && createdWorktree.worktreeIncludeSummary?.skipped.length) { + dependencies.sessionLogger.warn( + { + materialized: createdWorktree.worktreeIncludeSummary.materialized, + skipped: createdWorktree.worktreeIncludeSummary.skipped, + worktreePath: createdWorktree.worktree.worktreePath, + }, + "Worktree include completed with skipped entries", + ); + } + setTimeout(() => { if (input.firstAgentContext) { dependencies.autoNameWorkspaceBranchForFirstAgent({ diff --git a/packages/server/src/utils/worktree-include.test.ts b/packages/server/src/utils/worktree-include.test.ts new file mode 100644 index 00000000000..b846b9b7cc7 --- /dev/null +++ b/packages/server/src/utils/worktree-include.test.ts @@ -0,0 +1,635 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + existsSync, + chmodSync, + lstatSync, + linkSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { dirname, join, relative } from "path"; +import { isPlatform } from "../test-utils/platform.js"; +import { materializeWorktreeIncludePlan, readWorktreeIncludePlan } from "./worktree-include.js"; + +describe("worktree include planning", () => { + let tempDir: string; + let sourceRoot: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "worktree-include-test-")); + sourceRoot = join(tempDir, "source"); + mkdirSync(sourceRoot); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("copies bare entries and accepts explicit copy and symlink modes", async () => { + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + [".env.local", ".cache/**", "symlink shared-state", "copy packages/*/.runtime.env", ""].join( + "\n", + ), + ); + writeFileSync(join(sourceRoot, ".env.local"), "source\n"); + mkdirSync(join(sourceRoot, ".cache"), { recursive: true }); + writeFileSync(join(sourceRoot, ".cache", "state.txt"), "cache\n"); + mkdirSync(join(sourceRoot, "shared-state"), { recursive: true }); + writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "shared\n"); + mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true }); + writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toHaveLength(4); + expect(plan.materializations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ mode: "copy", relativePath: ".env.local", sourceKind: "file" }), + expect.objectContaining({ mode: "copy", relativePath: ".cache", sourceKind: "directory" }), + expect.objectContaining({ + mode: "symlink", + relativePath: "shared-state", + sourceKind: "directory", + }), + expect.objectContaining({ + mode: "copy", + relativePath: "packages/api/.runtime.env", + sourceKind: "file", + }), + ]), + ); + }); + + it("skips invalid and conflicting entries while retaining safe entries", async () => { + writeFileSync(join(sourceRoot, "shared"), "source\n"); + writeFileSync(join(sourceRoot, ".env"), "source\n"); + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + ["../outside", "symlink", "shared", "symlink shared", ".env", ""].join("\n"), + ); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }), + ]); + expect(plan.skipped).toEqual( + expect.arrayContaining([ + expect.objectContaining({ lineNumber: 1, raw: "../outside", reason: "invalid" }), + expect.objectContaining({ lineNumber: 2, raw: "symlink", reason: "invalid" }), + expect.objectContaining({ lineNumber: 3, raw: "shared", reason: "conflict" }), + expect.objectContaining({ lineNumber: 4, raw: "symlink shared", reason: "conflict" }), + ]), + ); + }); + + it("skips missing entries while retaining existing literal and glob matches", async () => { + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + [".env", ".env.local", "config/*.env", "missing/**", ""].join("\n"), + ); + writeFileSync(join(sourceRoot, ".env"), "source\n"); + mkdirSync(join(sourceRoot, "config"), { recursive: true }); + writeFileSync(join(sourceRoot, "config", "runtime.env"), "runtime\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }), + expect.objectContaining({ + mode: "copy", + relativePath: "config/runtime.env", + sourceKind: "file", + }), + ]); + expect(plan.skipped).toEqual( + expect.arrayContaining([ + expect.objectContaining({ raw: ".env.local", reason: "missing" }), + expect.objectContaining({ raw: "missing/**", reason: "missing" }), + ]), + ); + }); + + it("requires the optimized trailing recursive form to resolve to a directory", async () => { + writeFileSync(join(sourceRoot, "cache"), "not-a-directory\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([]); + expect(plan.skipped).toEqual([expect.objectContaining({ raw: "cache/**", reason: "unsafe" })]); + }); + + it("skips directory copies that overlap protected worktree paths", async () => { + const protectedWorktreeRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"); + mkdirSync(protectedWorktreeRoot, { recursive: true }); + writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n"); + + const plan = await readWorktreeIncludePlan({ + sourceRoot, + excludedSourceRoots: [protectedWorktreeRoot], + }); + + expect(plan.materializations).toEqual([]); + expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]); + }); + + it("protects every managed project beneath checkout-local worktree storage", async () => { + const managedWorktreesRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees"); + const siblingWorktree = join(managedWorktreesRoot, "other-project", "sibling"); + mkdirSync(siblingWorktree, { recursive: true }); + writeFileSync(join(siblingWorktree, ".env"), "secret\n"); + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + ".dev/paseo-home/worktrees/other-project/sibling/.env\n", + ); + + const plan = await readWorktreeIncludePlan({ + sourceRoot, + excludedSourceRoots: [managedWorktreesRoot], + }); + + expect(plan.materializations).toEqual([]); + expect(plan.skipped).toEqual([expect.objectContaining({ reason: "unsafe" })]); + }); + + it("allows includes from a source worktree inside the protected worktree root", async () => { + const managedWorktreesRoot = join(tempDir, "paseo-home", "worktrees", "project"); + sourceRoot = join(managedWorktreesRoot, "source-worktree"); + mkdirSync(sourceRoot, { recursive: true }); + writeFileSync(join(sourceRoot, ".worktreeinclude"), ".env\n"); + writeFileSync(join(sourceRoot, ".env"), "source\n"); + + const plan = await readWorktreeIncludePlan({ + sourceRoot, + excludedSourceRoots: [managedWorktreesRoot], + }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ relativePath: ".env", sourceKind: "file" }), + ]); + expect(plan.skipped).toEqual([]); + }); + + it.skipIf(isPlatform("win32"))( + "skips protected paths reached through a symlink alias", + async () => { + const sourceAlias = join(tempDir, "source-alias"); + symlinkSync(sourceRoot, sourceAlias, "dir"); + mkdirSync(join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"), { + recursive: true, + }); + writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n"); + + const plan = await readWorktreeIncludePlan({ + sourceRoot, + excludedSourceRoots: [join(sourceAlias, ".dev", "paseo-home", "worktrees", "project")], + }); + + expect(plan.materializations).toEqual([]); + expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]); + }, + ); + + it.skipIf(isPlatform("win32"))( + "skips external source links while retaining safe entries", + async () => { + const outsidePath = join(tempDir, "outside.txt"); + writeFileSync(outsidePath, "outside\n"); + writeFileSync(join(sourceRoot, "safe.txt"), "safe\n"); + symlinkSync(outsidePath, join(sourceRoot, "linked.txt")); + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + ["safe.txt", "linked.txt", ""].join("\n"), + ); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }), + ]); + expect(plan.skipped).toEqual([ + expect.objectContaining({ raw: "linked.txt", reason: "unsafe" }), + ]); + }, + ); + + it.skipIf(isPlatform("win32"))("matches globs beneath a safe symlinked directory", async () => { + mkdirSync(join(sourceRoot, "actual-config")); + writeFileSync(join(sourceRoot, "actual-config", "runtime.env"), "runtime\n"); + symlinkSync(join(sourceRoot, "actual-config"), join(sourceRoot, "config"), "dir"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/*.env\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ relativePath: "config/runtime.env", sourceKind: "file" }), + ]); + expect(plan.skipped).toEqual([]); + }); + + it.skipIf(isPlatform("win32"))( + "does not scan unrelated directories for bounded globs", + async () => { + writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n"); + mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true }); + writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n"); + const unreadableDirectory = join(sourceRoot, "unrelated"); + mkdirSync(unreadableDirectory); + chmodSync(unreadableDirectory, 0o000); + + try { + const plan = await readWorktreeIncludePlan({ sourceRoot }); + expect(plan.materializations).toEqual([ + expect.objectContaining({ relativePath: "packages/api/.runtime.env" }), + ]); + } finally { + chmodSync(unreadableDirectory, 0o700); + } + }, + ); + + it.skipIf(isPlatform("win32") || process.getuid?.() === 0)( + "skips inaccessible entries while retaining safe paths", + async () => { + const inaccessibleDirectory = join(sourceRoot, "private"); + mkdirSync(inaccessibleDirectory); + writeFileSync(join(inaccessibleDirectory, "secret.txt"), "secret\n"); + writeFileSync(join(sourceRoot, "safe.txt"), "safe\n"); + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + ["private/**", "private/*", "safe.txt", ""].join("\n"), + ); + chmodSync(inaccessibleDirectory, 0o000); + + try { + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }), + ]); + expect(plan.skipped).toEqual( + expect.arrayContaining([ + expect.objectContaining({ raw: "private/**", reason: "materialization" }), + expect.objectContaining({ raw: "private/*", reason: "materialization" }), + ]), + ); + } finally { + chmodSync(inaccessibleDirectory, 0o700); + } + }, + ); + + it.skipIf(isPlatform("win32") || process.getuid?.() === 0)( + "retains valid glob matches when a viable sibling is unreadable", + async () => { + mkdirSync(join(sourceRoot, "packages", "good"), { recursive: true }); + mkdirSync(join(sourceRoot, "packages", "private"), { recursive: true }); + writeFileSync(join(sourceRoot, "packages", "good", ".runtime.env"), "good\n"); + writeFileSync(join(sourceRoot, "packages", "private", ".runtime.env"), "private\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n"); + chmodSync(join(sourceRoot, "packages", "private"), 0o000); + + try { + const plan = await readWorktreeIncludePlan({ sourceRoot }); + expect(plan.materializations).toEqual([ + expect.objectContaining({ relativePath: "packages/good/.runtime.env" }), + ]); + expect(plan.skipped).toEqual([ + expect.objectContaining({ raw: "packages/*/.runtime.env", reason: "materialization" }), + ]); + } finally { + chmodSync(join(sourceRoot, "packages", "private"), 0o700); + } + }, + ); +}); + +describe.skipIf(isPlatform("win32"))("worktree include materialization", () => { + let tempDir: string; + let sourceRoot: string; + let worktreeRoot: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "worktree-include-materialize-test-")); + sourceRoot = join(tempDir, "source"); + worktreeRoot = join(tempDir, "worktree"); + mkdirSync(sourceRoot); + mkdirSync(worktreeRoot); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("copies snapshots, links shared paths, and is idempotent", async () => { + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + ["copy.txt", "copy-dir/**", "symlink linked.txt", "symlink linked-dir"].join("\n"), + ); + writeFileSync(join(sourceRoot, "copy.txt"), "copy-v1\n"); + mkdirSync(join(sourceRoot, "copy-dir"), { recursive: true }); + writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v1\n"); + writeFileSync(join(sourceRoot, "linked.txt"), "linked-v1\n"); + mkdirSync(join(sourceRoot, "linked-dir"), { recursive: true }); + writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v1\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(lstatSync(join(worktreeRoot, "copy.txt")).isSymbolicLink()).toBe(false); + expect(lstatSync(join(worktreeRoot, "copy-dir")).isSymbolicLink()).toBe(false); + expect(lstatSync(join(worktreeRoot, "linked.txt")).isSymbolicLink()).toBe(true); + expect(lstatSync(join(worktreeRoot, "linked-dir")).isSymbolicLink()).toBe(true); + + writeFileSync(join(sourceRoot, "copy.txt"), "copy-v2\n"); + writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v2\n"); + writeFileSync(join(sourceRoot, "linked.txt"), "linked-v2\n"); + writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v2\n"); + + expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v1\n"); + expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v1\n"); + expect(readFileSync(join(worktreeRoot, "linked.txt"), "utf8")).toBe("linked-v2\n"); + expect(readFileSync(join(worktreeRoot, "linked-dir", "state.txt"), "utf8")).toBe( + "linked-dir-v2\n", + ); + + await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v2\n"); + expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v2\n"); + }); + + it("replaces an existing directory snapshot without retaining destination-only files", async () => { + mkdirSync(join(sourceRoot, "cache")); + writeFileSync(join(sourceRoot, "cache", "current.txt"), "current\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n"); + mkdirSync(join(worktreeRoot, "cache")); + writeFileSync(join(worktreeRoot, "cache", "stale.txt"), "stale\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(result).toMatchObject({ materialized: 1, skipped: [] }); + expect(readFileSync(join(worktreeRoot, "cache", "current.txt"), "utf8")).toBe("current\n"); + expect(existsSync(join(worktreeRoot, "cache", "stale.txt"))).toBe(false); + }); + + it("retains an explicit descendant when an overlapping directory copy is skipped", async () => { + mkdirSync(join(sourceRoot, "config", "nested"), { recursive: true }); + writeFileSync(join(sourceRoot, "config", "local.env"), "local\n"); + writeFileSync(join(sourceRoot, "config", "nested", "state.txt"), "state\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/**\nconfig/local.env\n"); + mkdirSync(join(worktreeRoot, "config")); + writeFileSync(join(worktreeRoot, "config", "nested"), "conflict\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(result).toMatchObject({ materialized: 1 }); + expect(result.skipped).toEqual([ + expect.objectContaining({ raw: "config/**", reason: "conflict" }), + ]); + expect(readFileSync(join(worktreeRoot, "config", "local.env"), "utf8")).toBe("local\n"); + }); + + it("skips a destination parent symlink without writing through it", async () => { + mkdirSync(join(sourceRoot, "config"), { recursive: true }); + writeFileSync(join(sourceRoot, "config", "local.json"), "{}\n"); + writeFileSync(join(sourceRoot, "safe.txt"), "safe\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/local.json\nsafe.txt\n"); + + const outsideRoot = join(tempDir, "outside"); + mkdirSync(outsideRoot); + symlinkSync(outsideRoot, join(worktreeRoot, "config")); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(existsSync(join(outsideRoot, "local.json"))).toBe(false); + expect(readFileSync(join(worktreeRoot, "safe.txt"), "utf8")).toBe("safe\n"); + expect(result).toMatchObject({ materialized: 1 }); + expect(result.skipped).toEqual([ + expect.objectContaining({ raw: "config/local.json", reason: "conflict" }), + ]); + expect(readdirSync(worktreeRoot)).not.toContain( + expect.stringMatching(/^\.paseo-worktreeinclude-/), + ); + }); + + it("copies resolved source links and creates direct live links", async () => { + const targetPath = join(sourceRoot, "target.txt"); + const targetDirectoryPath = join(sourceRoot, "target-directory"); + writeFileSync(targetPath, "v1\n"); + mkdirSync(targetDirectoryPath); + writeFileSync(join(targetDirectoryPath, "state.txt"), "v1\n"); + symlinkSync(targetPath, join(sourceRoot, "copy-link.txt")); + symlinkSync(targetPath, join(sourceRoot, "live-link.txt")); + symlinkSync(targetDirectoryPath, join(sourceRoot, "copy-directory-link"), "dir"); + symlinkSync(targetDirectoryPath, join(sourceRoot, "live-directory-link"), "dir"); + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + [ + "copy-link.txt", + "symlink live-link.txt", + "copy-directory-link", + "symlink live-directory-link", + "", + ].join("\n"), + ); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + const copiedPath = join(worktreeRoot, "copy-link.txt"); + const linkedPath = join(worktreeRoot, "live-link.txt"); + const copiedDirectoryPath = join(worktreeRoot, "copy-directory-link"); + const linkedDirectoryPath = join(worktreeRoot, "live-directory-link"); + const canonicalWorktreeRoot = realpathSync(worktreeRoot); + expect(result).toMatchObject({ materialized: 4, skipped: [] }); + expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false); + expect(lstatSync(linkedPath).isSymbolicLink()).toBe(true); + expect(lstatSync(copiedDirectoryPath).isSymbolicLink()).toBe(false); + expect(lstatSync(linkedDirectoryPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(linkedPath)).toBe( + relative(dirname(join(canonicalWorktreeRoot, "live-link.txt")), realpathSync(targetPath)), + ); + expect(readlinkSync(linkedDirectoryPath)).toBe( + relative( + dirname(join(canonicalWorktreeRoot, "live-directory-link")), + realpathSync(targetDirectoryPath), + ), + ); + expect(realpathSync(linkedPath)).toBe(realpathSync(targetPath)); + expect(realpathSync(linkedDirectoryPath)).toBe(realpathSync(targetDirectoryPath)); + + writeFileSync(targetPath, "v2\n"); + writeFileSync(join(targetDirectoryPath, "state.txt"), "v2\n"); + expect(readFileSync(copiedPath, "utf8")).toBe("v1\n"); + expect(readFileSync(linkedPath, "utf8")).toBe("v2\n"); + expect(readFileSync(join(copiedDirectoryPath, "state.txt"), "utf8")).toBe("v1\n"); + expect(readFileSync(join(linkedDirectoryPath, "state.txt"), "utf8")).toBe("v2\n"); + }); + + it("rejects source links that alias Git metadata", async () => { + mkdirSync(join(sourceRoot, ".git")); + writeFileSync(join(sourceRoot, ".git", "config"), "secret\n"); + symlinkSync(join(sourceRoot, ".git"), join(sourceRoot, "metadata"), "dir"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "metadata\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([]); + expect(plan.skipped).toEqual([expect.objectContaining({ raw: "metadata", reason: "unsafe" })]); + }); + + it("treats hard links as ordinary files", async () => { + const targetPath = join(sourceRoot, "target.txt"); + const hardLinkPath = join(sourceRoot, "hard-link.txt"); + writeFileSync(targetPath, "v1\n"); + linkSync(targetPath, hardLinkPath); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "hard-link.txt\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + const copiedPath = join(worktreeRoot, "hard-link.txt"); + expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false); + expect(readFileSync(copiedPath, "utf8")).toBe("v1\n"); + }); + + it("skips a source link retargeted outside the checkout after planning", async () => { + const insidePath = join(sourceRoot, "inside.txt"); + const linkedPath = join(sourceRoot, "linked.txt"); + const outsidePath = join(tempDir, "outside.txt"); + writeFileSync(insidePath, "inside\n"); + writeFileSync(outsidePath, "outside\n"); + symlinkSync(insidePath, linkedPath); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink linked.txt\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + rmSync(linkedPath); + symlinkSync(outsidePath, linkedPath); + + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(existsSync(join(worktreeRoot, "linked.txt"))).toBe(false); + expect(result.skipped).toEqual([ + expect.objectContaining({ raw: "symlink linked.txt", reason: "unsafe" }), + ]); + }); + + it("skips a linked directory that exposes an external nested link", async () => { + const sharedPath = join(sourceRoot, "shared"); + const outsidePath = join(tempDir, "outside.txt"); + mkdirSync(sharedPath); + writeFileSync(outsidePath, "outside\n"); + symlinkSync(outsidePath, join(sharedPath, "outside.txt")); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([]); + expect(plan.skipped).toEqual([ + expect.objectContaining({ raw: "symlink shared", reason: "unsafe" }), + ]); + }); + + it("allows a linked directory with internal nested links", async () => { + const sharedPath = join(sourceRoot, "shared"); + const targetPath = join(sourceRoot, "shared-target"); + mkdirSync(sharedPath); + mkdirSync(targetPath); + writeFileSync(join(targetPath, "state.txt"), "source\n"); + symlinkSync(targetPath, join(sharedPath, "target"), "dir"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(result).toMatchObject({ materialized: 1, skipped: [] }); + expect(readFileSync(join(worktreeRoot, "shared", "target", "state.txt"), "utf8")).toBe( + "source\n", + ); + }); + + it("skips a source removed after planning", async () => { + writeFileSync(join(sourceRoot, ".worktreeinclude"), "runtime.env\n"); + const sourcePath = join(sourceRoot, "runtime.env"); + writeFileSync(sourcePath, "source\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + rmSync(sourcePath); + + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(existsSync(join(worktreeRoot, "runtime.env"))).toBe(false); + expect(result.skipped).toEqual([ + expect.objectContaining({ raw: "runtime.env", reason: "missing" }), + ]); + }); + + it.skipIf(process.getuid?.() === 0)( + "skips ordinary filesystem failures during materialization revalidation", + async () => { + mkdirSync(join(sourceRoot, "private")); + writeFileSync(join(sourceRoot, "private", "state.txt"), "private\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "private/**\n"); + const plan = await readWorktreeIncludePlan({ sourceRoot }); + chmodSync(join(sourceRoot, "private"), 0o000); + + try { + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + expect(result).toMatchObject({ materialized: 0 }); + expect(result.skipped).toEqual([ + expect.objectContaining({ raw: "private/**", reason: "materialization" }), + ]); + } finally { + chmodSync(join(sourceRoot, "private"), 0o700); + } + }, + ); +}); + +describe.skipIf(!isPlatform("win32"))("worktree include Windows directory links", () => { + let tempDir: string; + let sourceRoot: string; + let worktreeRoot: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "worktree-include-windows-test-")); + sourceRoot = join(tempDir, "source"); + worktreeRoot = join(tempDir, "worktree"); + mkdirSync(sourceRoot); + mkdirSync(worktreeRoot); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("uses a live directory link and removes it without touching the source", async () => { + mkdirSync(join(sourceRoot, "shared-state")); + writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v1\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared-state\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v2\n"); + expect(readFileSync(join(worktreeRoot, "shared-state", "state.txt"), "utf8")).toBe( + "source-v2\n", + ); + + rmSync(worktreeRoot, { recursive: true, force: true }); + expect(readFileSync(join(sourceRoot, "shared-state", "state.txt"), "utf8")).toBe("source-v2\n"); + }); +}); diff --git a/packages/server/src/utils/worktree-include.ts b/packages/server/src/utils/worktree-include.ts new file mode 100644 index 00000000000..32c00abbc2f --- /dev/null +++ b/packages/server/src/utils/worktree-include.ts @@ -0,0 +1,1359 @@ +import { type Dirent, type Stats } from "fs"; +import { + copyFile, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + rmdir, + symlink, +} from "fs/promises"; +import { + basename as pathBasename, + dirname, + isAbsolute, + join, + relative, + resolve, + win32, +} from "path"; +import { areEquivalentPaths, isPathInsideRoot } from "./path.js"; + +const WORKTREE_INCLUDE_FILE_NAME = ".worktreeinclude"; + +export type WorktreeIncludeMode = "copy" | "symlink"; +type WorktreeIncludeSourceKind = "file" | "directory"; +type WorktreeIncludeErrorCode = + | "conflict" + | "invalid_entry" + | "missing_source" + | "source_changed" + | "unsupported_source" + | "windows_symlink_unavailable"; + +export type WorktreeIncludeSkipReason = + | "conflict" + | "invalid" + | "materialization" + | "missing" + | "source_changed" + | "unsafe"; + +interface WorktreeIncludeEntry { + lineNumber: number; + mode: WorktreeIncludeMode; + raw: string; + relativePath: string; +} + +export interface WorktreeIncludeMaterialization { + lineNumber: number; + mode: WorktreeIncludeMode; + raw: string; + relativePath: string; + sourceKind: WorktreeIncludeSourceKind; +} + +export interface WorktreeIncludeSkippedEntry { + lineNumber: number; + message: string; + raw: string; + reason: WorktreeIncludeSkipReason; +} + +export interface WorktreeIncludeSummary { + materialized: number; + skipped: WorktreeIncludeSkippedEntry[]; +} + +export interface WorktreeIncludePlan { + excludedSourceRoots: string[]; + materializations: WorktreeIncludeMaterialization[]; + skipped: WorktreeIncludeSkippedEntry[]; + sourceRoot: string; +} + +export interface ReadWorktreeIncludePlanOptions { + excludedSourceRoots?: string[]; + sourceRoot: string; +} + +export interface MaterializeWorktreeIncludePlanOptions { + plan: WorktreeIncludePlan; + worktreeRoot: string; +} + +interface ResolvedWorktreeIncludeMaterialization { + materialization: WorktreeIncludeMaterialization; + sourcePath: string; +} + +interface StagedWorktreeIncludeMaterialization { + directoryPath: string; + entryPath: string; +} + +interface ParsedWorktreeIncludeEntries { + entries: WorktreeIncludeEntry[]; + skipped: WorktreeIncludeSkippedEntry[]; +} + +interface NormalizedWorktreeIncludeMaterializations { + materializations: WorktreeIncludeMaterialization[]; + skipped: WorktreeIncludeSkippedEntry[]; +} + +interface WorktreeIncludeCandidateCollection { + candidates: string[]; + errorsByPattern: Map; +} + +export class WorktreeIncludeError extends Error { + constructor( + public readonly code: WorktreeIncludeErrorCode, + message: string, + ) { + super(message); + this.name = "WorktreeIncludeError"; + } +} + +class WorktreeIncludeCleanupError extends Error { + constructor( + message: string, + public readonly cleanupError: unknown, + ) { + super(message); + this.name = "WorktreeIncludeCleanupError"; + } +} + +export async function readWorktreeIncludePlan( + options: ReadWorktreeIncludePlanOptions, +): Promise { + const sourceRoot = await realpath(options.sourceRoot); + const { entries, skipped } = await readWorktreeIncludeEntries(sourceRoot); + if (entries.length === 0) { + return { + sourceRoot, + excludedSourceRoots: [], + materializations: [], + skipped, + }; + } + + const excludedSourceRoots = ( + await Promise.all((options.excludedSourceRoots ?? []).map(canonicalizeExistingPathPrefix)) + ).filter((candidate) => !isPathInsideRoot(candidate, sourceRoot)); + const candidatePatterns = entries + .filter( + (entry) => entry.relativePath.includes("*") && getRecursiveDirectoryPath(entry) === null, + ) + .map((entry) => entry.relativePath); + const candidateCollection = + candidatePatterns.length > 0 + ? await collectWorktreeIncludeCandidates({ + sourceRoot, + excludedSourceRoots, + patterns: candidatePatterns, + }) + : { candidates: [], errorsByPattern: new Map() }; + const materializations: WorktreeIncludeMaterialization[] = []; + + for (const entry of entries) { + const matchedPaths = resolveEntryMatches({ entry, candidates: candidateCollection.candidates }); + const candidateError = candidateCollection.errorsByPattern.get(entry.relativePath); + if (candidateError !== undefined) { + skipped.push(toSkippedEntry(entry, candidateError)); + if (matchedPaths.length === 0) { + continue; + } + } + if (matchedPaths.length === 0) { + skipped.push(toSkippedEntry(entry, noMatchError(entry))); + continue; + } + + for (const relativePath of matchedPaths) { + try { + const resolved = await resolveSourceMaterialization({ + entry, + excludedSourceRoots, + relativePath, + sourceRoot, + }); + materializations.push(resolved.materialization); + } catch (error) { + if (!isWorktreeIncludeMaterializationError(error)) { + throw error; + } + skipped.push(toSkippedEntry(entry, error)); + } + } + } + + const normalized = normalizeMaterializations(materializations); + + return { + excludedSourceRoots, + materializations: normalized.materializations, + skipped: [...skipped, ...normalized.skipped], + sourceRoot, + }; +} + +async function canonicalizeExistingPathPrefix(path: string): Promise { + const absolutePath = resolve(path); + const missingSegments: string[] = []; + let existingPath = absolutePath; + + while (true) { + try { + return join(await realpath(existingPath), ...missingSegments); + } catch (error) { + if (getErrorCode(error) !== "ENOENT" && getErrorCode(error) !== "ENOTDIR") { + throw error; + } + + const parentPath = dirname(existingPath); + if (parentPath === existingPath) { + return absolutePath; + } + missingSegments.unshift(pathBasename(existingPath)); + existingPath = parentPath; + } + } +} + +export async function materializeWorktreeIncludePlan( + options: MaterializeWorktreeIncludePlanOptions, +): Promise { + const skipped: WorktreeIncludeSkippedEntry[] = []; + let materialized = 0; + if (options.plan.materializations.length === 0) { + return { materialized, skipped }; + } + + const worktreeRoot = await realpath(options.worktreeRoot); + for (const materialization of options.plan.materializations) { + const entry = { + lineNumber: materialization.lineNumber, + mode: materialization.mode, + raw: materialization.raw, + relativePath: materialization.relativePath, + }; + let resolved: ResolvedWorktreeIncludeMaterialization; + try { + resolved = await resolveSourceMaterialization({ + entry, + excludedSourceRoots: options.plan.excludedSourceRoots, + relativePath: materialization.relativePath, + sourceRoot: options.plan.sourceRoot, + }); + } catch (error) { + if (isSkippableWorktreeIncludeError(error) || getErrorCode(error) !== null) { + skipped.push(toSkippedEntry(entry, error)); + continue; + } + throw error; + } + if (resolved.materialization.sourceKind !== materialization.sourceKind) { + skipped.push( + toSkippedEntry( + entry, + new WorktreeIncludeError( + "source_changed", + `Source for .worktreeinclude entry '${materialization.raw}' changed type before it could be materialized`, + ), + ), + ); + continue; + } + + let staged: StagedWorktreeIncludeMaterialization | null = null; + let createdDestinationParents: string[] = []; + try { + const destinationPath = getDestinationPath({ + worktreeRoot, + relativePath: resolved.materialization.relativePath, + }); + if ( + !(await preflightDestination({ + worktreeRoot, + resolved, + })) + ) { + materialized++; + continue; + } + + staged = await stageMaterialization({ + destinationPath, + resolved, + worktreeRoot, + }); + createdDestinationParents = await ensureDestinationParent({ + worktreeRoot, + relativePath: resolved.materialization.relativePath, + }); + if ( + !(await preflightDestination({ + worktreeRoot, + resolved, + })) + ) { + await cleanupStagingDirectory(staged.directoryPath); + staged = null; + await cleanupCreatedDestinationParents(createdDestinationParents); + materialized++; + continue; + } + + const destinationStats = await lstatIfExists(destinationPath); + if (resolved.materialization.mode === "copy" && destinationStats !== null) { + await copyStagedMaterializationToExistingDestination({ + destinationPath, + resolved, + staged, + }); + } else { + await rename(staged.entryPath, destinationPath); + } + + await cleanupStagingDirectory(staged.directoryPath); + staged = null; + materialized++; + } catch (error) { + if (error instanceof WorktreeIncludeCleanupError) { + throw error; + } + if (staged !== null) { + await cleanupStagingDirectory(staged.directoryPath); + } + await cleanupCreatedDestinationParents(createdDestinationParents); + if (isWorktreeIncludeMaterializationError(error)) { + skipped.push(toSkippedEntry(entry, error)); + continue; + } + throw error; + } + } + + return { materialized, skipped }; +} + +async function stageMaterialization(options: { + destinationPath: string; + resolved: ResolvedWorktreeIncludeMaterialization; + worktreeRoot: string; +}): Promise { + const directoryPath = await mkdtemp(join(options.worktreeRoot, ".paseo-worktreeinclude-")); + const entryPath = join(directoryPath, "entry"); + try { + if (options.resolved.materialization.mode === "copy") { + await copyMaterializationPath({ + destinationPath: entryPath, + sourceKind: options.resolved.materialization.sourceKind, + sourcePath: options.resolved.sourcePath, + }); + if (options.resolved.materialization.sourceKind === "directory") { + const stagedStats = await lstat(entryPath); + if (!stagedStats.isDirectory()) { + throw new WorktreeIncludeError( + "source_changed", + `.worktreeinclude entry '${options.resolved.materialization.raw}' changed type while it was staged`, + ); + } + await assertCopyDirectorySafe({ + entry: options.resolved.materialization, + sourcePath: entryPath, + }); + } + } else { + await createMaterializationSymlink({ + destinationPath: entryPath, + linkDestinationPath: options.destinationPath, + resolved: options.resolved, + }); + } + return { directoryPath, entryPath }; + } catch (error) { + await cleanupStagingDirectory(directoryPath); + throw error; + } +} + +async function copyStagedMaterializationToExistingDestination(options: { + destinationPath: string; + resolved: ResolvedWorktreeIncludeMaterialization; + staged: StagedWorktreeIncludeMaterialization; +}): Promise { + const backupPath = join(options.staged.directoryPath, "backup"); + await rename(options.destinationPath, backupPath); + try { + await rename(options.staged.entryPath, options.destinationPath); + } catch (error) { + await restoreCopiedDestination({ + backupPath, + destinationPath: options.destinationPath, + }); + throw error; + } +} + +async function copyMaterializationPath(options: { + destinationPath: string; + sourceKind: WorktreeIncludeSourceKind; + sourcePath: string; +}): Promise { + if (options.sourceKind === "file") { + await copyFile(options.sourcePath, options.destinationPath); + return; + } + await cp(options.sourcePath, options.destinationPath, { + recursive: true, + force: true, + dereference: false, + }); +} + +async function restoreCopiedDestination(options: { + backupPath: string; + destinationPath: string; +}): Promise { + try { + const destinationStats = await lstatIfExists(options.destinationPath); + if (destinationStats !== null) { + if (destinationStats.isSymbolicLink()) { + throw new Error("destination changed while restoring a failed materialization"); + } + await rm(options.destinationPath, { + recursive: destinationStats.isDirectory(), + force: true, + }); + } + await rename(options.backupPath, options.destinationPath); + } catch (error) { + throw new WorktreeIncludeCleanupError( + `Unable to restore .worktreeinclude destination '${options.destinationPath}' after a failed materialization`, + error, + ); + } +} + +async function cleanupStagingDirectory(directoryPath: string): Promise { + try { + await rm(directoryPath, { recursive: true, force: true }); + } catch (error) { + throw new WorktreeIncludeCleanupError( + `Unable to clean up .worktreeinclude staging directory '${directoryPath}'`, + error, + ); + } +} + +async function cleanupCreatedDestinationParents(createdPaths: string[]): Promise { + for (const path of createdPaths.toReversed()) { + try { + await rmdir(path); + } catch (error) { + const code = getErrorCode(error); + if (code === "ENOENT" || code === "ENOTEMPTY" || code === "EEXIST") { + continue; + } + throw new WorktreeIncludeCleanupError( + `Unable to clean up .worktreeinclude destination directory '${path}'`, + error, + ); + } + } +} + +async function readWorktreeIncludeEntries( + sourceRoot: string, +): Promise { + let contents: string; + try { + contents = await readFile(join(sourceRoot, WORKTREE_INCLUDE_FILE_NAME), "utf8"); + } catch (error) { + if (getErrorCode(error) === "ENOENT") { + return { entries: [], skipped: [] }; + } + throw error; + } + + const entries: WorktreeIncludeEntry[] = []; + const skipped: WorktreeIncludeSkippedEntry[] = []; + + for (const [index, sourceLine] of contents.split(/\r?\n/).entries()) { + const lineNumber = index + 1; + const line = sourceLine.trim(); + if (line.length === 0) { + continue; + } + + if (line.startsWith("#")) { + continue; + } + + try { + entries.push(parseWorktreeIncludeEntry({ line, lineNumber })); + } catch (error) { + if (!isSkippableWorktreeIncludeError(error)) { + throw error; + } + skipped.push(toSkippedEntry({ lineNumber, raw: line }, error)); + } + } + + return { entries, skipped }; +} + +function parseWorktreeIncludeEntry(options: { + line: string; + lineNumber: number; +}): WorktreeIncludeEntry { + let mode: WorktreeIncludeMode = "copy"; + let path = options.line; + const separatorIndex = options.line.search(/\s/); + + if (separatorIndex === -1) { + if (options.line === "copy" || options.line === "symlink") { + throw new WorktreeIncludeError( + "invalid_entry", + `.worktreeinclude ${options.line} entry on line ${options.lineNumber} requires a path`, + ); + } + } else { + const verb = options.line.slice(0, separatorIndex); + if (verb === "copy" || verb === "symlink") { + mode = verb; + path = options.line.slice(separatorIndex).trim(); + } + } + + return { + lineNumber: options.lineNumber, + mode, + raw: options.line, + relativePath: normalizeRelativePath({ entry: path, lineNumber: options.lineNumber }), + }; +} + +function normalizeRelativePath(options: { entry: string; lineNumber: number }): string { + const fail = (reason: string): never => { + throw new WorktreeIncludeError( + "invalid_entry", + `Invalid .worktreeinclude entry '${options.entry}' on line ${options.lineNumber}: ${reason}`, + ); + }; + + if ( + options.entry.includes("\0") || + options.entry.startsWith("/") || + options.entry.startsWith("\\") || + isAbsolute(options.entry) || + win32.isAbsolute(options.entry) || + /^[A-Za-z]:/.test(options.entry) + ) { + fail("absolute paths are not allowed"); + } + + const segments = options.entry + .split(/[\\/]+/) + .filter((segment) => segment.length > 0 && segment !== "."); + if (segments.length === 0) { + fail("path must not be empty"); + } + + for (const segment of segments) { + if (segment === "..") { + fail("parent-directory segments are not allowed"); + } + if (segment.toLowerCase() === ".git") { + fail("git metadata cannot be materialized"); + } + if (segment.includes(":") || /[. ]$/.test(segment) || isWindowsReservedSegment(segment)) { + fail("path is not portable to Windows"); + } + } + + return segments.join("/"); +} + +function isWindowsReservedSegment(segment: string): boolean { + const basename = segment.split(".", 1)[0]?.toLowerCase() ?? ""; + return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/.test(basename); +} + +function getRecursiveDirectoryPath(entry: WorktreeIncludeEntry): string | null { + const segments = entry.relativePath.split("/"); + if ( + segments.length < 2 || + segments.at(-1) !== "**" || + segments.slice(0, -1).some((segment) => segment.includes("*")) + ) { + return null; + } + return segments.slice(0, -1).join("/"); +} + +async function collectWorktreeIncludeCandidates(options: { + excludedSourceRoots: string[]; + patterns: string[]; + sourceRoot: string; +}): Promise { + const candidates: string[] = []; + const errorsByPattern = new Map(); + + async function visit( + directoryPath: string, + directorySegments: string[], + patterns: string[], + ancestorCanonicalDirectories: ReadonlySet, + ): Promise { + let entries: Dirent[]; + try { + entries = await readdir(directoryPath, { withFileTypes: true }); + } catch (error) { + if (getErrorCode(error) === null) { + throw error; + } + for (const pattern of patterns) { + errorsByPattern.set(pattern, error); + } + return; + } + for (const entry of entries) { + if (entry.name.toLowerCase() === ".git") { + continue; + } + + const pathSegments = [...directorySegments, entry.name]; + const sourcePath = join(options.sourceRoot, ...pathSegments); + if ( + options.excludedSourceRoots.some((excludedRoot) => + isPathInsideRoot(excludedRoot, sourcePath), + ) + ) { + continue; + } + + const relativePath = pathSegments.join("/"); + if (patterns.some((pattern) => worktreeIncludeGlobMatches(pattern, relativePath))) { + candidates.push(relativePath); + } + const descendantPatterns = patterns.filter((pattern) => + canGlobMatchDescendant(pattern, pathSegments), + ); + if ((entry.isDirectory() || entry.isSymbolicLink()) && descendantPatterns.length > 0) { + try { + const canonicalDirectory = await realpath(sourcePath); + const canonicalStats = await lstat(canonicalDirectory); + const canonicalRelativePath = relative(options.sourceRoot, canonicalDirectory); + const isGitMetadata = canonicalRelativePath + .split(/[\\/]/) + .some((segment) => segment.toLowerCase() === ".git"); + const overlapsProtectedRoot = options.excludedSourceRoots.some( + (excludedRoot) => + isPathInsideRoot(excludedRoot, canonicalDirectory) || + isPathInsideRoot(canonicalDirectory, excludedRoot), + ); + if ( + !canonicalStats.isDirectory() || + !isPathInsideRoot(options.sourceRoot, canonicalDirectory) || + isGitMetadata || + overlapsProtectedRoot || + ancestorCanonicalDirectories.has(canonicalDirectory) + ) { + continue; + } + await visit( + sourcePath, + pathSegments, + descendantPatterns, + new Set([...ancestorCanonicalDirectories, canonicalDirectory]), + ); + } catch (error) { + if (getErrorCode(error) === null) { + throw error; + } + for (const pattern of descendantPatterns) { + errorsByPattern.set(pattern, error); + } + } + } + } + } + + await visit( + options.sourceRoot, + [], + options.patterns, + new Set([await realpath(options.sourceRoot)]), + ); + return { candidates: candidates.sort(), errorsByPattern }; +} + +function canGlobMatchDescendant(pattern: string, directorySegments: string[]): boolean { + const patternSegments = pattern.split("/"); + const cache = new Map(); + + function match(patternIndex: number, directoryIndex: number): boolean { + const cacheKey = `${patternIndex}:${directoryIndex}`; + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const patternSegment = patternSegments[patternIndex]; + let result: boolean; + if (directoryIndex === directorySegments.length) { + result = patternSegment !== undefined; + } else if (patternSegment === "**") { + result = match(patternIndex + 1, directoryIndex) || match(patternIndex, directoryIndex + 1); + } else { + const directorySegment = directorySegments[directoryIndex]; + result = + patternSegment !== undefined && + segmentGlobMatches(patternSegment, directorySegment) && + match(patternIndex + 1, directoryIndex + 1); + } + + cache.set(cacheKey, result); + return result; + } + + return match(0, 0); +} + +function resolveEntryMatches(options: { + candidates: string[]; + entry: WorktreeIncludeEntry; +}): string[] { + if (!options.entry.relativePath.includes("*")) { + return [options.entry.relativePath]; + } + + const recursiveDirectoryPath = getRecursiveDirectoryPath(options.entry); + if (recursiveDirectoryPath !== null) { + return [recursiveDirectoryPath]; + } + + return options.candidates.filter((candidate) => + worktreeIncludeGlobMatches(options.entry.relativePath, candidate), + ); +} + +function worktreeIncludeGlobMatches(pattern: string, candidate: string): boolean { + const patternSegments = pattern.split("/"); + const candidateSegments = candidate.split("/"); + const cache = new Map(); + + function match(patternIndex: number, candidateIndex: number): boolean { + const cacheKey = `${patternIndex}:${candidateIndex}`; + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const patternSegment = patternSegments[patternIndex]; + let result: boolean; + if (patternSegment === undefined) { + result = candidateIndex === candidateSegments.length; + } else if (patternSegment === "**") { + result = + match(patternIndex + 1, candidateIndex) || + (candidateIndex < candidateSegments.length && match(patternIndex, candidateIndex + 1)); + } else { + const candidateSegment = candidateSegments[candidateIndex]; + result = + candidateSegment !== undefined && + segmentGlobMatches(patternSegment, candidateSegment) && + match(patternIndex + 1, candidateIndex + 1); + } + + cache.set(cacheKey, result); + return result; + } + + return match(0, 0); +} + +function segmentGlobMatches(pattern: string, value: string): boolean { + const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, "[^/]*"); + return new RegExp(`^${escaped}$`).test(value); +} + +async function resolveSourceMaterialization(options: { + entry: WorktreeIncludeEntry; + excludedSourceRoots: string[]; + relativePath: string; + sourceRoot: string; +}): Promise { + const requestedSourcePath = join(options.sourceRoot, ...options.relativePath.split("/")); + const sourcePath = await realpathSourcePath(requestedSourcePath, options.entry); + assertSourcePathIsSafe({ + entry: options.entry, + excludedSourceRoots: options.excludedSourceRoots, + sourcePath, + sourceRoot: options.sourceRoot, + }); + + const sourceKind = await getCanonicalSourceKind({ + entry: options.entry, + sourcePath, + }); + if ( + getRecursiveDirectoryPath(options.entry) === options.relativePath && + sourceKind !== "directory" + ) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} requires a directory`, + ); + } + if (sourceKind === "directory") { + if (options.entry.mode === "copy") { + await assertCopyDirectorySafe({ + entry: options.entry, + sourcePath, + }); + } else { + await assertSymlinkDirectorySafe({ + entry: options.entry, + excludedSourceRoots: options.excludedSourceRoots, + sourcePath, + sourceRoot: options.sourceRoot, + }); + } + } + + return { + materialization: { + lineNumber: options.entry.lineNumber, + mode: options.entry.mode, + raw: options.entry.raw, + relativePath: options.relativePath, + sourceKind, + }, + sourcePath, + }; +} + +function assertSourcePathIsSafe(options: { + entry: WorktreeIncludeEntry; + excludedSourceRoots: string[]; + sourcePath: string; + sourceRoot: string; +}): void { + if (!isPathInsideRoot(options.sourceRoot, options.sourcePath)) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves outside the source checkout`, + ); + } + + const canonicalRelativePath = relative(options.sourceRoot, options.sourcePath); + if (canonicalRelativePath.split(/[\\/]/).some((segment) => segment.toLowerCase() === ".git")) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves into Git metadata`, + ); + } + + const excludedRoot = options.excludedSourceRoots.find( + (candidate) => + isPathInsideRoot(options.sourcePath, candidate) || + isPathInsideRoot(candidate, options.sourcePath), + ); + if (excludedRoot === undefined) { + return; + } + + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} overlaps with a protected worktree path`, + ); +} + +async function getCanonicalSourceKind(options: { + entry: WorktreeIncludeEntry; + sourcePath: string; +}): Promise { + const stats = await lstatSourcePath(options.sourcePath, options.entry); + if (stats.isSymbolicLink()) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} changed while its source path was resolved`, + ); + } + if (!stats.isFile() && !stats.isDirectory()) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} must match a regular file or directory`, + ); + } + + return stats.isDirectory() ? "directory" : "file"; +} + +async function realpathSourcePath( + sourcePath: string, + entry: WorktreeIncludeEntry, +): Promise { + try { + return await realpath(sourcePath); + } catch (error) { + const code = getErrorCode(error); + if (code === "ENOENT" || code === "ENOTDIR") { + throw noMatchError(entry); + } + if (code === "ELOOP") { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${entry.raw}' on line ${entry.lineNumber} contains a symbolic-link loop`, + ); + } + throw error; + } +} + +async function lstatSourcePath(sourcePath: string, entry: WorktreeIncludeEntry): Promise { + try { + return await lstat(sourcePath); + } catch (error) { + if (getErrorCode(error) === "ENOENT" || getErrorCode(error) === "ENOTDIR") { + throw noMatchError(entry); + } + throw error; + } +} + +async function assertCopyDirectorySafe(options: { + entry: WorktreeIncludeEntry; + sourcePath: string; +}): Promise { + for (const name of await readdir(options.sourcePath)) { + if (name.toLowerCase() === ".git") { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains git metadata`, + ); + } + + const sourcePath = join(options.sourcePath, name); + const stats = await lstatSourcePath(sourcePath, options.entry); + if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains an unsupported file type or symbolic link`, + ); + } + if (stats.isDirectory()) { + await assertCopyDirectorySafe({ sourcePath, entry: options.entry }); + } + } +} + +async function assertSymlinkDirectorySafe(options: { + entry: WorktreeIncludeEntry; + excludedSourceRoots: string[]; + sourcePath: string; + sourceRoot: string; +}): Promise { + const visitedDirectories = new Set(); + + async function visit(directoryPath: string): Promise { + if (visitedDirectories.has(directoryPath)) { + return; + } + visitedDirectories.add(directoryPath); + + for (const name of await readdir(directoryPath)) { + if (name.toLowerCase() === ".git") { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains git metadata`, + ); + } + + const childPath = join(directoryPath, name); + const childStats = await lstatSourcePath(childPath, options.entry); + if (childStats.isSymbolicLink()) { + const linkedPath = await realpathSourcePath(childPath, options.entry); + assertSourcePathIsSafe({ + entry: options.entry, + excludedSourceRoots: options.excludedSourceRoots, + sourcePath: linkedPath, + sourceRoot: options.sourceRoot, + }); + const linkedKind = await getCanonicalSourceKind({ + entry: options.entry, + sourcePath: linkedPath, + }); + if (linkedKind === "directory") { + await visit(linkedPath); + } + continue; + } + + if (childStats.isDirectory()) { + await visit(childPath); + continue; + } + if (!childStats.isFile()) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains an unsupported file type`, + ); + } + } + } + + await visit(options.sourcePath); +} + +function normalizeMaterializations( + materializations: WorktreeIncludeMaterialization[], +): NormalizedWorktreeIncludeMaterializations { + const byPath = new Map(); + for (const materialization of materializations) { + const matches = byPath.get(materialization.relativePath) ?? []; + matches.push(materialization); + byPath.set(materialization.relativePath, matches); + } + + const skipped: WorktreeIncludeSkippedEntry[] = []; + const candidates: WorktreeIncludeMaterialization[] = []; + for (const [relativePath, matches] of byPath) { + if (new Set(matches.map((materialization) => materialization.mode)).size === 1) { + candidates.push(matches[0]!); + continue; + } + + const message = `.worktreeinclude entries for '${relativePath}' use both copy and symlink modes`; + skipped.push( + ...matches.map((materialization) => + toSkippedEntry(materialization, new WorktreeIncludeError("conflict", message)), + ), + ); + } + + const conflicted = new Set(); + const skippedConflictEntries = new Set(); + const skipConflict = (materialization: WorktreeIncludeMaterialization, message: string): void => { + conflicted.add(materialization); + if (skippedConflictEntries.has(materialization)) { + return; + } + skippedConflictEntries.add(materialization); + skipped.push(toSkippedEntry(materialization, new WorktreeIncludeError("conflict", message))); + }; + + for (let leftIndex = 0; leftIndex < candidates.length; leftIndex++) { + const left = candidates[leftIndex]!; + for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex++) { + const right = candidates[rightIndex]!; + let ancestor: WorktreeIncludeMaterialization | null = null; + if (isRelativePathAncestor(left.relativePath, right.relativePath)) { + ancestor = left; + } else if (isRelativePathAncestor(right.relativePath, left.relativePath)) { + ancestor = right; + } + if (ancestor === null || (left.mode === "copy" && right.mode === "copy")) { + continue; + } + + const descendant = ancestor === left ? right : left; + const message = `.worktreeinclude entries for '${ancestor.relativePath}' and '${descendant.relativePath}' overlap with a symlink`; + skipConflict(left, message); + skipConflict(right, message); + } + } + + const sorted = candidates + .filter((materialization) => !conflicted.has(materialization)) + .sort((left, right) => { + const depthDifference = + left.relativePath.split("/").length - right.relativePath.split("/").length; + return depthDifference === 0 + ? left.relativePath.localeCompare(right.relativePath) + : depthDifference; + }); + return { materializations: sorted, skipped }; +} + +function isRelativePathAncestor(ancestor: string, candidate: string): boolean { + return ancestor !== candidate && candidate.startsWith(`${ancestor}/`); +} + +async function preflightDestination(options: { + resolved: ResolvedWorktreeIncludeMaterialization; + worktreeRoot: string; +}): Promise { + const { materialization } = options.resolved; + const destinationPath = getDestinationPath({ + worktreeRoot: options.worktreeRoot, + relativePath: materialization.relativePath, + }); + const destinationStats = await lstatIfExists(destinationPath); + if (destinationStats === null) { + return true; + } + + if (destinationStats.isSymbolicLink()) { + if ( + materialization.mode === "symlink" && + (await isExpectedSymlink({ + destinationPath, + sourcePath: options.resolved.sourcePath, + })) + ) { + return false; + } + throw destinationConflict(materialization); + } + + if (materialization.mode === "symlink") { + throw destinationConflict(materialization); + } + + if ( + (materialization.sourceKind === "file" && !destinationStats.isFile()) || + (materialization.sourceKind === "directory" && !destinationStats.isDirectory()) + ) { + throw destinationConflict(materialization); + } + + if (materialization.sourceKind === "directory") { + await assertCopyDestinationTreeSafe({ + sourcePath: options.resolved.sourcePath, + destinationPath, + materialization, + }); + } + return true; +} + +function getDestinationPath(options: { relativePath: string; worktreeRoot: string }): string { + const destinationPath = join(options.worktreeRoot, ...options.relativePath.split("/")); + if (!isPathInsideRoot(options.worktreeRoot, destinationPath)) { + throw new WorktreeIncludeError( + "invalid_entry", + `.worktreeinclude entry '${options.relativePath}' resolves outside the worktree`, + ); + } + return destinationPath; +} + +async function ensureDestinationParent(options: { + relativePath: string; + worktreeRoot: string; +}): Promise { + const parentSegments = options.relativePath.split("/").slice(0, -1); + let currentPath = options.worktreeRoot; + const createdPaths: string[] = []; + try { + for (const segment of parentSegments) { + currentPath = join(currentPath, segment); + const stats = await lstatIfExists(currentPath); + if (stats === null) { + await mkdir(currentPath); + createdPaths.push(currentPath); + continue; + } + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new WorktreeIncludeError( + "conflict", + `Refusing to materialize .worktreeinclude entry '${options.relativePath}' through '${currentPath}'`, + ); + } + } + } catch (error) { + await cleanupCreatedDestinationParents(createdPaths); + throw error; + } + return createdPaths; +} + +async function assertCopyDestinationTreeSafe(options: { + destinationPath: string; + materialization: WorktreeIncludeMaterialization; + sourcePath: string; +}): Promise { + for (const name of await readdir(options.sourcePath)) { + const sourceChildPath = join(options.sourcePath, name); + const sourceStats = await lstat(sourceChildPath); + if (sourceStats.isSymbolicLink()) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.materialization.relativePath}' contains a symbolic link`, + ); + } + + const destinationChildPath = join(options.destinationPath, name); + const destinationStats = await lstatIfExists(destinationChildPath); + if (destinationStats === null) { + continue; + } + if (destinationStats.isSymbolicLink()) { + throw destinationConflict(options.materialization); + } + if ( + (sourceStats.isFile() && !destinationStats.isFile()) || + (sourceStats.isDirectory() && !destinationStats.isDirectory()) + ) { + throw destinationConflict(options.materialization); + } + if (sourceStats.isDirectory()) { + await assertCopyDestinationTreeSafe({ + sourcePath: sourceChildPath, + destinationPath: destinationChildPath, + materialization: options.materialization, + }); + } + } +} + +async function createMaterializationSymlink(options: { + destinationPath: string; + linkDestinationPath?: string; + resolved: ResolvedWorktreeIncludeMaterialization; +}): Promise { + if (process.platform !== "win32") { + const target = relative( + dirname(options.linkDestinationPath ?? options.destinationPath), + options.resolved.sourcePath, + ); + await symlink(target, options.destinationPath); + return; + } + + let type: "dir" | "file" | "junction" = "file"; + if (options.resolved.materialization.sourceKind === "directory") { + type = isWindowsNetworkPath(options.resolved.sourcePath) ? "dir" : "junction"; + } + try { + await symlink(options.resolved.sourcePath, options.destinationPath, type); + } catch (error) { + throw toWindowsSymlinkError({ error, entry: options.resolved.materialization }); + } +} + +function isWindowsSymlinkPrivilegeError(error: unknown): boolean { + const code = getErrorCode(error); + return code === "EACCES" || code === "EPERM" || code === "ENOTSUP"; +} + +function isWindowsNetworkPath(path: string): boolean { + return path.startsWith("\\\\"); +} + +function toWindowsSymlinkError(options: { + entry: WorktreeIncludeMaterialization; + error: unknown; +}): Error { + if (!isWindowsSymlinkPrivilegeError(options.error)) { + return options.error instanceof Error ? options.error : new Error(String(options.error)); + } + return new WorktreeIncludeError( + "windows_symlink_unavailable", + `Unable to create a Windows symlink for .worktreeinclude entry '${options.entry.relativePath}'. Enable Developer Mode or use copy ${options.entry.relativePath}.`, + ); +} + +async function isExpectedSymlink(options: { + destinationPath: string; + sourcePath: string; +}): Promise { + try { + const [destinationTarget, sourceTarget] = await Promise.all([ + realpath(options.destinationPath), + realpath(options.sourcePath), + ]); + return areEquivalentPaths(destinationTarget, sourceTarget); + } catch { + return false; + } +} + +async function lstatIfExists(path: string): Promise { + try { + return await lstat(path); + } catch (error) { + if (getErrorCode(error) === "ENOENT") { + return null; + } + throw error; + } +} + +function destinationConflict( + materialization: WorktreeIncludeMaterialization, +): WorktreeIncludeError { + return new WorktreeIncludeError( + "conflict", + `.worktreeinclude entry '${materialization.relativePath}' on line ${materialization.lineNumber} conflicts with the new worktree`, + ); +} + +function noMatchError(entry: WorktreeIncludeEntry): WorktreeIncludeError { + return new WorktreeIncludeError( + "missing_source", + `No paths matched .worktreeinclude entry '${entry.raw}' on line ${entry.lineNumber}`, + ); +} + +function toSkippedEntry( + entry: Pick, + error: unknown, +): WorktreeIncludeSkippedEntry { + return { + lineNumber: entry.lineNumber, + message: error instanceof Error ? error.message : String(error), + raw: entry.raw, + reason: getSkipReason(error), + }; +} + +function getSkipReason(error: unknown): WorktreeIncludeSkipReason { + if (!(error instanceof WorktreeIncludeError)) { + return "materialization"; + } + switch (error.code) { + case "conflict": + return "conflict"; + case "invalid_entry": + return "invalid"; + case "windows_symlink_unavailable": + return "materialization"; + case "missing_source": + return "missing"; + case "source_changed": + return "source_changed"; + case "unsupported_source": + return "unsafe"; + } +} + +function isSkippableWorktreeIncludeError(error: unknown): error is WorktreeIncludeError { + return error instanceof WorktreeIncludeError && error.code !== "windows_symlink_unavailable"; +} + +function isWorktreeIncludeMaterializationError(error: unknown): boolean { + return error instanceof WorktreeIncludeError || getErrorCode(error) !== null; +} + +function getErrorCode(error: unknown): string | null { + if (typeof error !== "object" || error === null || !("code" in error)) { + return null; + } + const { code } = error; + return typeof code === "string" ? code : null; +} diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index d895df5395d..53a555cf4d6 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -34,6 +34,7 @@ import { writeFileSync, readFileSync, chmodSync, + lstatSync, } from "fs"; import { delimiter, dirname, join } from "path"; import { tmpdir } from "os"; @@ -362,6 +363,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(metadata).toMatchObject({ baseRefName: "main" }); }); + it("removes fetched branches when include planning fails", async () => { + const remoteDir = join(tempDir, "remote.git"); + const remoteCloneDir = join(tempDir, "remote-clone"); + execFileSync("git", ["clone", "--bare", repoDir, remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + + execFileSync("git", ["clone", remoteDir, remoteCloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir }); + execFileSync("git", ["checkout", "-b", "contributor/cleanup"], { cwd: remoteCloneDir }); + writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n"); + execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pr branch"], { + cwd: remoteCloneDir, + }); + const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir }) + .toString() + .trim(); + execFileSync("git", ["push", "origin", "contributor/cleanup"], { cwd: remoteCloneDir }); + execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/44/head", prHead]); + mkdirSync(join(repoDir, ".worktreeinclude")); + + await expect( + createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "pr-44-cleanup", + source: { + kind: "checkout-github-pr", + githubPrNumber: 44, + headRef: "contributor/cleanup", + baseRefName: "main", + }, + runSetup: false, + paseoHome, + }), + ).rejects.toMatchObject({ code: "EISDIR" }); + + expect(() => + execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], { + cwd: repoDir, + stdio: "pipe", + }), + ).toThrow(); + + await expect( + createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "branch-cleanup", + source: { kind: "checkout-branch", branchName: "contributor/cleanup" }, + runSetup: false, + paseoHome, + }), + ).rejects.toMatchObject({ code: "EISDIR" }); + + expect(() => + execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], { + cwd: repoDir, + stdio: "pipe", + }), + ).toThrow(); + }); + it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => { const remoteDir = join(tempDir, "remote.git"); const remoteCloneDir = join(tempDir, "remote-clone"); @@ -1062,6 +1125,241 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); + it("materializes copies and symlinks before setup, then removes only the new worktree links", async () => { + writeFileSync( + join(repoDir, ".gitignore"), + [".copy.env", "copy-cache/", "linked-file.txt", "linked-state", "setup.log", ""].join("\n"), + ); + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [ + "test -f .copy.env", + "test -L linked-file.txt", + "test -L linked-state", + "cat linked-state/state.txt > setup.log", + ], + }, + }), + ); + execFileSync("git", ["add", ".gitignore", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include fixture"], { + cwd: repoDir, + }); + + writeFileSync( + join(repoDir, ".worktreeinclude"), + [".copy.env", "copy-cache/**", "symlink linked-file.txt", "symlink linked-state", ""].join( + "\n", + ), + ); + writeFileSync(join(repoDir, ".copy.env"), "copy-v1\n"); + mkdirSync(join(repoDir, "copy-cache"), { recursive: true }); + writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v1\n"); + writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v1\n"); + mkdirSync(join(repoDir, "linked-state"), { recursive: true }); + writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v1\n"); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "include-links", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/include-links" }, + runSetup: true, + paseoHome, + }); + + expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe( + "linked-state-v1\n", + ); + expect(lstatSync(join(result.worktreePath, ".copy.env")).isSymbolicLink()).toBe(false); + expect(lstatSync(join(result.worktreePath, "copy-cache")).isSymbolicLink()).toBe(false); + expect(lstatSync(join(result.worktreePath, "linked-file.txt")).isSymbolicLink()).toBe(true); + expect(lstatSync(join(result.worktreePath, "linked-state")).isSymbolicLink()).toBe(true); + expect( + execFileSync("git", ["status", "--porcelain"], { + cwd: result.worktreePath, + encoding: "utf8", + }), + ).toBe(""); + + writeFileSync(join(repoDir, ".copy.env"), "copy-v2\n"); + writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v2\n"); + writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v2\n"); + writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v2\n"); + + expect(readFileSync(join(result.worktreePath, ".copy.env"), "utf8")).toBe("copy-v1\n"); + expect(readFileSync(join(result.worktreePath, "copy-cache", "state.txt"), "utf8")).toBe( + "copy-cache-v1\n", + ); + expect(readFileSync(join(result.worktreePath, "linked-file.txt"), "utf8")).toBe( + "linked-file-v2\n", + ); + expect(readFileSync(join(result.worktreePath, "linked-state", "state.txt"), "utf8")).toBe( + "linked-state-v2\n", + ); + + await deletePaseoWorktree({ + cwd: repoDir, + worktreePath: result.worktreePath, + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(false); + expect(readFileSync(join(repoDir, "linked-file.txt"), "utf8")).toBe("linked-file-v2\n"); + expect(readFileSync(join(repoDir, "linked-state", "state.txt"), "utf8")).toBe( + "linked-state-v2\n", + ); + }); + + it("skips missing includes and materializes paths that exist", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); + const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "missing-include"); + writeFileSync( + join(repoDir, ".worktreeinclude"), + [".env", ".env.local", ".sops.yaml", ""].join("\n"), + ); + writeFileSync(join(repoDir, ".env"), "present\n"); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "missing-include", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" }, + runSetup: false, + paseoHome, + }); + + expect(result.worktreePath).toBe(expectedWorktreePath); + expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n"); + expect(existsSync(join(result.worktreePath, ".env.local"))).toBe(false); + expect(existsSync(join(result.worktreePath, ".sops.yaml"))).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoDir, + encoding: "utf8", + }), + ).toContain(expectedWorktreePath); + }); + + it("skips checkout-local worktree storage and creates the remaining includes", async () => { + const checkoutLocalPaseoHome = join(repoDir, ".dev", "paseo-home"); + const projectHash = await deriveWorktreeProjectHash(repoDir); + const expectedWorktreePath = join( + checkoutLocalPaseoHome, + "worktrees", + projectHash, + "protected-include", + ); + mkdirSync(join(checkoutLocalPaseoHome, "worktrees", projectHash), { recursive: true }); + writeFileSync(join(repoDir, ".worktreeinclude"), [".dev/**", ".env", ""].join("\n")); + writeFileSync(join(repoDir, ".env"), "present\n"); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "protected-include", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "feature/protected-include", + }, + runSetup: false, + paseoHome: checkoutLocalPaseoHome, + }); + + expect(result.worktreePath).toBe(expectedWorktreePath); + expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n"); + expect(result.worktreeIncludeSummary?.skipped).toEqual([ + expect.objectContaining({ raw: ".dev/**", reason: "unsafe" }), + ]); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoDir, + encoding: "utf8", + }), + ).toContain(expectedWorktreePath); + expect( + execFileSync("git", ["branch", "--list", "feature/protected-include"], { + cwd: repoDir, + encoding: "utf8", + }).trim(), + ).toContain("feature/protected-include"); + }); + + it("keeps includes when branching from a Paseo-managed worktree", async () => { + writeFileSync(join(repoDir, ".gitignore"), ".env\n"); + writeFileSync(join(repoDir, ".worktreeinclude"), ".env\n"); + execFileSync("git", ["add", ".gitignore", ".worktreeinclude"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include config"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, ".env"), "source\n"); + + const sourceWorktree = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "include-source", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "feature/include-source", + }, + runSetup: false, + paseoHome, + }); + const nestedWorktree = await createLegacyWorktreeForTest({ + cwd: sourceWorktree.worktreePath, + worktreeSlug: "include-nested", + source: { + kind: "branch-off", + baseBranch: "feature/include-source", + branchName: "feature/include-nested", + }, + runSetup: false, + paseoHome, + }); + + expect(readFileSync(join(sourceWorktree.worktreePath, ".env"), "utf8")).toBe("source\n"); + expect(readFileSync(join(nestedWorktree.worktreePath, ".env"), "utf8")).toBe("source\n"); + expect(nestedWorktree.worktreeIncludeSummary?.skipped).toEqual([]); + }); + + it("skips a symlink include conflict and keeps the new worktree", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); + const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict"); + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} })); + writeFileSync(join(repoDir, ".worktreeinclude"), "symlink paseo.json\n"); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "include-conflict", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "feature/include-conflict", + }, + runSetup: false, + paseoHome, + }); + + expect(result.worktreePath).toBe(expectedWorktreePath); + expect(existsSync(expectedWorktreePath)).toBe(true); + expect(lstatSync(join(expectedWorktreePath, "paseo.json")).isSymbolicLink()).toBe(false); + expect(result.worktreeIncludeSummary?.skipped).toEqual([ + expect.objectContaining({ raw: "symlink paseo.json", reason: "conflict" }), + ]); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoDir, + encoding: "utf8", + }), + ).toContain(expectedWorktreePath); + expect( + execFileSync("git", ["branch", "--list", "feature/include-conflict"], { + cwd: repoDir, + encoding: "utf8", + }).trim(), + ).toContain("feature/include-conflict"); + }); + it("creates a worktree without error when no paseo.json exists in the main repo", async () => { const result = await createLegacyWorktreeForTest({ cwd: repoDir, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 4ab641806b9..f46ae6c54b4 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs"; import { copyFile, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import stripAnsi from "strip-ansi"; import { buildStringCommandShellInvocation, @@ -36,6 +36,11 @@ import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; +import { + materializeWorktreeIncludePlan, + readWorktreeIncludePlan, + type WorktreeIncludeSummary, +} from "./worktree-include.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -49,6 +54,10 @@ export interface WorktreeConfig { worktreePath: string; } +export interface CreatedWorktreeConfig extends WorktreeConfig { + worktreeIncludeSummary: WorktreeIncludeSummary; +} + export interface WorktreeRuntimeEnv { [key: string]: string; PASEO_SOURCE_CHECKOUT_PATH: string; @@ -1161,13 +1170,67 @@ export async function deletePaseoWorktree({ } } +export interface RollbackCreatedPaseoWorktreeOptions extends DeletePaseoWorktreeOptions { + createdBranchName?: string; + expectedOid?: string; +} + +async function removeCreatedWorktreeBranch(options: { + createdBranchName?: string; + expectedOid?: string; + cwd?: string | null; +}): Promise { + if (!options.createdBranchName || !options.cwd) { + return; + } + if (!(await localBranchExists(options.cwd, options.createdBranchName))) { + return; + } + if (options.expectedOid) { + await runGitCommand( + ["update-ref", "-d", `refs/heads/${options.createdBranchName}`, options.expectedOid], + { cwd: options.cwd }, + ); + } else { + await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], { + cwd: options.cwd, + }); + } +} + +async function rollbackCreatedWorktreeBranch( + options: { + createdBranchName?: string; + expectedOid?: string; + cwd: string; + }, + cause: unknown, +): Promise { + let cleanupError: unknown; + try { + await removeCreatedWorktreeBranch(options); + } catch (error) { + cleanupError = error; + } + if (cleanupError) { + const failure = new Error( + `${cause instanceof Error ? cause.message : "Worktree workflow failed"}; rollback also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, + { cause }, + ); + Object.assign(failure, { cleanupError }); + throw failure; + } + throw cause; +} + export async function rollbackCreatedPaseoWorktree( - options: DeletePaseoWorktreeOptions, + options: RollbackCreatedPaseoWorktreeOptions, cause: unknown, ): Promise { let cleanupError: unknown; try { await deletePaseoWorktree(options); + await removeCreatedWorktreeBranch(options); } catch (error) { cleanupError = error; } @@ -1233,50 +1296,100 @@ export const createWorktree = async ({ runSetup, paseoHome, worktreesRoot, -}: CreateWorktreeOptions): Promise => { +}: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); - let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); - mkdirSync(dirname(worktreePath), { recursive: true }); + const { worktreeIncludePlan, worktreePath } = await (async () => { + try { + const paseoWorktreesBaseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot }); + const paseoWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot); + const includePlan = await readWorktreeIncludePlan({ + sourceRoot: cwd, + excludedSourceRoots: [paseoWorktreesBaseRoot], + }); + const requestedWorktreePath = join(paseoWorktreesRoot, worktreeSlug); + mkdirSync(dirname(requestedWorktreePath), { recursive: true }); + + // Also handle worktree path collision + let finalWorktreePath = requestedWorktreePath; + let pathSuffix = 1; + while (existsSync(finalWorktreePath)) { + finalWorktreePath = `${requestedWorktreePath}-${pathSuffix}`; + pathSuffix++; + } - // Also handle worktree path collision - let finalWorktreePath = worktreePath; - let pathSuffix = 1; - while (existsSync(finalWorktreePath)) { - finalWorktreePath = `${worktreePath}-${pathSuffix}`; - pathSuffix++; - } + // Primitive owner for `git worktree add`; callers route through createWorktreeCore. + await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], { + cwd, + timeout: 120_000, + }); - // Primitive owner for `git worktree add`; callers route through createWorktreeCore. - await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], { - cwd, - timeout: 120_000, - }); - worktreePath = normalizePathForOwnership(finalWorktreePath); + return { + worktreeIncludePlan: includePlan, + worktreePath: normalizePathForOwnership(finalWorktreePath), + }; + } catch (error) { + return rollbackCreatedWorktreeBranch( + { + cwd, + createdBranchName: sourcePlan.createdBranchNameBeforeWorktreeAdd, + expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd, + }, + error, + ); + } + })(); - if (sourcePlan.pushRemote) { - await configureWorktreePushRemote({ - cwd, - branchName: sourcePlan.branchName, - remote: sourcePlan.pushRemote, + let worktreeIncludeSummary: WorktreeIncludeSummary = { + materialized: 0, + skipped: [...worktreeIncludePlan.skipped], + }; + try { + if (sourcePlan.pushRemote) { + await configureWorktreePushRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.pushRemote, + }); + } + if (sourcePlan.trackingRemote) { + await configureWorktreeTrackingRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.trackingRemote, + }); + } + + writePaseoWorktreeMetadata(worktreePath, { + baseRefName: sourcePlan.metadataBaseRefName, + ...(sourcePlan.changeRequestLookupTarget + ? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget } + : {}), }); - } - if (sourcePlan.trackingRemote) { - await configureWorktreeTrackingRemote({ - cwd, - branchName: sourcePlan.branchName, - remote: sourcePlan.trackingRemote, + + await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); + const materialization = await materializeWorktreeIncludePlan({ + plan: worktreeIncludePlan, + worktreeRoot: worktreePath, }); + worktreeIncludeSummary = { + materialized: materialization.materialized, + skipped: [...worktreeIncludePlan.skipped, ...materialization.skipped], + }; + } catch (error) { + await rollbackCreatedPaseoWorktree( + { + cwd, + worktreePath, + teardownCwds: [], + paseoHome, + worktreesBaseRoot: worktreesRoot, + createdBranchName: sourcePlan.createdBranchName, + expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd, + }, + error, + ); } - writePaseoWorktreeMetadata(worktreePath, { - baseRefName: sourcePlan.metadataBaseRefName, - ...(sourcePlan.changeRequestLookupTarget - ? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget } - : {}), - }); - - await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); - if (runSetup) { await runWorktreeSetupCommands({ worktreePath, @@ -1287,6 +1400,7 @@ export const createWorktree = async ({ return { branchName: sourcePlan.branchName, + worktreeIncludeSummary, worktreePath, }; }; @@ -1299,6 +1413,9 @@ interface ResolveWorktreeSourcePlanOptions { interface WorktreeSourcePlan { branchName: string; + createdBranchName?: string; + createdBranchNameBeforeWorktreeAdd?: string; + createdBranchOidBeforeWorktreeAdd?: string; metadataBaseRefName: string; changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget; addArguments: string[]; @@ -1314,6 +1431,11 @@ interface WorktreeSourcePlan { }; } +type ChangeRequestWorktreeSource = Extract< + WorktreeSource, + { kind: "checkout-change-request" | "checkout-github-pr" } +>; + async function resolveWorktreeSourcePlan({ cwd, source, @@ -1332,93 +1454,143 @@ async function resolveWorktreeSourcePlan({ return { branchName: newBranchName, + createdBranchName: newBranchName, metadataBaseRefName: normalizedBaseBranch, addArguments: ["-b", newBranchName, "--no-track", base], }; } - case "checkout-branch": { - await validateExistingWorktreeBranchName(cwd, source.branchName); - if (!(await localBranchExists(cwd, source.branchName))) { - try { - await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], { - cwd, - timeout: 120_000, - }); - } catch { - throw new UnknownBranchError({ branchName: source.branchName, cwd }); + case "checkout-branch": + return resolveCheckoutBranchWorktreeSourcePlan({ cwd, branchName: source.branchName }); + case "checkout-change-request": + case "checkout-github-pr": + return resolveChangeRequestWorktreeSourcePlan({ cwd, source }); + } +} + +async function resolveCheckoutBranchWorktreeSourcePlan(options: { + branchName: string; + cwd: string; +}): Promise { + await validateExistingWorktreeBranchName(options.cwd, options.branchName); + const needsFetch = !(await localBranchExists(options.cwd, options.branchName)); + let createdBranchOid: string | undefined; + if (needsFetch) { + try { + createdBranchOid = await fetchNewLocalBranchAtomically({ + cwd: options.cwd, + localBranchName: options.branchName, + remoteName: "origin", + remoteRef: `refs/heads/${options.branchName}`, + }); + } catch { + throw new UnknownBranchError({ branchName: options.branchName, cwd: options.cwd }); + } + } + + try { + if (await isBranchCheckedOut(options.cwd, options.branchName)) { + throw new BranchAlreadyCheckedOutError(options.branchName); + } + } catch (error) { + if (!needsFetch) { + throw error; + } + return rollbackCreatedWorktreeBranch( + { + cwd: options.cwd, + createdBranchName: options.branchName, + expectedOid: createdBranchOid, + }, + error, + ); + } + + return { + branchName: options.branchName, + ...(needsFetch + ? { + createdBranchName: options.branchName, + createdBranchNameBeforeWorktreeAdd: options.branchName, + createdBranchOidBeforeWorktreeAdd: createdBranchOid, } - } - if (await isBranchCheckedOut(cwd, source.branchName)) { - throw new BranchAlreadyCheckedOutError(source.branchName); - } + : {}), + metadataBaseRefName: options.branchName, + addArguments: [options.branchName], + }; +} - return { - branchName: source.branchName, - metadataBaseRefName: source.branchName, - addArguments: [source.branchName], +async function resolveChangeRequestWorktreeSourcePlan(options: { + cwd: string; + source: ChangeRequestWorktreeSource; +}): Promise { + const { cwd, source } = options; + const localBranchCandidate = source.localBranchName ?? source.headRef; + await validateExistingWorktreeBranchName(cwd, localBranchCandidate); + const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate); + const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName); + const changeRequestNumber = + source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber; + + let createdBranchOid: string | undefined; + try { + createdBranchOid = await fetchWorktreeCheckoutRefs({ + cwd, + localBranchName, + checkoutRefs: source.checkoutRefs ?? [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + }); + const shouldTrackOriginHead = source.trackOriginHead === true; + const trackingRemote = shouldTrackOriginHead + ? await tryFetchWorktreeTrackingRemote({ + cwd, + remoteName: "origin", + headRef: source.headRef, + }) + : undefined; + const remotePlan: Pick = {}; + if (source.pushRemoteUrl) { + const remoteName = `paseo-pr-${changeRequestNumber}`; + remotePlan.pushRemote = { + name: remoteName, + url: source.pushRemoteUrl, + headRef: source.headRef, + track: true, }; - } - case "checkout-change-request": - case "checkout-github-pr": { - const localBranchCandidate = source.localBranchName ?? source.headRef; - await validateExistingWorktreeBranchName(cwd, localBranchCandidate); - const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate); - const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName); - const changeRequestNumber = - source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber; - await fetchWorktreeCheckoutRefs({ - cwd, - localBranchName, - checkoutRefs: source.checkoutRefs ?? [ - { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, - ], - }); - const shouldTrackOriginHead = source.trackOriginHead === true; - const trackingRemote = shouldTrackOriginHead - ? await tryFetchWorktreeTrackingRemote({ - cwd, - remoteName: "origin", - headRef: source.headRef, - }) - : undefined; - const remotePlan: Pick = {}; - if (source.pushRemoteUrl) { - const remoteName = `paseo-pr-${changeRequestNumber}`; + } else if (shouldTrackOriginHead && localBranchName !== source.headRef) { + const originUrl = await getWorktreeRemotePushUrl(cwd, "origin"); + if (originUrl) { remotePlan.pushRemote = { - name: remoteName, - url: source.pushRemoteUrl, + name: `paseo-pr-${changeRequestNumber}`, + url: originUrl, headRef: source.headRef, - track: true, + track: false, }; - } else if (shouldTrackOriginHead && localBranchName !== source.headRef) { - const originUrl = await getWorktreeRemotePushUrl(cwd, "origin"); - if (originUrl) { - remotePlan.pushRemote = { - name: `paseo-pr-${changeRequestNumber}`, - url: originUrl, - headRef: source.headRef, - track: false, - }; - } - } - if (trackingRemote) { - remotePlan.trackingRemote = trackingRemote; } - - return { - branchName: localBranchName, - metadataBaseRefName: normalizedBaseRefName, - changeRequestLookupTarget: { - headRef: source.headRef, - ...(source.headRepositoryOwner - ? { headRepositoryOwner: source.headRepositoryOwner } - : {}), - changeRequestNumber, - }, - addArguments: [localBranchName], - ...remotePlan, - }; } + if (trackingRemote) { + remotePlan.trackingRemote = trackingRemote; + } + + return { + branchName: localBranchName, + createdBranchName: localBranchName, + createdBranchNameBeforeWorktreeAdd: localBranchName, + createdBranchOidBeforeWorktreeAdd: createdBranchOid, + metadataBaseRefName: normalizedBaseRefName, + changeRequestLookupTarget: { + headRef: source.headRef, + ...(source.headRepositoryOwner ? { headRepositoryOwner: source.headRepositoryOwner } : {}), + changeRequestNumber, + }, + addArguments: [localBranchName], + ...remotePlan, + }; + } catch (error) { + return rollbackCreatedWorktreeBranch( + { cwd, createdBranchName: localBranchName, expectedOid: createdBranchOid }, + error, + ); } } @@ -1471,27 +1643,22 @@ async function fetchWorktreeCheckoutRefs(options: { cwd: string; localBranchName: string; checkoutRefs: WorktreeCheckoutRef[]; -}): Promise { +}): Promise { let lastResult: | Awaited> | { stderr: string; stdout: string; exitCode: number | null } | null = null; for (const checkoutRef of options.checkoutRefs) { - lastResult = await runGitCommand( - [ - "fetch", - checkoutRef.remoteName ?? "origin", - `+${checkoutRef.remoteRef}:refs/heads/${options.localBranchName}`, - "--force", - ], - { + try { + return await fetchNewLocalBranchAtomically({ cwd: options.cwd, - timeout: 120_000, - acceptExitCodes: [0, 1, 128], - }, - ); - if (lastResult.exitCode === 0) { - return; + localBranchName: options.localBranchName, + remoteName: checkoutRef.remoteName ?? "origin", + remoteRef: checkoutRef.remoteRef, + }); + } catch (error) { + lastResult = + error instanceof Error ? { stderr: error.message, stdout: "", exitCode: 1 } : null; } } const attemptedRefs = options.checkoutRefs @@ -1502,6 +1669,35 @@ async function fetchWorktreeCheckoutRefs(options: { ); } +async function fetchNewLocalBranchAtomically(options: { + cwd: string; + localBranchName: string; + remoteName: string; + remoteRef: string; +}): Promise { + const temporaryRef = `refs/paseo/worktree-fetch/${randomUUID()}`; + try { + await runGitCommand( + ["fetch", options.remoteName, `${options.remoteRef}:${temporaryRef}`, "--force"], + { cwd: options.cwd, timeout: 120_000 }, + ); + const { stdout } = await runGitCommand(["rev-parse", "--verify", temporaryRef], { + cwd: options.cwd, + }); + const oid = stdout.trim(); + const nullOid = "0".repeat(oid.length); + await runGitCommand(["update-ref", `refs/heads/${options.localBranchName}`, oid, nullOid], { + cwd: options.cwd, + }); + return oid; + } finally { + await runGitCommand(["update-ref", "-d", temporaryRef], { + cwd: options.cwd, + acceptExitCodes: [0, 1, 128], + }); + } +} + async function tryFetchWorktreeTrackingRemote(options: { cwd: string; remoteName: string; diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index bdc8a24f230..90b49b4b131 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -110,6 +110,46 @@ Both fields accept a multiline shell script or an array of commands; commands ru Commands run with the worktree as `cwd`. Use `$PASEO_SOURCE_CHECKOUT_PATH` to reach files in the original checkout (untracked config, local caches, etc). +## .worktreeinclude + +Use a root-level .worktreeinclude to materialize local source-checkout files before +worktree.setup runs. Each path is relative to the source checkout. + + # Copy is the default. + .env.local + .cache/** + + # Modes can also be explicit. + symlink node_modules + copy .tool-state/** + +Each line is `[copy|symlink] `; the mode is optional and defaults to `copy`. Blank lines +and whole-line comments are ignored. A single star matches within a path segment and a double +star matches recursively; a directory ending in /\*\* materializes that directory as one +recursive entry. Absolute paths, parent-directory paths, and .git paths are rejected. + +Copy entries are independent snapshots: a copied file or directory replaces an existing path on +a later materialization. Symlink entries point directly at +the live source file or directory, so changes through either path affect the same data. Paseo +does not replace an existing file, directory, or different link with a symlink. + +Entries must resolve to regular files or directories. A top-level source symbolic link is +dereferenced only when its canonical target remains inside the source checkout: `copy` snapshots +that target and `symlink` links directly to it. Directory snapshots reject nested symbolic links +so Paseo never writes through an unexpected path. A symlinked directory intentionally exposes its +live source contents. + +Prefer paths ignored by the target branch. For a symlinked directory, use an ignore rule without +a trailing slash (for example, node_modules, not node_modules/), because Git treats the link +itself as a file. Unignored materialized paths appear in git status. + +On Windows, Paseo uses junctions for local directories. File links and network-directory links +require Windows symbolic-link support. It never silently copies an explicit `symlink ` +entry; enable Developer Mode or switch that entry to `copy ` if link creation fails. + +Archiving removes only the worktree's links, not their source targets. If the source path is +later moved or deleted, a symlink becomes broken; Paseo does not repair it automatically. + ## Scripts and services `scripts` are named commands you can run inside a worktree on demand. Mark one as a _service_ and Paseo supervises it as a long-running process, assigns it a port, and routes HTTP traffic to it through the daemon's reverse proxy. From f91a98434868847b7d31d3a11b7dad06d06f793c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 28 Jul 2026 12:18:21 +0200 Subject: [PATCH 109/420] fix(claude): show a single 1M-context Opus 5 model (#2497) --- packages/cli/tests/15-provider.test.ts | 10 ----- .../agent/providers/claude/agent.test.ts | 5 +-- .../agent/providers/claude/model-manifest.ts | 15 ++------ .../agent/providers/claude/models.test.ts | 37 ++++++++++++++----- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index ec07fe8629e..421f68aa5a5 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -108,16 +108,6 @@ const EXPECTED_CLAUDE_MODELS = [ ] as const; const EXPECTED_CLAUDE_CONTEXT_MODELS = [ - { - id: "claude-opus-5[1m]", - model: "Opus 5 1M", - descriptionFragment: "1M context window", - }, - { - id: "claude-opus-5", - model: "Opus 5", - descriptionFragment: "200K context window", - }, { id: "claude-fable-5[1m]", model: "Fable 5 1M", diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index e773cd73590..e4e88059280 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -416,7 +416,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }); expect(models.map((m) => m.id)).toEqual([ - "claude-opus-5[1m]", "claude-opus-5", "claude-fable-5[1m]", "claude-fable-5", @@ -439,7 +438,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { } const defaultModel = models.find((m) => m.isDefault); - expect(defaultModel?.id).toBe("claude-opus-5[1m]"); + expect(defaultModel?.id).toBe("claude-opus-5"); } finally { await fs.rm(emptyConfigDir, { recursive: true, force: true }); } @@ -461,7 +460,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { force: false, }); - expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5[1m]"); + expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5"); expect(models.map((model) => model.id)).toContain("claude-fable-5[1m]"); } finally { await fs.rm(emptyConfigDir, { recursive: true, force: true }); diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index ec9efdb1f69..d091e07c7c1 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -31,22 +31,13 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off"; export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode"; export const CLAUDE_MODEL_MANIFEST = [ - { - id: "claude-opus-5[1m]", - label: "Opus 5 1M", - description: "Opus 5 with 1M context window", - defaultPriority: 2, - minimumClaudeCodeVersion: "2.1.219", - contextWindowMaxTokens: 1_000_000, - effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, - supportsThinkingDisabled: true, - }, { id: "claude-opus-5", label: "Opus 5", - description: "Opus 5 · 200K context window", + description: "Opus 5 · Latest release", + defaultPriority: 2, minimumClaudeCodeVersion: "2.1.219", - contextWindowMaxTokens: 200_000, + contextWindowMaxTokens: 1_000_000, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, supportsThinkingDisabled: true, }, diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index c3e62b1dc33..24a49baa798 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -50,7 +50,6 @@ describe("getClaudeModels", () => { it("returns all claude models", () => { const models = getClaudeModels(); expect(models.map((m) => m.id)).toEqual([ - "claude-opus-5[1m]", "claude-opus-5", "claude-fable-5[1m]", "claude-fable-5", @@ -72,7 +71,7 @@ describe("getClaudeModels", () => { const models = getClaudeModels(); const defaults = models.filter((m) => m.isDefault); expect(defaults).toHaveLength(1); - expect(defaults[0].id).toBe("claude-opus-5[1m]"); + expect(defaults[0].id).toBe("claude-opus-5"); }); it("defines context window sizes in the catalog", () => { @@ -82,8 +81,7 @@ describe("getClaudeModels", () => { expect(contextWindows).toEqual( new Map([ - ["claude-opus-5[1m]", 1_000_000], - ["claude-opus-5", 200_000], + ["claude-opus-5", 1_000_000], ["claude-fable-5[1m]", 1_000_000], ["claude-fable-5", 200_000], ["claude-opus-4-8[1m]", 1_000_000], @@ -103,10 +101,8 @@ describe("getClaudeModels", () => { it("filters models by their minimum Claude Code version", () => { const oldVersionModels = getClaudeModels("2.1.218"); - expect(oldVersionModels.map((model) => model.id)).not.toContain("claude-opus-5[1m]"); expect(oldVersionModels.map((model) => model.id)).not.toContain("claude-opus-5"); expect(oldVersionModels.find((model) => model.isDefault)?.id).toBe("claude-opus-4-8"); - expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5[1m]"); expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5"); expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5[1m]"); @@ -347,7 +343,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => { describe("normalizeClaudeRuntimeModelId", () => { it("returns exact match for known model IDs", () => { - expect(normalizeClaudeRuntimeModelId("claude-opus-5[1m]")).toBe("claude-opus-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-opus-5")).toBe("claude-opus-5"); expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5"); expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]"); @@ -366,7 +361,6 @@ describe("normalizeClaudeRuntimeModelId", () => { expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6"); expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5"); - expect(normalizeClaudeRuntimeModelId("claude-opus-5-20260724[1m]")).toBe("claude-opus-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101[1m]")).toBe( "claude-sonnet-5[1m]", @@ -374,7 +368,6 @@ describe("normalizeClaudeRuntimeModelId", () => { }); it("preserves [1m] suffix from runtime model strings", () => { - expect(normalizeClaudeRuntimeModelId("claude-opus-5[1m]")).toBe("claude-opus-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]"); @@ -421,6 +414,32 @@ describe("findClaudeModel", () => { }); }); +describe("Claude Opus 5 catalog", () => { + it("offers a single Opus 5 entry with a 1M context window", () => { + const opus5Models = getClaudeModels() + .filter((model) => model.id.startsWith("claude-opus-5")) + .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); + + expect(opus5Models).toEqual([ + { id: "claude-opus-5", label: "Opus 5", contextWindowMaxTokens: 1_000_000 }, + ]); + }); + + it("resolves retired and dated Opus 5 IDs to the single catalog entry", () => { + expect(findClaudeModel("claude-opus-5[1m]")?.id).toBe("claude-opus-5"); + expect(findClaudeModel("claude-opus-5-20260724")?.id).toBe("claude-opus-5"); + expect(findClaudeModel("claude-opus-5-20260724[1m]")?.id).toBe("claude-opus-5"); + expect(findClaudeModel("claude-opus-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000); + }); + + it("keeps disabled thinking available for agents persisted on the retired 1M ID", () => { + expect(resolveClaudeDisabledThinkingForModel("claude-opus-5[1m]")).toEqual({ + supported: true, + fallbackThinkingOptionId: "low", + }); + }); +}); + describe("claudeManifestModelSupportsFastMode", () => { it("keeps fast mode strict to first-party manifest model IDs", () => { expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull(); From f0d7eeb98cb0ed3f940502b42fde6ba2e5eca242 Mon Sep 17 00:00:00 2001 From: "Jason@HND" Date: Tue, 28 Jul 2026 19:49:21 +0900 Subject: [PATCH 110/420] fix(quota): restore Grok Settings usage for current CLI auth/billing (#2353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(quota): restore Grok Settings usage for current CLI auth/billing Grok CLI no longer stores a top-level access_token or usage.creditUsage. Read nested auth key tokens and config.used.val so Settings → Usage shows monthly credits again. Keep legacy shapes and env tokens working. Fixes #2352 * fix(quota): make Grok auth-file tests work on Windows Inject homeDir into GrokQuotaProvider like Kimi so nested ~/.grok/auth.json tests do not depend on os.homedir() which ignores $HOME on Windows (USERPROFILE). --- .../services/quota-fetcher/providers/grok.ts | 47 +++++-- .../services/quota-fetcher/service.test.ts | 119 +++++++++++++++++- 2 files changed, 155 insertions(+), 11 deletions(-) diff --git a/packages/server/src/services/quota-fetcher/providers/grok.ts b/packages/server/src/services/quota-fetcher/providers/grok.ts index 8d660c34c1f..33de944b79c 100644 --- a/packages/server/src/services/quota-fetcher/providers/grok.ts +++ b/packages/server/src/services/quota-fetcher/providers/grok.ts @@ -21,6 +21,11 @@ const GrokUsageResponseSchema = z.object({ val: ApiNumberSchema.optional(), }) .nullish(), + used: z + .object({ + val: ApiNumberSchema.optional(), + }) + .nullish(), }) .nullish(), usage: z @@ -30,13 +35,36 @@ const GrokUsageResponseSchema = z.object({ .nullish(), }); -const GrokAuthSchema = z.object({ - access_token: z.string().optional(), -}); - interface GrokQuotaProviderOptions { logger: Logger; fetch?: ProviderApiFetch; + /** Override home directory (tests). Production uses os.homedir(). */ + homeDir?: string; +} + +/** Resolve a Grok CLI token from ~/.grok/auth.json (legacy or current nested shape). */ +export function extractGrokTokenFromAuth(auth: unknown): string | null { + if (auth == null || typeof auth !== "object" || Array.isArray(auth)) return null; + const record = auth as Record; + + const topLevel = record["access_token"]; + if (typeof topLevel === "string" && topLevel.length > 0) { + return topLevel; + } + + const entries = Object.entries(record); + const preferred = entries.filter(([key]) => key.startsWith("https://auth.x.ai::")); + const candidates = preferred.length > 0 ? preferred : entries; + + for (const [, value] of candidates) { + if (value == null || typeof value !== "object" || Array.isArray(value)) continue; + const nestedKey = (value as Record)["key"]; + if (typeof nestedKey === "string" && nestedKey.length > 0) { + return nestedKey; + } + } + + return null; } export class GrokQuotaProvider implements ProviderUsageFetcher { @@ -45,10 +73,12 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { private readonly logger: Logger; private readonly fetchApi: ProviderApiFetch; + private readonly homeDir: string | undefined; constructor(options: GrokQuotaProviderOptions) { this.logger = options.logger; this.fetchApi = options.fetch ?? fetch; + this.homeDir = options.homeDir; } async fetchUsage(): Promise { @@ -76,7 +106,8 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { const resp = GrokUsageResponseSchema.parse(await res.json()); const monthlyLimit = resp.config?.monthlyLimit?.val ?? null; - const creditUsage = resp.usage?.creditUsage ?? null; + // Live CLI billing uses config.used.val; older mocks used usage.creditUsage. + const creditUsage = resp.config?.used?.val ?? resp.usage?.creditUsage ?? null; const balances: ProviderUsageBalance[] = []; if (monthlyLimit !== null || creditUsage !== null) { const remaining = @@ -107,11 +138,11 @@ export class GrokQuotaProvider implements ProviderUsageFetcher { } private async readGrokToken(): Promise { - const path = join(homedir(), ".grok", "auth.json"); + // homeDir override is for tests: Windows os.homedir() ignores $HOME (uses USERPROFILE). + const path = join(this.homeDir ?? homedir(), ".grok", "auth.json"); if (!existsSync(path)) return null; try { - const auth = GrokAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); - return auth.access_token ?? null; + return extractGrokTokenFromAuth(JSON.parse(await fs.readFile(path, "utf8"))); } catch { return null; } diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 7b1044cef10..5550be407dc 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -51,6 +51,11 @@ function writeKimiCredentials(dir: string, accessToken: string): void { ); } +function writeGrokAuth(home: string, auth: Record): void { + mkdirSync(join(home, ".grok"), { recursive: true }); + writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); +} + function writeMiniMaxConfig(dir: string, payload: Record): void { mkdirSync(join(dir, ".mmx"), { recursive: true }); writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload)); @@ -382,7 +387,13 @@ describe("real provider usage fetchers", () => { new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }), new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }), new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), - new GrokQuotaProvider({ logger, fetch: fetchThroughTestDouble }), + new GrokQuotaProvider({ + logger, + fetch: fetchThroughTestDouble, + // Match Kimi: inject temp HOME so nested auth-file tests work on Windows + // (os.homedir() uses USERPROFILE there and ignores process.env.HOME). + homeDir, + }), new KimiQuotaProvider({ logger, fetch: fetchThroughTestDouble, @@ -720,8 +731,7 @@ describe("real provider usage fetchers", () => { "https://cli-chat-proxy.grok.com/v1/billing", () => jsonResponse({ - config: { monthlyLimit: { val: 0 } }, - usage: { creditUsage: 0 }, + config: { monthlyLimit: { val: 0 }, used: { val: 0 } }, }), ], ]), @@ -742,6 +752,109 @@ describe("real provider usage fetchers", () => { }); }); + it("fetches Grok usage from live billing shape (config.used.val)", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing", + () => + jsonResponse({ + config: { + monthlyLimit: { val: 150000 }, + used: { val: 37886 }, + billingPeriodStart: "2026-07-01T00:00:00+00:00", + billingPeriodEnd: "2026-08-01T00:00:00+00:00", + }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 37886, + remaining: 112114, + limit: 150000, + unit: "credits", + }), + ], + }); + }); + + it("fetches Grok usage with nested ~/.grok/auth.json key token", async () => { + writeGrokAuth(homeDir, { + "https://auth.x.ai::test-user-id": { + key: "nested_jwt_token", + refresh_token: "rt_nested", + expires_at: "2026-08-01T00:00:00Z", + user_id: "test-user-id", + email: "user@example.com", + }, + }); + + let authorization: string | null = null; + fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record | undefined)?.Authorization ?? null; + return jsonResponse({ + config: { + monthlyLimit: { val: 100 }, + used: { val: 25 }, + }, + }); + }) as typeof fetch; + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(authorization).toBe("Bearer nested_jwt_token"); + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 25, + remaining: 75, + limit: 100, + }), + ], + }); + }); + + it("still accepts legacy Grok usage.creditUsage when config.used is absent", async () => { + process.env["GROK_API_KEY"] = "grok_test_token"; + fetchApi = mockFetch( + new Map([ + [ + "https://cli-chat-proxy.grok.com/v1/billing", + () => + jsonResponse({ + config: { monthlyLimit: { val: 50 } }, + usage: { creditUsage: 10 }, + }), + ], + ]), + ); + + const grok = findProvider(await service().listUsage(), "grok"); + + expect(grok).toMatchObject({ + status: "available", + balances: [ + expect.objectContaining({ + id: "monthly_credits", + used: 10, + remaining: 40, + limit: 50, + }), + ], + }); + }); + it("fetches Kimi usage from KIMI_TOKEN", async () => { process.env["KIMI_TOKEN"] = "kimi_test_token"; fetchApi = mockFetch( From 76e336a1beea5388404dfd5d2a31c8bfd39b6907 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 28 Jul 2026 13:18:56 +0200 Subject: [PATCH 111/420] Run only relevant CI checks for each pull request (#2500) * perf(ci): skip unaffected test jobs Keep required checks present as skipped jobs and run the full matrix whenever change detection cannot produce a trustworthy result. * fix(ci): harden change-based job gating Include shared build inputs in packaged desktop smoke selection and pin the path filter action that controls job execution. * fix(ci): run CLI checks for Nix packaging changes The CLI supervision regression suite reads the Nix package definition directly, so include that external dependency in its path filter. * fix(ci): track external test inputs Select server and CLI suites when their narrowly scoped cross-package fixtures and source assertions change. * fix(ci): track server test CLI imports Run server tests for CLI source changes because the Hub relationship harness executes the CLI command graph directly. * fix(ci): pin gating checkout action Keep every third-party action that controls change detection pinned to a verified commit SHA. --- .github/workflows/ci.yml | 173 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 161 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 888b55ba629..448b7db223c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,103 @@ env: ONNXRUNTIME_NODE_INSTALL: skip jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }} + server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }} + desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }} + desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }} + app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }} + sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }} + playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }} + relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }} + cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - name: Detect affected CI jobs + id: filter + uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 + with: + filters: | + shared: + - '.github/workflows/ci.yml' + - '.github/actions/**' + - '.mise.toml' + - '.tool-versions' + - 'package.json' + - 'package-lock.json' + - 'patches/**' + - 'scripts/**' + - 'tsconfig.json' + - 'tsconfig.base.json' + - 'vitest.config.ts' + quality: + - 'packages/**' + - '*.cjs' + - '*.js' + - '*.json' + - '*.mjs' + - '*.ts' + server: + - 'packages/app/e2e/fixtures/recording.*' + - 'packages/client/**' + - 'packages/cli/src/**' + - 'packages/highlight/**' + - 'packages/protocol/**' + - 'packages/relay/**' + - 'packages/server/**' + desktop: + - 'packages/app/**' + - 'packages/cli/**' + - 'packages/client/**' + - 'packages/desktop/**' + - 'packages/expo-two-way-audio/**' + - 'packages/highlight/**' + - 'packages/protocol/**' + - 'packages/relay/**' + - 'packages/server/**' + desktop_package: + - '.github/workflows/ci.yml' + - 'packages/desktop/**' + app: + - 'packages/app/**' + - 'packages/client/**' + - 'packages/expo-two-way-audio/**' + - 'packages/highlight/**' + - 'packages/protocol/**' + - 'packages/relay/**' + sdk: + - 'packages/client/**' + - 'packages/protocol/**' + - 'packages/relay/**' + playwright: + - 'packages/app/**' + - 'packages/client/**' + - 'packages/expo-two-way-audio/**' + - 'packages/highlight/**' + - 'packages/protocol/**' + - 'packages/relay/**' + - 'packages/server/**' + relay: + - 'packages/relay/**' + cli: + - 'nix/**' + - 'packages/app/e2e/global-setup.ts' + - 'packages/cli/**' + - 'packages/client/**' + - 'packages/desktop/src/daemon/runtime-paths.ts' + - 'packages/highlight/**' + - 'packages/protocol/**' + - 'packages/relay/**' + - 'packages/server/**' + format: runs-on: ubuntu-latest env: @@ -39,6 +136,12 @@ jobs: run: npx oxfmt --check . lint: + needs: changes + if: >- + ${{ always() && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -63,6 +166,12 @@ jobs: run: npm run lint typecheck: + needs: changes + if: >- + ${{ always() && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -89,6 +198,12 @@ jobs: npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server server-tests: + needs: changes + if: >- + ${{ always() && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.server != 'false') }} strategy: fail-fast: false matrix: @@ -126,6 +241,12 @@ jobs: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} desktop-tests: + needs: changes + if: >- + ${{ always() && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.desktop != 'false') }} strategy: fail-fast: false matrix: @@ -134,19 +255,9 @@ jobs: timeout-minutes: 30 permissions: contents: read - pull-requests: read steps: - uses: actions/checkout@v4 - - name: Detect desktop changes - if: matrix.os == 'ubuntu-latest' - id: desktop_changes - uses: dorny/paths-filter@v3 - with: - filters: | - desktop: - - 'packages/desktop/**' - - uses: actions/setup-node@v4 with: node-version: "22" @@ -184,7 +295,11 @@ jobs: retention-days: 7 - name: Build and smoke unpacked desktop app - if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true' + if: >- + matrix.os == 'ubuntu-latest' && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.desktop_package != 'false') run: npm run build:desktop -- --publish never --linux --x64 --dir env: EP_GH_IGNORE_TIME: true @@ -192,7 +307,11 @@ jobs: PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke - name: Upload packaged smoke diagnostics - if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true' + if: >- + failure() && matrix.os == 'ubuntu-latest' && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.desktop_package != 'false') uses: actions/upload-artifact@v4 with: name: desktop-packaged-smoke-linux-x64 @@ -201,6 +320,12 @@ jobs: retention-days: 7 app-tests: + needs: changes + if: >- + ${{ always() && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.app != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -225,6 +350,12 @@ jobs: run: npm run test --workspace=@getpaseo/app sdk-tests: + needs: changes + if: >- + ${{ always() && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.sdk != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -251,6 +382,12 @@ jobs: run: npm run typecheck:examples --workspace=@getpaseo/client playwright: + needs: changes + if: >- + ${{ always() && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.playwright != 'false') }} strategy: fail-fast: false matrix: @@ -309,6 +446,12 @@ jobs: retention-days: 7 relay-tests: + needs: changes + if: >- + ${{ always() && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.relay != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -330,6 +473,12 @@ jobs: run: npm run test --workspace=@getpaseo/relay cli-tests: + needs: changes + if: >- + ${{ always() && + (github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.cli != 'false') }} strategy: fail-fast: false matrix: From fd7061a8b2bae685da53056cc986a71b362add7e Mon Sep 17 00:00:00 2001 From: stonegray <7140974+stonegray@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:59:26 -0400 Subject: [PATCH 112/420] Fix AppImage launches from Linux desktops (#2439) --- packages/desktop/src/main.ts | 16 ++-- .../desktop/src/system/linux-sandbox.test.ts | 82 +++++++++++++++++++ packages/desktop/src/system/linux-sandbox.ts | 45 ++++++++++ 3 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 packages/desktop/src/system/linux-sandbox.test.ts create mode 100644 packages/desktop/src/system/linux-sandbox.ts diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 5509fdd0e73..b066e50b71c 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -8,7 +8,7 @@ import { inheritLoginShellEnv } from "./login-shell-env.js"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { app, @@ -40,6 +40,7 @@ import { buildStandardContextMenuItems, } from "./window/window-manager.js"; import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js"; +import { configureLinuxSandbox } from "./system/linux-sandbox.js"; import { registerDialogHandlers } from "./features/dialogs.js"; import { registerNotificationHandlers, @@ -309,12 +310,13 @@ if (forcedUserDataDir) { } } -// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID -// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the -// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code. -if (process.platform === "linux" && process.env.APPIMAGE) { - app.commandLine.appendSwitch("no-sandbox"); -} +configureLinuxSandbox({ + platform: process.platform, + resourcesPath: process.resourcesPath, + statSandbox: (sandboxPath) => statSync(sandboxPath), + disableSandbox: () => app.commandLine.appendSwitch("no-sandbox"), + reportInspectionError: (error) => log.error("[linux-sandbox] failed to inspect helper", error), +}); // Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging // rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). diff --git a/packages/desktop/src/system/linux-sandbox.test.ts b/packages/desktop/src/system/linux-sandbox.test.ts new file mode 100644 index 00000000000..988ac574511 --- /dev/null +++ b/packages/desktop/src/system/linux-sandbox.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { configureLinuxSandbox } from "./linux-sandbox"; + +interface SandboxMetadata { + mode: number; + uid: number; +} + +function configureWithSandbox( + sandbox: SandboxMetadata | Error, + platform: NodeJS.Platform = "linux", +) { + const disabledSwitches: string[] = []; + const inspectionErrors: unknown[] = []; + + configureLinuxSandbox({ + platform, + resourcesPath: "/opt/Paseo/resources", + statSandbox: () => { + if (sandbox instanceof Error) { + throw sandbox; + } + return sandbox; + }, + disableSandbox: () => disabledSwitches.push("no-sandbox"), + reportInspectionError: (error) => inspectionErrors.push(error), + }); + + return { disabledSwitches, inspectionErrors }; +} + +function createFileSystemError(code: string): NodeJS.ErrnoException { + const error: NodeJS.ErrnoException = new Error(code); + error.code = code; + return error; +} + +describe("configureLinuxSandbox", () => { + it("disables the sandbox when an AppImage mount strips SUID", () => { + expect(configureWithSandbox({ uid: 1000, mode: 0o755 })).toEqual({ + disabledSwitches: ["no-sandbox"], + inspectionErrors: [], + }); + }); + + it("keeps the sandbox for a root-owned 4755 helper", () => { + expect(configureWithSandbox({ uid: 0, mode: 0o4755 })).toEqual({ + disabledSwitches: [], + inspectionErrors: [], + }); + }); + + it("disables the sandbox when a SUID helper is not root-owned", () => { + expect(configureWithSandbox({ uid: 1000, mode: 0o4755 })).toEqual({ + disabledSwitches: ["no-sandbox"], + inspectionErrors: [], + }); + }); + + it("disables the sandbox when the helper is missing", () => { + expect(configureWithSandbox(createFileSystemError("ENOENT"))).toEqual({ + disabledSwitches: ["no-sandbox"], + inspectionErrors: [], + }); + }); + + it("keeps the sandbox and reports unexpected inspection failures", () => { + const permissionError = createFileSystemError("EACCES"); + + expect(configureWithSandbox(permissionError)).toEqual({ + disabledSwitches: [], + inspectionErrors: [permissionError], + }); + }); + + it("does not inspect or configure the sandbox outside Linux", () => { + expect(configureWithSandbox(createFileSystemError("EACCES"), "darwin")).toEqual({ + disabledSwitches: [], + inspectionErrors: [], + }); + }); +}); diff --git a/packages/desktop/src/system/linux-sandbox.ts b/packages/desktop/src/system/linux-sandbox.ts new file mode 100644 index 00000000000..724195d4efc --- /dev/null +++ b/packages/desktop/src/system/linux-sandbox.ts @@ -0,0 +1,45 @@ +import path from "node:path"; + +const REQUIRED_SANDBOX_MODE = 0o4755; +const PERMISSION_BITS = 0o7777; + +interface SandboxMetadata { + mode: number; + uid: number; +} + +interface LinuxSandboxConfiguration { + platform: NodeJS.Platform; + resourcesPath: string; + statSandbox: (sandboxPath: string) => SandboxMetadata; + disableSandbox: () => void; + reportInspectionError: (error: unknown) => void; +} + +function isMissingSandbox(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +export function configureLinuxSandbox(input: LinuxSandboxConfiguration): void { + if (input.platform !== "linux") { + return; + } + + try { + const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox"); + const sandbox = input.statSandbox(sandboxPath); + const hasUsableSandbox = + sandbox.uid === 0 && (sandbox.mode & PERMISSION_BITS) === REQUIRED_SANDBOX_MODE; + + if (!hasUsableSandbox) { + input.disableSandbox(); + } + } catch (error) { + if (isMissingSandbox(error)) { + input.disableSandbox(); + return; + } + + input.reportInspectionError(error); + } +} From fdee3236f75e9465fe0e239150ab0affe9d4bd23 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 28 Jul 2026 15:51:12 +0200 Subject: [PATCH 113/420] Speed up server CI tests (#2537) * perf(ci): reduce server test latency Server test files use isolated resources, so they do not require suite-wide serialization. * fix(ci): scope server test parallelism to unit suite Keep real-provider and local-resource suites serialized because they may share user configuration and account limits. * fix(terminal): isolate zsh runtimes by process Prevent concurrent daemon and test processes from deleting or replacing shell integration files used by another process. * fix(ci): preserve required matrix check names GitHub evaluates job conditions before expanding a matrix. Expand active matrices first and gate their expensive steps so required check contexts are always reported. Allow superseded runs to cancel while retaining fail-open path detection. --- .github/workflows/ci.yml | 114 ++++++++++++------ packages/server/package.json | 2 +- .../src/terminal/terminal.posix.test.ts | 6 +- packages/server/src/terminal/terminal.ts | 2 +- scripts/ci-workflow.test.mjs | 57 +++++++++ 5 files changed, 142 insertions(+), 39 deletions(-) create mode 100644 scripts/ci-workflow.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 448b7db223c..3f0214819f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,9 @@ jobs: - 'packages/relay/**' - 'packages/server/**' + - name: Validate CI workflow + run: node --test scripts/ci-workflow.test.mjs + format: runs-on: ubuntu-latest env: @@ -138,7 +141,7 @@ jobs: lint: needs: changes if: >- - ${{ always() && + ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.changes.result != 'success' || needs.changes.outputs.quality != 'false') }} @@ -168,7 +171,7 @@ jobs: typecheck: needs: changes if: >- - ${{ always() && + ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.changes.result != 'success' || needs.changes.outputs.quality != 'false') }} @@ -199,11 +202,7 @@ jobs: server-tests: needs: changes - if: >- - ${{ always() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.server != 'false') }} + if: ${{ !cancelled() }} strategy: fail-fast: false matrix: @@ -212,28 +211,43 @@ jobs: name: server-tests (${{ matrix.os }}) env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + RUN_TESTS: >- + ${{ github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.server != 'false' }} steps: + - name: Skip unaffected server tests + if: env.RUN_TESTS != 'true' + run: echo "No server changes detected." + - uses: actions/checkout@v4 + if: env.RUN_TESTS == 'true' with: fetch-depth: 0 - uses: actions/setup-node@v4 + if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Fetch origin/main (worktree tests) + if: env.RUN_TESTS == 'true' run: git fetch --no-tags origin main:refs/remotes/origin/main - name: Install dependencies + if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci - name: Install agent CLIs for provider tests + if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai - name: Build server dependencies + if: env.RUN_TESTS == 'true' run: npm run build:server-deps - name: Run server tests + if: env.RUN_TESTS == 'true' run: npm run test --workspace=@getpaseo/server env: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} @@ -242,11 +256,7 @@ jobs: desktop-tests: needs: changes - if: >- - ${{ always() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.desktop != 'false') }} + if: ${{ !cancelled() }} strategy: fail-fast: false matrix: @@ -255,39 +265,53 @@ jobs: timeout-minutes: 30 permissions: contents: read + env: + RUN_TESTS: >- + ${{ github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.desktop != 'false' }} steps: + - name: Skip unaffected desktop tests + if: env.RUN_TESTS != 'true' + run: echo "No desktop changes detected." + - uses: actions/checkout@v4 + if: env.RUN_TESTS == 'true' - uses: actions/setup-node@v4 + if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Install dependencies with retry + if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci - name: Build server stack + if: env.RUN_TESTS == 'true' run: npm run build:server - name: Run desktop tests + if: env.RUN_TESTS == 'true' run: npm run test --workspace=@getpaseo/desktop - name: Build app dependencies for desktop E2E - if: matrix.os == 'ubuntu-latest' + if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' run: npm run build:app-deps - name: Install virtual display - if: matrix.os == 'ubuntu-latest' + if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' run: sudo apt-get update && sudo apt-get install -y xvfb xauth - name: Run real Electron browser tab bridge E2E - if: matrix.os == 'ubuntu-latest' + if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop env: PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e - name: Upload browser tab bridge diagnostics uses: actions/upload-artifact@v4 - if: failure() && matrix.os == 'ubuntu-latest' + if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' with: name: browser-tab-bridge-e2e path: ${{ runner.temp }}/browser-tab-bridge-e2e @@ -296,7 +320,7 @@ jobs: - name: Build and smoke unpacked desktop app if: >- - matrix.os == 'ubuntu-latest' && + env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || needs.changes.result != 'success' || needs.changes.outputs.desktop_package != 'false') @@ -308,7 +332,7 @@ jobs: - name: Upload packaged smoke diagnostics if: >- - failure() && matrix.os == 'ubuntu-latest' && + env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || needs.changes.result != 'success' || needs.changes.outputs.desktop_package != 'false') @@ -322,7 +346,7 @@ jobs: app-tests: needs: changes if: >- - ${{ always() && + ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.changes.result != 'success' || needs.changes.outputs.app != 'false') }} @@ -352,7 +376,7 @@ jobs: sdk-tests: needs: changes if: >- - ${{ always() && + ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.changes.result != 'success' || needs.changes.outputs.sdk != 'false') }} @@ -383,11 +407,7 @@ jobs: playwright: needs: changes - if: >- - ${{ always() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.playwright != 'false') }} + if: ${{ !cancelled() }} strategy: fail-fast: false matrix: @@ -401,43 +421,57 @@ jobs: runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + RUN_TESTS: >- + ${{ github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.playwright != 'false' }} steps: + - name: Skip unaffected Playwright tests + if: env.RUN_TESTS != 'true' + run: echo "No Playwright changes detected." + - uses: actions/checkout@v4 + if: env.RUN_TESTS == 'true' - uses: actions/setup-node@v4 + if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Install dependencies with retry + if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci - name: Install Playwright browsers + if: env.RUN_TESTS == 'true' timeout-minutes: 10 run: npx playwright install chromium - name: Build app dependencies + if: env.RUN_TESTS == 'true' run: npm run build:app-deps - name: Build server stack + if: env.RUN_TESTS == 'true' run: npm run build:server - name: Install agent CLIs for provider tests - if: ${{ !matrix.desktop }} + if: env.RUN_TESTS == 'true' && !matrix.desktop run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - name: Run Playwright E2E tests - if: ${{ !matrix.desktop }} + if: env.RUN_TESTS == 'true' && !matrix.desktop run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4 env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - name: Run desktop-overlay Playwright tests - if: ${{ matrix.desktop }} + if: env.RUN_TESTS == 'true' && matrix.desktop run: npm run test:e2e:desktop --workspace=@getpaseo/app - name: Upload test artifacts uses: actions/upload-artifact@v4 - if: failure() + if: env.RUN_TESTS == 'true' && failure() with: name: playwright-results-${{ matrix.shard }} path: | @@ -448,7 +482,7 @@ jobs: relay-tests: needs: changes if: >- - ${{ always() && + ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.changes.result != 'success' || needs.changes.outputs.relay != 'false') }} @@ -474,11 +508,7 @@ jobs: cli-tests: needs: changes - if: >- - ${{ always() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.cli != 'false') }} + if: ${{ !cancelled() }} strategy: fail-fast: false matrix: @@ -487,24 +517,38 @@ jobs: name: cli-tests (shard ${{ matrix.shard }}/3) env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + RUN_TESTS: >- + ${{ github.event_name == 'workflow_dispatch' || + needs.changes.result != 'success' || + needs.changes.outputs.cli != 'false' }} steps: + - name: Skip unaffected CLI tests + if: env.RUN_TESTS != 'true' + run: echo "No CLI changes detected." + - uses: actions/checkout@v4 + if: env.RUN_TESTS == 'true' - uses: actions/setup-node@v4 + if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Install dependencies + if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci - name: Build server stack + if: env.RUN_TESTS == 'true' run: npm run build:server - name: Install agent CLIs for provider tests + if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - name: Run CLI tests + if: env.RUN_TESTS == 'true' run: npm run test --workspace=@getpaseo/cli env: PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" diff --git a/packages/server/package.json b/packages/server/package.json index 479c8fcb6ed..577cb449cd2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -49,7 +49,7 @@ "speech:download": "tsx scripts/download-speech-models.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", - "test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"", + "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/terminal/terminal.posix.test.ts b/packages/server/src/terminal/terminal.posix.test.ts index 0ea433ebb0c..4452fb6c1fa 100644 --- a/packages/server/src/terminal/terminal.posix.test.ts +++ b/packages/server/src/terminal/terminal.posix.test.ts @@ -253,7 +253,7 @@ function lastNonEmptyLineIsPrompt(state: ReturnType } function removeZshShellIntegrationRuntimeDir(): void { - rmSync(join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh`), { + rmSync(join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh-${process.pid}`), { recursive: true, force: true, }); @@ -272,7 +272,9 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { expect(resolvedEnv.TERM).toBe("xterm-256color"); expect(resolvedEnv.TERM_PROGRAM).toBe("kitty"); expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir"); - expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir"); + expect(resolvedEnv.ZDOTDIR).toBe( + join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh-${process.pid}`), + ); expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true); expect(existsSync(join(resolvedEnv.ZDOTDIR, "paseo-integration.zsh"))).toBe(true); }); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 4d3a1aa7d06..ee2ca377f45 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -384,7 +384,7 @@ function resolveZshShellIntegrationRuntimeDir(): string { } catch { // keep fallback } - return join(tmpdir(), `${username}-paseo-zsh`); + return join(tmpdir(), `${username}-paseo-zsh-${process.pid}`); } function prepareZshShellIntegrationRuntimeDir(sourceDir = resolveZshShellIntegrationDir()): string { diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs new file mode 100644 index 00000000000..7954357ddf2 --- /dev/null +++ b/scripts/ci-workflow.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const workflowPath = new URL("../.github/workflows/ci.yml", import.meta.url); + +function jobBlocks(source) { + const jobs = new Map(); + let currentJob; + + for (const line of source.split("\n")) { + const jobMatch = /^ ([a-z0-9-]+):\s*$/.exec(line); + if (jobMatch) { + currentJob = jobMatch[1]; + jobs.set(currentJob, []); + continue; + } + + if (currentJob && (/^ \S/.test(line) || /^ \S/.test(line))) { + jobs.get(currentJob).push(line); + } + } + + return jobs; +} + +test("matrix jobs expand before change gating", () => { + const workflow = readFileSync(workflowPath, "utf8"); + const gatedMatrixJobs = [...jobBlocks(workflow)] + .filter(([, lines]) => { + const hasMatrix = lines.some((line) => line.startsWith(" matrix:")); + const unsafeJobCondition = lines.some( + (line) => line.startsWith(" if:") && line.trim() !== "if: ${{ !cancelled() }}", + ); + return hasMatrix && unsafeJobCondition; + }) + .map(([jobId]) => jobId); + + assert.deepEqual( + gatedMatrixJobs, + [], + "change-based job conditions skip a matrix before GitHub can emit its interpolated check names", + ); +}); + +test("change gating allows superseded workflow runs to cancel", () => { + const workflow = readFileSync(workflowPath, "utf8"); + const cancellationBlockingJobs = [...jobBlocks(workflow)] + .filter(([, lines]) => lines.some((line) => line.trim().startsWith("${{ always()"))) + .map(([jobId]) => jobId); + + assert.deepEqual( + cancellationBlockingJobs, + [], + "always() keeps jobs alive after concurrency cancellation; use !cancelled() for fail-open gating", + ); +}); From e241e02afbb24ead6e51cbf2ceb02b2897ac2d31 Mon Sep 17 00:00:00 2001 From: nllptrx <48022579+nllptrx@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:29:33 +0200 Subject: [PATCH 114/420] feat(app): dismiss the chat keyboard on a fast upward flick (#2417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): dismiss the chat keyboard on a fast upward flick Scrolling the history to read earlier messages left the keyboard up, so the visible transcript stayed cramped and the keyboard had to be closed by hand first — two steps for what should be one. React Native's keyboardDismissMode cannot express the wanted behaviour: "on-drag" fires on the first pixel and kills the keyboard on any peek-scroll, and "interactive" is broken on inverted lists. So the gesture is measured here: samples are taken from the scroll events and, at release, the speed over the drag's final stretch decides. Measuring at release rather than averaging the whole drag is what keeps a fast but controlled read-scroll from counting as a flick, since such a gesture decelerates before the finger lifts. Timestamps and offsets come from the events, never from a clock: with a busy JS thread the callbacks arrive in a burst long after the gesture, and wall-clock spacing then reads a calm scroll as a flick. The release offset comes from the end-drag event for the same reason — the last onScroll can be stale by the time a short flick lands. Android needs both the blur and the dismiss. Dismissing alone leaves the input focused and the keyboard inset applied, so the layout stays shifted with an empty gap where the keyboard was; blurring alone releases focus but leaves the IME on screen. Verified on an Android device with a Release build: a slow drag and a 0.6 dp/ms scroll keep the keyboard, a 2.9 dp/ms flick dismisses it and the composer settles back with no leftover gap. iOS was exercised by hand on device only, without automated coverage of the gesture itself. * refactor(app): isolate keyboard flick dismissal * fix(app): isolate keyboard shift context --------- Co-authored-by: Mohamed Boudra --- .../scroll-keyboard-dismiss/model.test.ts | 139 ++++++++++++++++++ .../scroll-keyboard-dismiss/model.ts | 124 ++++++++++++++++ .../use-scroll-keyboard-dismiss.ts | 57 +++++++ .../app/src/agent-stream/strategy-native.tsx | 10 +- .../components/ui/autocomplete-popover.tsx | 2 +- .../app/src/hooks/keyboard-shift-context.ts | 18 +++ .../app/src/hooks/use-keyboard-shift-style.ts | 25 +--- 7 files changed, 349 insertions(+), 26 deletions(-) create mode 100644 packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts create mode 100644 packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts create mode 100644 packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts create mode 100644 packages/app/src/hooks/keyboard-shift-context.ts diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts new file mode 100644 index 00000000000..9d22d2b90c2 --- /dev/null +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + beginDrag, + IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, + recordScroll, + releaseDrag, + type ScrollKeyboardDismissEvent, + type ScrollKeyboardDismissGesture, +} from "./model"; + +interface Point { + ts: number; + y: number; + nativeTs?: number | null; +} + +function event(point: Point): ScrollKeyboardDismissEvent { + return { + timeStamp: point.ts, + nativeEvent: { + contentOffset: { y: point.y }, + ...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }), + }, + }; +} + +function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture { + return points.reduce( + (gesture, point) => recordScroll(gesture, event(point)), + beginDrag(event(start)), + ); +} + +function shouldDismiss(start: Point, points: Point[], release: Point): boolean { + return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss; +} + +describe("scroll keyboard dismissal", () => { + it("keeps the keyboard up for a slow read-scroll", () => { + expect( + shouldDismiss( + { ts: 1000, y: 0 }, + [ + { ts: 1040, y: 8 }, + { ts: 1080, y: 16 }, + { ts: 1120, y: 24 }, + ], + { ts: 1160, y: 32 }, + ), + ).toBe(false); + }); + + it("dismisses for an upward flick", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe( + true, + ); + }); + + it("keeps the keyboard up when the inverted list moves toward newer messages", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe( + false, + ); + }); + + it("keeps a fast-but-decelerating drag below the release threshold", () => { + expect( + shouldDismiss( + { ts: 1000, y: 0 }, + [ + { ts: 1100, y: 500 }, + { ts: 1200, y: 590 }, + { ts: 1270, y: 598 }, + ], + { ts: 1300, y: 600 }, + ), + ).toBe(false); + }); + + it("uses the whole drag when the gesture is too short to sample", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true); + }); + + it("steps back a sample when release lands immediately after one", () => { + expect( + shouldDismiss( + { ts: 1000, y: 0 }, + [ + { ts: 1040, y: 120 }, + { ts: 1075, y: 225 }, + ], + { ts: 1080, y: 240 }, + ), + ).toBe(true); + }); + + it("keeps the keyboard up when release has no measurable time span", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false); + }); + + it("ignores scroll events that arrive before the minimum sample span", () => { + expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe( + true, + ); + }); + + it("uses native gesture time when delayed callbacks arrive in a burst", () => { + expect( + shouldDismiss( + { ts: 5000, nativeTs: 1000, y: 0 }, + [ + { ts: 5001, nativeTs: 1200, y: 40 }, + { ts: 5002, nativeTs: 1400, y: 80 }, + ], + { ts: 5003, nativeTs: 1600, y: 120 }, + ), + ).toBe(false); + }); + + it("falls back to synthetic event time when native time is absent", () => { + expect( + shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], { + ts: 1080, + nativeTs: null, + y: 240, + }), + ).toBe(true); + }); + + it("ignores scroll and release events without a matching drag", () => { + const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 })); + expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE); + expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false); + }); + + it("returns to idle after release", () => { + const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 })); + expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE); + }); +}); diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts new file mode 100644 index 00000000000..73e0d19a762 --- /dev/null +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts @@ -0,0 +1,124 @@ +/** + * Pure state machine for the chat history's flick-to-dismiss gesture. + * + * Native keyboardDismissMode cannot express this interaction: "on-drag" + * dismisses on the first pixel, while "interactive" behaves incorrectly on an + * inverted list. We therefore classify the list's existing scroll gesture at + * release instead of introducing a competing pan recognizer. + */ + +const DISMISS_VELOCITY_POINTS_PER_MS = 1.5; +const RELEASE_SAMPLE_MIN_MS = 30; + +/** + * The subset of a native scroll event used by the classifier. React Native's + * type omits `nativeEvent.timestamp`, although iOS and Android both send it. + */ +export interface ScrollKeyboardDismissEvent { + timeStamp: number; + nativeEvent: { + contentOffset: { y: number }; + timestamp?: number; + }; +} + +interface DragSamples { + startTs: number; + startY: number; + sampleTs: number; + sampleY: number; + previousSampleTs: number; + previousSampleY: number; +} + +export type ScrollKeyboardDismissGesture = + | { phase: "idle" } + | { phase: "dragging"; samples: DragSamples }; + +export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({ + phase: "idle", +}); + +function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number { + const nativeTimestamp = event.nativeEvent.timestamp; + if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) { + return nativeTimestamp; + } + return event.timeStamp; +} + +export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture { + const timestamp = resolveEventTimeMs(event); + const offsetY = event.nativeEvent.contentOffset.y; + return { + phase: "dragging", + samples: { + startTs: timestamp, + startY: offsetY, + sampleTs: timestamp, + sampleY: offsetY, + previousSampleTs: 0, + previousSampleY: 0, + }, + }; +} + +export function recordScroll( + gesture: ScrollKeyboardDismissGesture, + event: ScrollKeyboardDismissEvent, +): ScrollKeyboardDismissGesture { + if (gesture.phase === "idle") { + return gesture; + } + + const timestamp = resolveEventTimeMs(event); + if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) { + return gesture; + } + + const { samples } = gesture; + return { + phase: "dragging", + samples: { + startTs: samples.startTs, + startY: samples.startY, + sampleTs: timestamp, + sampleY: event.nativeEvent.contentOffset.y, + previousSampleTs: samples.sampleTs, + previousSampleY: samples.sampleY, + }, + }; +} + +export function releaseDrag( + gesture: ScrollKeyboardDismissGesture, + event: ScrollKeyboardDismissEvent, +): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } { + if (gesture.phase === "idle") { + return { gesture, shouldDismiss: false }; + } + + const releaseTs = resolveEventTimeMs(event); + const releaseY = event.nativeEvent.contentOffset.y; + const { samples } = gesture; + + // The release event carries the gesture's true endpoint. The final onScroll + // event can still be stale when a short flick lands. + let spanStartTs = samples.startTs; + let spanStartY = samples.startY; + if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) { + spanStartTs = samples.sampleTs; + spanStartY = samples.sampleY; + } else if (samples.previousSampleTs > 0) { + spanStartTs = samples.previousSampleTs; + spanStartY = samples.previousSampleY; + } + + const spanDurationMs = releaseTs - spanStartTs; + const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0; + + return { + gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, + shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS, + }; +} diff --git a/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts b/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts new file mode 100644 index 00000000000..8632cc2bc14 --- /dev/null +++ b/packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts @@ -0,0 +1,57 @@ +import { useRef } from "react"; +import { + Keyboard, + TextInput, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; +import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { + beginDrag, + IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, + recordScroll, + releaseDrag, +} from "./model"; + +type ScrollEvent = NativeSyntheticEvent; + +/** + * Owns the chat history's flick-to-dismiss behavior. The native stream only + * forwards the FlatList scroll lifecycle; removing this hook and those three + * calls removes the feature completely. + */ +export function useScrollKeyboardDismiss() { + const { shift } = useKeyboardShift(); + const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE); + + const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => { + gestureRef.current = beginDrag(event); + }); + + const onScroll = useStableEvent((event: ScrollEvent) => { + gestureRef.current = recordScroll(gestureRef.current, event); + }); + + const onScrollEndDrag = useStableEvent((event: ScrollEvent) => { + const release = releaseDrag(gestureRef.current, event); + gestureRef.current = release.gesture; + + // `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a + // second calculation on JS, this prevents a hardware keyboard's focused + // composer from being blurred when no software keyboard occupies space. + if (!release.shouldDismiss || shift.value <= 0) { + return; + } + + // Keep blur and dismiss paired: this exact sequence was validated on a + // physical Android device to clear both input focus and the IME inset. + const focusedInput = TextInput.State.currentlyFocusedInput(); + if (focusedInput) { + TextInput.State.blurTextInput(focusedInput); + } + Keyboard.dismiss(); + }); + + return { onScroll, onScrollBeginDrag, onScrollEndDrag }; +} diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 6b67096e7bc..2a38fa26d2c 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -24,6 +24,7 @@ import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; import { useBottomAnchorController } from "./bottom-anchor-controller"; +import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy, @@ -52,7 +53,6 @@ const historyStartSlotStyle: ViewStyle = { paddingTop: 4, paddingBottom: 8, }; - interface HistoryRowDisplayVariants { regular?: StreamItem; compact?: StreamItem; @@ -110,6 +110,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const scrollOffsetYRef = useRef(0); const isUserScrollActiveRef = useRef(false); + const scrollKeyboardDismiss = useScrollKeyboardDismiss(); const userScrollEndFrameIdRef = useRef(null); const programmaticScrollEventBudgetRef = useRef(0); const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); @@ -335,6 +336,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const previousOffsetY = scrollOffsetYRef.current; scrollOffsetYRef.current = contentOffset.y; + scrollKeyboardDismiss.onScroll(event); + streamViewportMetricsRef.current = { contentHeight: Math.max(0, contentSize.height), viewportWidth: Math.max(0, layoutMeasurement.width), @@ -365,7 +368,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } }); - const handleScrollBeginDrag = useStableEvent(() => { + const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent) => { if (!isLoadingOlderHistory) { historyStartPaginationStateRef.current = rearmHistoryStartPagination( historyStartPaginationStateRef.current, @@ -373,6 +376,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat } clearPendingUserScrollEnd(); isUserScrollActiveRef.current = true; + scrollKeyboardDismiss.onScrollBeginDrag(event); bottomAnchorController.beginUserScroll(); evaluateHistoryStart(); }); @@ -381,6 +385,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat // gesture position now because layout may move the viewport in the meantime. const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent) => { const isNearBottom = isScrollEventNearBottom(event); + scrollKeyboardDismiss.onScrollEndDrag(event); + clearPendingUserScrollEnd(); userScrollEndFrameIdRef.current = requestAnimationFrame(() => { userScrollEndFrameIdRef.current = null; diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index 71873a85d11..e3b75f31e17 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -8,7 +8,7 @@ import { measureFloatingPanelPortalHost, useFloatingPanelPortalHostName, } from "@/components/ui/floating-panel-portal"; -import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style"; +import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; import { SPACING } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; diff --git a/packages/app/src/hooks/keyboard-shift-context.ts b/packages/app/src/hooks/keyboard-shift-context.ts new file mode 100644 index 00000000000..a6eeb537299 --- /dev/null +++ b/packages/app/src/hooks/keyboard-shift-context.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; +import type { SharedValue } from "react-native-reanimated"; + +export interface KeyboardShiftContextValue { + shift: SharedValue; + bottomInset: SharedValue; +} + +export const KeyboardShiftContext = createContext(null); + +/** Read the app-wide keyboard inset without loading its native provider implementation. */ +export function useKeyboardShift(): KeyboardShiftContextValue { + const context = useContext(KeyboardShiftContext); + if (!context) { + throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider"); + } + return context; +} diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index 63799c6783b..441d73520eb 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -1,11 +1,4 @@ -import { - createContext, - createElement, - useContext, - useEffect, - useMemo, - type ReactNode, -} from "react"; +import { createElement, useEffect, useMemo, type ReactNode } from "react"; import { Platform } from "react-native"; import type { ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -23,16 +16,10 @@ import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, } from "@/hooks/keyboard-shift-policy"; +import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context"; type KeyboardShiftMode = "translate" | "padding"; -interface KeyboardShiftContextValue { - shift: SharedValue; - bottomInset: SharedValue; -} - -const KeyboardShiftContext = createContext(null); - export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const insets = useSafeAreaInsets(); const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation(); @@ -78,14 +65,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { return createElement(KeyboardShiftContext.Provider, { value }, children); } -export function useKeyboardShift(): KeyboardShiftContextValue { - const context = useContext(KeyboardShiftContext); - if (!context) { - throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider"); - } - return context; -} - export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): { shift: SharedValue; style: ReturnType>; From 963d4f92400c6511fc58fd1b44fb162c9d29d0af Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 28 Jul 2026 16:30:50 +0200 Subject: [PATCH 115/420] Configure agent thinking from the CLI (#2533) * feat(cli): update agent thinking from the CLI * fix(cli): harden agent thinking updates * feat(cli): configure thinking for schedules * fix(cli): report applied thinking updates * fix(cli): report current agent thinking state --- packages/cli/src/cli-surface.test.ts | 13 ++ packages/cli/src/commands/agent/index.ts | 3 +- .../cli/src/commands/agent/update.test.ts | 108 +++++++++++++ packages/cli/src/commands/agent/update.ts | 148 ++++++++++++++---- packages/cli/src/commands/schedule/create.ts | 2 + packages/cli/src/commands/schedule/index.ts | 1 + .../cli/src/commands/schedule/shared.test.ts | 19 +++ packages/cli/src/commands/schedule/shared.ts | 14 +- packages/cli/tests/16-agent-update.test.ts | 25 +++ packages/cli/tests/31-loop-schedule.test.ts | 3 + .../messages.project-command-center.test.ts | 10 ++ packages/protocol/src/messages.ts | 2 + .../server/src/server/websocket-server.ts | 2 + 13 files changed, 319 insertions(+), 31 deletions(-) create mode 100644 packages/cli/src/commands/agent/update.test.ts diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index 9011c23b32a..c480fe824ff 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -35,6 +35,19 @@ describe("canonical CLI surface", () => { expect(run?.helpInformation()).not.toContain("--detach"); }); + it("offers thinking configuration when running, updating, and scheduling agents", () => { + const cli = createCli(); + const run = cli.commands.find((command) => command.name() === "run"); + const agent = cli.commands.find((command) => command.name() === "agent"); + const update = agent?.commands.find((command) => command.name() === "update"); + const schedule = cli.commands.find((command) => command.name() === "schedule"); + const scheduleCreate = schedule?.commands.find((command) => command.name() === "create"); + + expect(run?.helpInformation()).toContain("--thinking "); + expect(update?.helpInformation()).toContain("--thinking "); + expect(scheduleCreate?.helpInformation()).toContain("--thinking "); + }); + it("offers opening an existing agent in the desktop app", () => { const agent = createCli().commands.find((command) => command.name() === "agent"); const open = agent?.commands.find((command) => command.name() === "open"); diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index a0fd5d5def6..85d34ef9170 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -92,9 +92,10 @@ export function createAgentCommand(): Command { addJsonAndDaemonHostOptions( agent .command("update") - .description("Update an agent's metadata") + .description("Update an agent's settings or metadata") .argument("", "Agent ID (or prefix)") .option("--name ", "Update the agent's display name") + .option("--thinking ", "Update the agent's thinking option ID") .option( "--label