From 4e9d80d118c0305814e4bc29a161f22bc10b7abf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Apr 2026 19:25:24 +0700 Subject: [PATCH 01/16] fix: prevent model name truncation in combobox item rows --- packages/app/src/components/ui/combobox.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index a646bac20..78a12cd8a 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -890,6 +890,7 @@ const styles = StyleSheet.create((theme) => ({ comboboxItemLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, + flexShrink: 0, }, comboboxItemDescription: { fontSize: theme.fontSize.xs, From deca82653e013a22b5fd5a62b3f218594c2cfc82 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Apr 2026 21:02:07 +0700 Subject: [PATCH 02/16] chore(release): cut 0.1.57-rc.1 --- package-lock.json | 38 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 8 ++--- packages/cli/package.json | 6 ++-- packages/desktop/package.json | 6 ++-- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 6 ++-- packages/website/package.json | 2 +- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb390f0b6..483514e98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.56", + "version": "0.1.57-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.56", + "version": "0.1.57-rc.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34906,16 +34906,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.56", - "@getpaseo/highlight": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/expo-two-way-audio": "0.1.57-rc.1", + "@getpaseo/highlight": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35056,11 +35056,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/relay": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35101,11 +35101,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.56", + "version": "0.1.57-rc.1", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/cli": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35139,7 +35139,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.56", + "version": "0.1.57-rc.1", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35340,7 +35340,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35366,7 +35366,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35382,14 +35382,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.56", - "@getpaseo/relay": "0.1.56", + "@getpaseo/highlight": "0.1.57-rc.1", + "@getpaseo/relay": "0.1.57-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35817,7 +35817,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index 6b4a332d1..3dd99ed9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.56", + "version": "0.1.57-rc.1", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index 72224ae52..6670e35e7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.56", + "version": "0.1.57-rc.1", "private": true, "scripts": { "start": "expo start", @@ -31,9 +31,9 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.56", - "@getpaseo/highlight": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/expo-two-way-audio": "0.1.57-rc.1", + "@getpaseo/highlight": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index bab89f940..f4056bfc0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.56", + "version": "0.1.57-rc.1", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -24,8 +24,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/relay": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d251e2a9f..36843f596 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.56", + "version": "0.1.57-rc.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -13,8 +13,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/cli": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 454748fa5..0a7d355ec 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.1.56", + "version": "0.1.57-rc.1", "description": "Native module for two way audio streaming", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 76cd85999..1ec82f7c0 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.56", + "version": "0.1.57-rc.1", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index d33a39760..00ca118e3 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.56", + "version": "0.1.57-rc.1", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index 6fae3809a..84006f5ab 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.56", + "version": "0.1.57-rc.1", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -64,8 +64,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.56", - "@getpaseo/relay": "0.1.56", + "@getpaseo/highlight": "0.1.57-rc.1", + "@getpaseo/relay": "0.1.57-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", diff --git a/packages/website/package.json b/packages/website/package.json index 055c9800c..d2b5bc0b0 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.56", + "version": "0.1.57-rc.1", "private": true, "type": "module", "scripts": { From b8ade39f73c89554aee1560ba5df2718295b19a1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 14:03:10 +0000 Subject: [PATCH 03/16] fix: update lockfile signatures and Nix hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index dd0909a3f..f3d346853 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-NBB8+DNyodnREhVEiNMLADd+4MMK4lssRuzlwvvPjk8="; + npmDepsHash = "sha256-UrTPJVju3jiSlkUMfh25rWobIo9hWNVFoVewFGxRQC0="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). From 45a5ba8637a60fc1bc6abe7372997d4e32a13871 Mon Sep 17 00:00:00 2001 From: Edvard Chen Date: Thu, 16 Apr 2026 10:23:05 +0800 Subject: [PATCH 04/16] Fix duplicate command args for generic ACP providers (#444) * Fix duplicate command args for generic ACP providers * Fix desktop IME enter handling --- packages/app/src/components/message-input.tsx | 3 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 7 +++ packages/app/src/utils/keyboard-ime.test.ts | 31 +++++++++++++ packages/app/src/utils/keyboard-ime.ts | 5 +++ .../agent/providers/generic-acp-agent.test.ts | 45 +++++++++++++++++++ .../agent/providers/generic-acp-agent.ts | 4 -- 6 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/utils/keyboard-ime.test.ts create mode 100644 packages/app/src/utils/keyboard-ime.ts create mode 100644 packages/server/src/server/agent/providers/generic-acp-agent.test.ts diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 44ce35c52..3965435e0 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -45,6 +45,7 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { formatShortcut } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; +import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { markScrollInvestigationEvent, markScrollInvestigationRender, @@ -895,7 +896,7 @@ export const MessageInput = forwardRef(funct // IME composition in progress (e.g. CJK input) — all key events belong to the // IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is // cleared before the keydown fires. - if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + if (isImeComposingKeyboardEvent(event.nativeEvent)) return; // Allow parent to intercept key events (e.g., for autocomplete navigation) if (onKeyPressCallback) { diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 9031d2b0c..84625295b 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -27,6 +27,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { isNative } from "@/constants/platform"; +import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; export function useKeyboardShortcuts({ enabled, @@ -323,6 +324,12 @@ export function useKeyboardShortcuts({ return; } + // During IME composition, Enter confirms the candidate selection and must + // not route through global shortcuts like message send. + if (isImeComposingKeyboardEvent(event)) { + return; + } + const store = useKeyboardShortcutsStore.getState(); if (store.capturingShortcut) { return; diff --git a/packages/app/src/utils/keyboard-ime.test.ts b/packages/app/src/utils/keyboard-ime.test.ts new file mode 100644 index 000000000..e505f4b63 --- /dev/null +++ b/packages/app/src/utils/keyboard-ime.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { isImeComposingKeyboardEvent } from "./keyboard-ime"; + +describe("isImeComposingKeyboardEvent", () => { + it("ignores events while IME composition is active", () => { + expect( + isImeComposingKeyboardEvent({ + isComposing: true, + keyCode: 13, + } as KeyboardEvent), + ).toBe(true); + }); + + it("ignores Chromium IME fallback events with keyCode 229", () => { + expect( + isImeComposingKeyboardEvent({ + isComposing: false, + keyCode: 229, + } as KeyboardEvent), + ).toBe(true); + }); + + it("keeps regular keyboard events eligible for shortcuts", () => { + expect( + isImeComposingKeyboardEvent({ + isComposing: false, + keyCode: 13, + } as KeyboardEvent), + ).toBe(false); + }); +}); diff --git a/packages/app/src/utils/keyboard-ime.ts b/packages/app/src/utils/keyboard-ime.ts new file mode 100644 index 000000000..7d1d7edfc --- /dev/null +++ b/packages/app/src/utils/keyboard-ime.ts @@ -0,0 +1,5 @@ +export function isImeComposingKeyboardEvent( + event: Pick, +): boolean { + return event.isComposing || event.keyCode === 229; +} diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.test.ts new file mode 100644 index 000000000..cd05d1f3a --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; + +const mockState = vi.hoisted(() => ({ + superConstructorOptions: [] as unknown[], +})); + +vi.mock("./acp-agent.js", () => ({ + ACPAgentClient: class ACPAgentClient { + readonly provider: string; + + constructor(options: unknown) { + this.provider = "acp"; + mockState.superConstructorOptions.push(options); + } + }, +})); + +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient", () => { + test("passes the custom command only as defaultCommand", () => { + new GenericACPAgentClient({ + logger: createTestLogger(), + command: ["hermes", "acp"], + env: { + HERMES_LOG: "info", + }, + }); + + expect(mockState.superConstructorOptions).toEqual([ + { + provider: "acp", + logger: expect.any(Object), + runtimeSettings: { + env: { + HERMES_LOG: "info", + }, + }, + defaultCommand: ["hermes", "acp"], + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index ea9423df9..53b2f0f1c 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -21,10 +21,6 @@ export class GenericACPAgentClient extends ACPAgentClient { provider: "acp", logger: options.logger, runtimeSettings: { - command: { - mode: "replace", - argv: options.command, - }, env: options.env, }, defaultCommand: options.command as [string, ...string[]], From ffbb2ffd06262b059b8d1976057d6337ef75c640 Mon Sep 17 00:00:00 2001 From: Rui Fan <1996fanrui@gmail.com> Date: Thu, 16 Apr 2026 04:28:02 +0200 Subject: [PATCH 05/16] fix: retry file explorer init when client reconnects after page refresh (#442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasInitializedRef was set to true before confirming the directory listing request actually succeeded. On page refresh the WebSocket client is still reconnecting, so requestDirectoryListing returned early with "Host is not connected" and the ref stayed true — preventing any retry once the client became available. Fix: make requestDirectoryListing return Promise and reset hasInitializedRef on failure so the init effect re-runs automatically when requestDirectoryListing is recreated after the client reconnects. Fixes #441 Co-authored-by: Claude Sonnet 4.6 --- .../app/src/components/file-explorer-pane.tsx | 31 ++++++++++++------- .../src/hooks/use-file-explorer-actions.ts | 13 +++++--- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 5d952e135..9575eb891 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -167,23 +167,32 @@ export function FileExplorerPane({ if (hasInitializedRef.current) { return; } + // Mark initialized eagerly so concurrent effect re-runs don't double-fetch. + // If the root listing fails (e.g. client not yet connected), we reset the + // flag so the next time requestDirectoryListing is recreated (when client + // becomes available) this effect retries automatically. hasInitializedRef.current = true; void requestDirectoryListing(".", { recordHistory: false, setCurrentPath: false, - }); - const persistedPaths = - usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""]; - if (persistedPaths) { - for (const path of persistedPaths) { - if (path !== ".") { - void requestDirectoryListing(path, { - recordHistory: false, - setCurrentPath: false, - }); + }).then((succeeded) => { + if (!succeeded) { + hasInitializedRef.current = false; + return; + } + const persistedPaths = + usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""]; + if (persistedPaths) { + for (const path of persistedPaths) { + if (path !== ".") { + void requestDirectoryListing(path, { + recordHistory: false, + setCurrentPath: false, + }); + } } } - } + }); }, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]); // Expand ancestor directories when a file is selected (e.g., from an inline path click) diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 766b1f978..f23c4056e 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -82,9 +82,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor ); const requestDirectoryListing = useCallback( - async (path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }) => { + async ( + path: string, + options?: { recordHistory?: boolean; setCurrentPath?: boolean }, + ): Promise => { if (!workspaceStateKey) { - return; + return false; } const normalizedPath = path && path.length > 0 ? path : "."; const shouldSetCurrentPath = options?.setCurrentPath ?? true; @@ -113,7 +116,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor lastError: "Workspace is unavailable", pendingRequest: null, })); - return; + return false; } if (!client) { @@ -123,7 +126,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor lastError: "Host is not connected", pendingRequest: null, })); - return; + return false; } try { @@ -150,6 +153,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor return nextState; }); + return true; } catch (error) { updateExplorerState((state) => ({ ...state, @@ -157,6 +161,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor lastError: error instanceof Error ? error.message : "Failed to list directory", pendingRequest: null, })); + return false; } }, [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey], From 57312d4f75e7b51b22838404ff3d13ede1201341 Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Thu, 16 Apr 2026 12:29:05 +1000 Subject: [PATCH 06/16] fix(server): Map OpenCode todo and compaction events (#429) Translate OpenCode todo and compaction events into Paseo timeline items so issue #106 uses the existing todo list and compaction UI without changing the client model. Keep session.status handling limited to existing terminal states and add focused translator coverage for each mapped event. Fixes #106 Co-authored-by: OpenCode --- .../server/agent/providers/opencode-agent.ts | 64 ++++++ .../opencode/event-translator.test.ts | 183 ++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 4aea0eb2e..c2953b422 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1159,6 +1159,38 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function mapOpenCodeTodosToTimelineItems( + todos: Array<{ content?: string | null; status?: string | null }>, +): Extract { + return { + type: "todo", + items: todos.flatMap((todo) => { + const text = readNonEmptyString(todo.content); + if (!text) { + return []; + } + + return [ + { + text, + completed: todo.status === "completed", + }, + ]; + }), + }; +} + +function createCompactionTimelineItem( + status: Extract["status"], + trigger?: Extract["trigger"], +): Extract { + return { + type: "compaction", + status, + ...(trigger ? { trigger } : {}), + }; +} + export function translateOpenCodeEvent( event: OpenCodeEvent, state: OpenCodeEventTranslationState, @@ -1258,6 +1290,12 @@ export function translateOpenCodeEvent( item: parsedToolPart.data, }); } + } else if (part.type === "compaction") { + events.push({ + type: "timeline", + provider: "opencode", + item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"), + }); } else if (part.type === "step-finish") { mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part); if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) { @@ -1378,6 +1416,32 @@ export function translateOpenCodeEvent( break; } + case "todo.updated": { + if (event.properties.sessionID !== state.sessionId) { + break; + } + + events.push({ + type: "timeline", + provider: "opencode", + item: mapOpenCodeTodosToTimelineItems(event.properties.todos), + }); + break; + } + + case "session.compacted": { + if (event.properties.sessionID !== state.sessionId) { + break; + } + + events.push({ + type: "timeline", + provider: "opencode", + item: createCompactionTimelineItem("completed"), + }); + break; + } + case "session.idle": { if (event.properties.sessionID === state.sessionId) { state.streamedPartKeys.clear(); diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 72986fe28..69bd08ce6 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -310,6 +310,96 @@ describe("translateOpenCodeEvent", () => { }); }); + it("emits normalized todo timeline items from todo.updated", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "todo.updated", + properties: { + sessionID: "session-1", + todos: [ + { content: "Outline", status: "pending", priority: "high" }, + { content: "Ship", status: "completed", priority: "medium" }, + { content: " ", status: "completed", priority: "low" }, + ], + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "todo", + items: [ + { text: "Outline", completed: false }, + { text: "Ship", completed: true }, + ], + }, + }, + ]); + }); + + it("emits compaction loading timeline items from compaction parts", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "compaction-part-1", + sessionID: "session-1", + messageID: "message-compaction-1", + type: "compaction", + auto: true, + }, + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "compaction", + status: "loading", + trigger: "auto", + }, + }, + ]); + }); + + it("emits compaction completed timeline items from session.compacted", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "session.compacted", + properties: { + sessionID: "session-1", + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "compaction", + status: "completed", + }, + }, + ]); + }); + it("emits reasoning from message.part.delta events", () => { const state = createState(); @@ -510,6 +600,99 @@ describe("translateOpenCodeEvent", () => { expect(result).toEqual([]); }); + it("emits turn_completed from session.status idle", () => { + const state = createState(); + state.streamedPartKeys.add("text:part-1"); + state.partTypes.set("part-1", "text"); + + const result = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { type: "idle" }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "turn_completed", + provider: "opencode", + usage: undefined, + }, + ]); + expect(state.streamedPartKeys.size).toBe(0); + expect(state.partTypes.size).toBe(0); + }); + + it("emits turn_failed from fatal session.status retry", () => { + const state = createState(); + state.streamedPartKeys.add("text:part-1"); + state.partTypes.set("part-1", "text"); + + const result = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { + type: "retry", + attempt: 2, + message: "Invalid API key", + next: Date.now() + 1000, + }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "turn_failed", + provider: "opencode", + error: "Invalid API key", + }, + ]); + expect(state.streamedPartKeys.size).toBe(0); + expect(state.partTypes.size).toBe(0); + }); + + it("ignores transient session.status updates", () => { + const state = createState(); + + const busy = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { type: "busy" }, + }, + }, + state, + ); + + const retry = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { + type: "retry", + attempt: 1, + message: "rate limited", + next: Date.now() + 1000, + }, + }, + }, + state, + ); + + expect(busy).toEqual([]); + expect(retry).toEqual([]); + }); + it("emits structured assistant output when schema mode completes without text parts", () => { const state = createState(); From 1885d8602f6b3b13ed54abf25146a141dd6ecafa Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Thu, 16 Apr 2026 12:33:49 +1000 Subject: [PATCH 07/16] feat(app): Render markdown files in file pane (#427) Detect .md and .markdown files in the shared file pane and render them as markdown instead of syntax highlighted code. This makes README-style files easier to read without changing the existing preview flow for other text files or .mdx. Keep the change focused to the app renderer by reusing the existing markdown stack and adding a narrow extension check with targeted coverage. --- .../components/file-pane-render-mode.test.ts | 23 ++++++++++++ .../src/components/file-pane-render-mode.ts | 4 +++ packages/app/src/components/file-pane.tsx | 35 +++++++++++++++++-- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/components/file-pane-render-mode.test.ts create mode 100644 packages/app/src/components/file-pane-render-mode.ts diff --git a/packages/app/src/components/file-pane-render-mode.test.ts b/packages/app/src/components/file-pane-render-mode.test.ts new file mode 100644 index 000000000..b57b81711 --- /dev/null +++ b/packages/app/src/components/file-pane-render-mode.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; + +describe("isRenderedMarkdownFile", () => { + it("detects .md files", () => { + expect(isRenderedMarkdownFile("README.md")).toBe(true); + expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true); + }); + + it("detects .markdown files", () => { + expect(isRenderedMarkdownFile("notes.markdown")).toBe(true); + expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true); + }); + + it("does not treat .mdx files as rendered markdown", () => { + expect(isRenderedMarkdownFile("page.mdx")).toBe(false); + }); + + it("does not treat other text files as rendered markdown", () => { + expect(isRenderedMarkdownFile("src/index.ts")).toBe(false); + expect(isRenderedMarkdownFile("README.md.txt")).toBe(false); + }); +}); diff --git a/packages/app/src/components/file-pane-render-mode.ts b/packages/app/src/components/file-pane-render-mode.ts new file mode 100644 index 000000000..d6f507f9e --- /dev/null +++ b/packages/app/src/components/file-pane-render-mode.ts @@ -0,0 +1,4 @@ +export function isRenderedMarkdownFile(filePath: string): boolean { + const normalizedPath = filePath.trim().toLowerCase(); + return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown"); +} diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index e11419e57..7341bf888 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; +import Markdown, { MarkdownIt } from "react-native-markdown-display"; import { ActivityIndicator, Image as RNImage, @@ -20,7 +21,9 @@ import { type HighlightStyle, } from "@getpaseo/highlight"; import { lineNumberGutterWidth } from "@/components/code-insets"; +import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import { isWeb } from "@/constants/platform"; +import { createMarkdownStyles } from "@/styles/markdown-styles"; interface CodeLineProps { tokens: HighlightToken[]; @@ -117,6 +120,9 @@ function FilePreviewBody({ const isDark = theme.colorScheme === "dark"; const colorMap = isDark ? darkHighlightColors : lightHighlightColors; const baseColor = isDark ? "#c9d1d9" : "#24292f"; + const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); + const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); + const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath); const previewScrollRef = useRef(null); const scrollbar = useWebScrollViewScrollbar(previewScrollRef, { @@ -124,12 +130,12 @@ function FilePreviewBody({ }); const highlightedLines = useMemo(() => { - if (!preview || preview.kind !== "text") { + if (!preview || preview.kind !== "text" || isMarkdownFile) { return null; } return highlightCode(preview.content ?? "", filePath); - }, [preview?.kind, preview?.content, filePath]); + }, [isMarkdownFile, preview?.kind, preview?.content, filePath]); const gutterWidth = useMemo(() => { if (!highlightedLines) return 0; @@ -154,6 +160,28 @@ function FilePreviewBody({ } if (preview.kind === "text") { + if (isMarkdownFile) { + return ( + + + + {preview.content ?? ""} + + + {scrollbar.overlay} + + ); + } + const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]]; const codeLines = ( @@ -329,6 +357,9 @@ const styles = StyleSheet.create((theme) => ({ previewCodeScrollContent: { padding: theme.spacing[4], }, + previewMarkdownScrollContent: { + padding: theme.spacing[4], + }, previewImageScrollContent: { flexGrow: 1, padding: theme.spacing[4], From 0b3c29b2ec5b13b1d3d80f2734ed0d36518e037a Mon Sep 17 00:00:00 2001 From: Edvard Chen Date: Thu, 16 Apr 2026 10:34:30 +0800 Subject: [PATCH 08/16] fix(desktop): allow localhost origins in dev (#419) Desktop dev uses Electron pages on random localhost ports, so the daemon must accept those websocket origins during local development. --- packages/desktop/scripts/dev.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index a4756af9a..540941b2a 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -13,6 +13,11 @@ npm run build:main EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port") export EXPO_PORT +# Allow any origin in dev so Electron on random localhost ports can reach +# the daemon websocket. Safe here because this script is development-only +# and the daemon still binds to localhost. +export PASEO_CORS_ORIGINS="*" + echo "══════════════════════════════════════════════════════" echo " Paseo Desktop Dev" echo "══════════════════════════════════════════════════════" From 483312e1dcee6029e5922351be49a74c16dcd51d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Apr 2026 09:42:40 +0700 Subject: [PATCH 09/16] feat: add disallowedTools to provider config Allow providers to specify tools that should be disabled via a disallowedTools array in config.json. This is useful for providers that extend "claude" but point to third-party API endpoints that don't support Anthropic-only server-side tools like WebSearch. Closes #390 --- docs/CUSTOM-PROVIDERS.md | 26 +++++++++++ .../server/agent/provider-launch-config.ts | 2 + .../server/agent/provider-registry.test.ts | 46 +++++++++++++++++++ .../src/server/agent/provider-registry.ts | 7 ++- .../server/agent/providers/claude-agent.ts | 6 +++ 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/docs/CUSTOM-PROVIDERS.md b/docs/CUSTOM-PROVIDERS.md index 3e1afa9a0..dedcdb60d 100644 --- a/docs/CUSTOM-PROVIDERS.md +++ b/docs/CUSTOM-PROVIDERS.md @@ -107,6 +107,7 @@ Required fields for custom providers: - `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key - The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic) - If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider +- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors. - Automated setup is also available: `npx @z_ai/coding-helper` - Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude) @@ -173,6 +174,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk - API keys must be created in the **Singapore region** - The coding plan is for personal use only in interactive coding tools +- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors. - Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan) --- @@ -436,6 +438,7 @@ Every entry under `agents.providers` accepts these fields: | `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process | | `env` | `Record` | No | Environment variables to set for the agent process | | `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) | +| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) | | `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) | | `order` | `number` | No | Sort order in the provider list | @@ -460,6 +463,29 @@ Each entry in the `models` array: | `description` | `string` | No | Short description | | `isDefault` | `boolean` | No | Mark as the default thinking option | +### Gotcha: `extends: "claude"` with third-party endpoints + +When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors. + +Use `disallowedTools` to disable unsupported tools: + +```json +{ + "agents": { + "providers": { + "my-proxy": { + "extends": "claude", + "label": "My Proxy", + "env": { + "ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1" + }, + "disallowedTools": ["WebSearch"] + } + } + } +} +``` + ### Valid `extends` values Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi` diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index cf7de3a9b..e03ce335c 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -34,6 +34,7 @@ export const ProviderRuntimeSettingsSchema = z .object({ command: ProviderCommandSchema.optional(), env: z.record(z.string()).optional(), + disallowedTools: z.array(z.string()).optional(), }) .strict(); @@ -64,6 +65,7 @@ export const ProviderOverrideSchema = z command: z.array(z.string().min(1)).min(1).optional(), env: z.record(z.string()).optional(), models: z.array(ProviderProfileModelSchema).optional(), + disallowedTools: z.array(z.string()).optional(), enabled: z.boolean().optional(), order: z.number().optional(), }) diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index e19a087e7..8d669c1bd 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -473,6 +473,52 @@ describe("buildProviderRegistry", () => { expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude"); }); + test("disallowedTools flows through to runtime settings", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { + disallowedTools: ["WebSearch", "WebFetch"], + }, + }, + }); + + expect(mockState.constructorArgs.claude[0]).toEqual({ + runtimeSettings: { + command: undefined, + env: undefined, + disallowedTools: ["WebSearch", "WebFetch"], + }, + }); + }); + + test("derived provider inherits and merges disallowedTools from base", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { + disallowedTools: ["WebSearch"], + }, + zai: { + extends: "claude", + label: "ZAI", + disallowedTools: ["ComputerUse"], + }, + }, + }); + + const zaiArgs = mockState.constructorArgs.claude.find( + (entry) => + Array.isArray((entry.runtimeSettings as { disallowedTools?: string[] })?.disallowedTools) && + (entry.runtimeSettings as { disallowedTools: string[] }).disallowedTools.includes( + "ComputerUse", + ), + ); + expect(zaiArgs).toBeDefined(); + expect((zaiArgs!.runtimeSettings as { disallowedTools: string[] }).disallowedTools).toEqual([ + "WebSearch", + "ComputerUse", + ]); + }); + test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => { buildProviderRegistry(logger, { providerOverrides: { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 0707a8534..a7d383078 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -90,7 +90,7 @@ function getProviderClientFactory(provider: string): ProviderClientFactory { } function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings | undefined { - if (!override?.command && !override?.env) { + if (!override?.command && !override?.env && !override?.disallowedTools) { return undefined; } @@ -102,6 +102,7 @@ function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings } : undefined, env: override.env, + disallowedTools: override.disallowedTools, }; } @@ -122,6 +123,10 @@ function mergeRuntimeSettings( ...(override?.env ?? {}), } : undefined, + disallowedTools: + base?.disallowedTools || override?.disallowedTools + ? [...(base?.disallowedTools ?? []), ...(override?.disallowedTools ?? [])] + : undefined, }; } diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 9ce93275a..90b137654 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -2145,6 +2145,12 @@ class ClaudeAgentSession implements AgentSession { if (this.claudeSessionId) { base.resume = this.claudeSessionId; } + if (this.runtimeSettings?.disallowedTools?.length) { + base.disallowedTools = [ + ...(base.disallowedTools ?? []), + ...this.runtimeSettings.disallowedTools, + ]; + } return this.applyRuntimeSettings(base); } From 86bb5cc82701f3f668389e31360bde98912250cc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Apr 2026 10:44:57 +0800 Subject: [PATCH 10/16] fix: make MCP tools work for archived agents, matching CLI code paths (#423) Extract shared functions from session.ts (toAgentPersistenceHandle, buildStoredAgentPayload, ensureAgentLoaded) so both CLI/WebSocket handlers and MCP tools use the same code paths for agent lookup. Fix get_agent_status, get_agent_activity, and list_agents MCP tools to fall back to persistent storage for archived agents. Add includeArchived param to list_agents. Fix setupFinishNotification to not wake archived callers. Delete dead agent-management-mcp.ts. --- .../server/src/server/agent/agent-loading.ts | 80 ++ .../src/server/agent/agent-management-mcp.ts | 1025 ----------------- .../src/server/agent/agent-projections.ts | 92 ++ .../src/server/agent/mcp-server.test.ts | 267 ++++- .../server/src/server/agent/mcp-server.ts | 64 +- .../src/server/agent/mcp-shared.test.ts | 78 ++ .../server/src/server/agent/mcp-shared.ts | 10 +- .../server/src/server/persistence-hooks.ts | 76 +- .../src/server/schedule/service.test.ts | 58 + .../server/src/server/schedule/service.ts | 79 +- packages/server/src/server/session.ts | 248 +--- 11 files changed, 751 insertions(+), 1326 deletions(-) create mode 100644 packages/server/src/server/agent/agent-loading.ts delete mode 100644 packages/server/src/server/agent/agent-management-mcp.ts create mode 100644 packages/server/src/server/agent/mcp-shared.test.ts diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts new file mode 100644 index 000000000..01ae00630 --- /dev/null +++ b/packages/server/src/server/agent/agent-loading.ts @@ -0,0 +1,80 @@ +import type { Logger } from "pino"; + +import type { AgentProvider } from "./agent-sdk-types.js"; +import type { AgentManager, ManagedAgent } from "./agent-manager.js"; +import type { AgentStorage } from "./agent-storage.js"; +import { + buildConfigOverrides, + buildSessionConfig, + extractTimestamps, + toAgentPersistenceHandle, +} from "../persistence-hooks.js"; + +const pendingAgentInitializations = new Map>(); + +export interface EnsureAgentLoadedDeps { + agentManager: AgentManager; + agentStorage: AgentStorage; + validProviders?: Iterable; + logger: Logger; +} + +export async function ensureAgentLoaded( + agentId: string, + deps: EnsureAgentLoadedDeps, +): Promise { + const existing = deps.agentManager.getAgent(agentId); + if (existing) { + return existing; + } + + const inflight = pendingAgentInitializations.get(agentId); + if (inflight) { + return inflight; + } + + const initPromise = (async () => { + const record = await deps.agentStorage.get(agentId); + if (!record) { + throw new Error(`Agent not found: ${agentId}`); + } + + const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds(); + const handle = toAgentPersistenceHandle(deps.logger, validProviders, record.persistence); + + let snapshot: ManagedAgent; + if (handle) { + snapshot = await deps.agentManager.resumeAgentFromPersistence( + handle, + buildConfigOverrides(record), + agentId, + extractTimestamps(record), + ); + deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence"); + } else { + const config = buildSessionConfig(record, { + validProviders, + logger: deps.logger, + }); + if (!config) { + throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); + } + snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels }); + deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); + } + + await deps.agentManager.hydrateTimelineFromProvider(agentId); + return deps.agentManager.getAgent(agentId) ?? snapshot; + })(); + + pendingAgentInitializations.set(agentId, initPromise); + + try { + return await initPromise; + } finally { + const current = pendingAgentInitializations.get(agentId); + if (current === initPromise) { + pendingAgentInitializations.delete(agentId); + } + } +} diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts deleted file mode 100644 index 6070fd5ec..000000000 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ /dev/null @@ -1,1025 +0,0 @@ -/** - * Agent Management MCP Server - * - * Purpose: Managing agents from the UI/voice assistant LLM - * Transport: In-memory (runs in-process with the voice assistant LLM) - * Server name: "paseo-agent-management" - * - * Tools: - * - create_agent - * - wait_for_agent - * - send_agent_prompt - * - get_agent_status - * - list_agents - * - cancel_agent - * - kill_agent - * - get_agent_activity - * - set_agent_mode - * - list_pending_permissions - * - respond_to_permission - * - * No callerAgentId needed - voice assistant is not an agent. - */ - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { ensureValidJson } from "../json-utils.js"; -import type { Logger } from "pino"; - -import type { AgentProvider } from "./agent-sdk-types.js"; -import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; -import { - AgentPermissionRequestPayloadSchema, - AgentPermissionResponseSchema, - AgentSnapshotPayloadSchema, -} from "../messages.js"; -import { toAgentPayload } from "./agent-projections.js"; -import { curateAgentActivity } from "./activity-curator.js"; -import { AgentStorage } from "./agent-storage.js"; -import { - appendTimelineItemIfAgentKnown, - emitLiveTimelineItemIfAgentKnown, -} from "./timeline-append.js"; -import { type WorktreeConfig } from "../../utils/worktree.js"; -import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; -import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; -import { expandUserPath } from "../path-utils.js"; -import type { TerminalManager } from "../../terminal/terminal-manager.js"; -import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; -import type { ScheduleService } from "../schedule/service.js"; -import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; -import type { ProviderDefinition } from "./provider-registry.js"; -import { - AgentModelSchema, - AgentProviderEnum, - AgentStatusEnum, - ProviderSummarySchema, - parseDurationString, - resolveProviderAndModel, - sanitizePermissionRequest, - serializeSnapshotWithMetadata, - startAgentRun, - toScheduleSummary, - waitForAgentWithTimeout, -} from "./mcp-shared.js"; - -export interface AgentManagementMcpOptions { - agentManager: AgentManager; - agentStorage: AgentStorage; - terminalManager?: TerminalManager | null; - scheduleService?: ScheduleService | null; - providerRegistry?: Record | null; - paseoHome?: string; - logger: Logger; -} - -export async function createAgentManagementMcpServer( - options: AgentManagementMcpOptions, -): Promise { - const { agentManager, agentStorage, scheduleService, providerRegistry, logger } = options; - const childLogger = logger.child({ - module: "agent", - component: "agent-management-mcp", - }); - const waitTracker = new WaitForAgentTracker(logger); - const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => { - const resolvedProviderModel = resolveProviderAndModel({ - provider: params?.provider, - defaultProvider: "claude", - }); - return { - type: "new-agent" as const, - config: { - provider: resolvedProviderModel.provider, - cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(), - ...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}), - }, - }; - }; - - const server = new McpServer({ - name: "paseo-agent-management", - version: "1.0.0", - }); - - const inputSchema = { - cwd: z - .string() - .describe("Required working directory for the agent (absolute, relative, or ~)."), - title: z - .string() - .trim() - .min(1, "Title is required") - .max(60, "Title must be 60 characters or fewer") - .describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."), - provider: AgentProviderEnum.optional().describe( - "Optional agent implementation to spawn. Defaults to 'claude'.", - ), - model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"), - thinking: z.string().optional().describe("Thinking option ID"), - labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), - initialPrompt: z - .string() - .optional() - .describe("Optional task to start immediately after creation (non-blocking)."), - mode: z - .string() - .optional() - .describe("Optional session mode to configure before the first run."), - worktreeName: z - .string() - .optional() - .describe("Optional git worktree branch name (lowercase alphanumerics + hyphen)."), - baseBranch: z - .string() - .optional() - .describe("Required when worktreeName is set: the base branch to diff/merge against."), - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), - }; - - server.registerTool( - "create_agent", - { - title: "Create agent", - description: - "Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.", - inputSchema, - outputSchema: { - agentId: z.string(), - type: AgentProviderEnum, - status: AgentStatusEnum, - cwd: z.string(), - currentModeId: z.string().nullable(), - availableModes: z.array( - z.object({ - id: z.string(), - label: z.string(), - description: z.string().nullable().optional(), - }), - ), - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), - }, - }, - async (args) => { - const { - cwd, - provider, - initialPrompt, - mode, - worktreeName, - baseBranch, - background = false, - title, - model, - thinking, - labels, - } = args as { - cwd: string; - provider?: AgentProvider; - initialPrompt?: string; - mode?: string; - worktreeName?: string; - baseBranch?: string; - background?: boolean; - title: string; - model?: string; - thinking?: string; - labels?: Record; - }; - - let resolvedCwd = expandUserPath(cwd); - let worktreeConfig: WorktreeConfig | undefined; - - if (worktreeName) { - if (!baseBranch) { - throw new Error("baseBranch is required when creating a worktree"); - } - const worktree = await createAgentWorktree({ - branchName: worktreeName, - cwd: resolvedCwd, - baseBranch, - worktreeSlug: worktreeName, - paseoHome: options.paseoHome, - }); - resolvedCwd = worktree.worktreePath; - worktreeConfig = worktree; - } - - const resolvedProvider: AgentProvider = provider ?? "claude"; - const normalizedTitle = title?.trim() ?? null; - const snapshot = await agentManager.createAgent( - { - provider: resolvedProvider, - cwd: resolvedCwd, - modeId: mode, - title: normalizedTitle ?? undefined, - model, - thinkingOptionId: thinking, - }, - undefined, - labels ? { labels } : undefined, - ); - - if (worktreeConfig) { - void runAsyncWorktreeBootstrap({ - agentId: snapshot.id, - worktree: worktreeConfig, - terminalManager: options.terminalManager ?? null, - appendTimelineItem: (item) => - appendTimelineItemIfAgentKnown({ - agentManager, - agentId: snapshot.id, - item, - }), - emitLiveTimelineItem: (item) => - emitLiveTimelineItemIfAgentKnown({ - agentManager, - agentId: snapshot.id, - item, - }), - logger: childLogger, - }); - } - - const trimmedPrompt = initialPrompt?.trim(); - if (trimmedPrompt) { - scheduleAgentMetadataGeneration({ - agentManager, - agentId: snapshot.id, - cwd: snapshot.cwd, - initialPrompt: trimmedPrompt, - explicitTitle: normalizedTitle ?? undefined, - paseoHome: options.paseoHome, - logger: childLogger, - }); - - try { - agentManager.recordUserMessage(snapshot.id, trimmedPrompt, { - emitState: false, - }); - } catch (error) { - childLogger.error( - { err: error, agentId: snapshot.id }, - "Failed to record initial prompt", - ); - } - - try { - startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger); - - if (!background) { - const result = await waitForAgentWithTimeout(agentManager, snapshot.id, { - waitForActive: true, - }); - - const responseData = { - agentId: snapshot.id, - type: provider, - status: result.status, - cwd: snapshot.cwd, - currentModeId: snapshot.currentModeId, - availableModes: snapshot.availableModes, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - return { - content: [], - structuredContent: validJson, - }; - } - } catch (error) { - childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); - } - } - - return { - content: [], - structuredContent: ensureValidJson({ - agentId: snapshot.id, - type: provider, - status: snapshot.lifecycle, - cwd: snapshot.cwd, - currentModeId: snapshot.currentModeId, - availableModes: snapshot.availableModes, - lastMessage: null, - permission: null, - }), - }; - }, - ); - - server.registerTool( - "wait_for_agent", - { - title: "Wait for agent", - description: - "Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.", - inputSchema: { - agentId: z.string().describe("Agent identifier returned by the create_agent tool"), - }, - outputSchema: { - agentId: z.string(), - status: AgentStatusEnum, - permission: AgentPermissionRequestPayloadSchema.nullable(), - lastMessage: z.string().nullable(), - }, - }, - async ({ agentId }, { signal }) => { - const abortController = new AbortController(); - const cleanupFns: Array<() => void> = []; - - const cleanup = () => { - while (cleanupFns.length) { - const fn = cleanupFns.pop(); - try { - fn?.(); - } catch { - // ignore cleanup errors - } - } - }; - - const forwardExternalAbort = () => { - if (!abortController.signal.aborted) { - const reason = signal?.reason ?? new Error("wait_for_agent aborted"); - abortController.abort(reason); - } - }; - - if (signal) { - if (signal.aborted) { - forwardExternalAbort(); - } else { - signal.addEventListener("abort", forwardExternalAbort, { - once: true, - }); - cleanupFns.push(() => signal.removeEventListener("abort", forwardExternalAbort)); - } - } - - const unregister = waitTracker.register(agentId, (reason) => { - if (!abortController.signal.aborted) { - abortController.abort(new Error(reason ?? "wait_for_agent cancelled")); - } - }); - cleanupFns.push(unregister); - - try { - const result: WaitForAgentResult = await waitForAgentWithTimeout(agentManager, agentId, { - signal: abortController.signal, - }); - - const validJson = ensureValidJson({ - agentId, - status: result.status, - permission: sanitizePermissionRequest(result.permission), - lastMessage: result.lastMessage, - }); - - return { - content: [], - structuredContent: validJson, - }; - } finally { - cleanup(); - } - }, - ); - - server.registerTool( - "send_agent_prompt", - { - title: "Send agent prompt", - description: - "Send a task to a running agent. Returns immediately after the agent begins processing.", - inputSchema: { - agentId: z.string(), - prompt: z.string(), - sessionMode: z - .string() - .optional() - .describe("Optional mode to set before running the prompt."), - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), - }, - outputSchema: { - success: z.boolean(), - status: AgentStatusEnum, - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), - }, - }, - async ({ agentId, prompt, sessionMode, background = false }) => { - const snapshot = agentManager.getAgent(agentId); - if (!snapshot) { - throw new Error(`Agent ${agentId} not found`); - } - - if (agentManager.hasInFlightRun(agentId)) { - waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); - } - - if (sessionMode) { - await agentManager.setAgentMode(agentId, sessionMode); - } - - try { - agentManager.recordUserMessage(agentId, prompt, { - emitState: false, - }); - } catch (error) { - childLogger.error({ err: error, agentId }, "Failed to record user message"); - } - - startAgentRun(agentManager, agentId, prompt, childLogger, { - replaceRunning: true, - }); - - if (!background) { - const result = await waitForAgentWithTimeout(agentManager, agentId, { - waitForActive: true, - }); - - const responseData = { - success: true, - status: result.status, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - return { - content: [], - structuredContent: validJson, - }; - } - - const currentSnapshot = agentManager.getAgent(agentId); - - const responseData = { - success: true, - status: currentSnapshot?.lifecycle ?? "idle", - lastMessage: null, - permission: null, - }; - const validJson = ensureValidJson(responseData); - - return { - content: [], - structuredContent: validJson, - }; - }, - ); - - server.registerTool( - "get_agent_status", - { - title: "Get agent status", - description: - "Return the latest snapshot for an agent, including lifecycle state, capabilities, and pending permissions.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - status: AgentStatusEnum, - snapshot: AgentSnapshotPayloadSchema, - }, - }, - async ({ agentId }) => { - const snapshot = agentManager.getAgent(agentId); - if (!snapshot) { - throw new Error(`Agent ${agentId} not found`); - } - - const structuredSnapshot = await serializeSnapshotWithMetadata( - agentStorage, - snapshot, - childLogger, - ); - return { - content: [], - structuredContent: ensureValidJson({ - status: snapshot.lifecycle, - snapshot: structuredSnapshot, - }), - }; - }, - ); - - server.registerTool( - "list_agents", - { - title: "List agents", - description: "List all live agents managed by the server.", - inputSchema: {}, - outputSchema: { - agents: z.array(AgentSnapshotPayloadSchema), - }, - }, - async () => { - const snapshots = agentManager.listAgents(); - const agents = await Promise.all( - snapshots.map((snapshot) => - serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger), - ), - ); - return { - content: [], - structuredContent: ensureValidJson({ agents }), - }; - }, - ); - - server.registerTool( - "cancel_agent", - { - title: "Cancel agent run", - description: "Abort the agent's current run but keep the agent alive for future tasks.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId }) => { - const success = await agentManager.cancelAgentRun(agentId); - if (success) { - waitTracker.cancel(agentId, "Agent run cancelled"); - } - return { - content: [], - structuredContent: ensureValidJson({ success }), - }; - }, - ); - - server.registerTool( - "archive_agent", - { - title: "Archive agent", - description: - "Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId }) => { - await agentManager.archiveAgent(agentId); - waitTracker.cancel(agentId, "Agent archived"); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "kill_agent", - { - title: "Kill agent", - description: "Terminate an agent session permanently.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId }) => { - await agentManager.closeAgent(agentId); - waitTracker.cancel(agentId, "Agent terminated"); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "update_agent", - { - title: "Update agent", - description: "Update an agent name and/or labels.", - inputSchema: { - agentId: z.string(), - name: z.string().optional(), - labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId, name, labels }) => { - const trimmedName = name?.trim(); - if (trimmedName) { - const record = await agentStorage.get(agentId); - if (!record) { - throw new Error(`Agent ${agentId} not found`); - } - await agentStorage.upsert({ - ...record, - title: trimmedName, - updatedAt: new Date().toISOString(), - }); - agentManager.notifyAgentState(agentId); - } - - if (labels) { - await agentManager.setLabels(agentId, labels); - } - - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "create_schedule", - { - title: "Create schedule", - description: "Create a recurring schedule that runs on an agent or a new agent.", - inputSchema: { - prompt: z.string().trim().min(1, "prompt is required"), - every: z.string().optional(), - cron: z.string().optional(), - name: z.string().optional(), - target: z.enum(["self", "new-agent"]).optional(), - provider: AgentProviderEnum.optional().describe( - "Provider, or provider/model (for example: codex or codex/gpt-5.4).", - ), - cwd: z.string().optional(), - maxRuns: z.number().int().positive().optional(), - expiresIn: z.string().optional(), - }, - outputSchema: ScheduleSummarySchema.shape, - }, - async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const cadenceCount = Number(every !== undefined) + Number(cron !== undefined); - if (cadenceCount !== 1) { - throw new Error("Specify exactly one of every or cron"); - } - if (target === "self") { - throw new Error("target=self requires a caller agent"); - } - - const schedule = await scheduleService.create({ - prompt: prompt.trim(), - cadence: every - ? { type: "every" as const, everyMs: parseDurationString(every) } - : { type: "cron" as const, expression: cron!.trim() }, - target: resolveNewAgentScheduleTarget({ provider, cwd }), - ...(name?.trim() ? { name: name.trim() } : {}), - ...(maxRuns === undefined ? {} : { maxRuns }), - ...(expiresIn === undefined - ? {} - : { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }), - }); - - return { - content: [], - structuredContent: ensureValidJson(toScheduleSummary(schedule)), - }; - }, - ); - - server.registerTool( - "list_schedules", - { - title: "List schedules", - description: "List all schedules managed by the daemon.", - inputSchema: {}, - outputSchema: { - schedules: z.array(ScheduleSummarySchema), - }, - }, - async () => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const schedules = (await scheduleService.list()).map((schedule) => - toScheduleSummary(schedule), - ); - return { - content: [], - structuredContent: ensureValidJson({ schedules }), - }; - }, - ); - - server.registerTool( - "inspect_schedule", - { - title: "Inspect schedule", - description: "Inspect a schedule and its run history.", - inputSchema: { - id: z.string(), - }, - outputSchema: StoredScheduleSchema.shape, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const schedule = await scheduleService.inspect(id); - return { - content: [], - structuredContent: ensureValidJson(schedule), - }; - }, - ); - - server.registerTool( - "pause_schedule", - { - title: "Pause schedule", - description: "Pause an active schedule.", - inputSchema: { - id: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - await scheduleService.pause(id); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "resume_schedule", - { - title: "Resume schedule", - description: "Resume a paused schedule.", - inputSchema: { - id: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - await scheduleService.resume(id); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "delete_schedule", - { - title: "Delete schedule", - description: "Delete a schedule permanently.", - inputSchema: { - id: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - await scheduleService.delete(id); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "list_providers", - { - title: "List providers", - description: "List available agent providers and their modes.", - inputSchema: {}, - outputSchema: { - providers: z.array(ProviderSummarySchema), - }, - }, - async () => ({ - content: [], - structuredContent: ensureValidJson({ - providers: Object.values(providerRegistry ?? {}).map((provider) => ({ - id: provider.id, - label: provider.label, - modes: provider.modes.map((mode) => ({ - id: mode.id, - label: mode.label, - ...(mode.description ? { description: mode.description } : {}), - })), - })), - }), - }), - ); - - server.registerTool( - "list_models", - { - title: "List models", - description: "List models for an agent provider.", - inputSchema: { - provider: AgentProviderEnum, - }, - outputSchema: { - provider: z.string(), - models: z.array(AgentModelSchema), - }, - }, - async ({ provider }) => { - if (!providerRegistry) { - throw new Error("Provider registry is not configured"); - } - - const definition = providerRegistry[provider]; - if (!definition) { - throw new Error(`Provider ${provider} is not configured`); - } - - const models = await definition.fetchModels(); - return { - content: [], - structuredContent: ensureValidJson({ - provider, - models, - }), - }; - }, - ); - - server.registerTool( - "get_agent_activity", - { - title: "Get agent activity", - description: "Return recent agent timeline entries as a curated summary.", - inputSchema: { - agentId: z.string(), - limit: z - .number() - .optional() - .describe("Optional limit for number of activities to include (most recent first)."), - }, - outputSchema: { - agentId: z.string(), - updateCount: z.number(), - currentModeId: z.string().nullable(), - content: z.string(), - }, - }, - async ({ agentId, limit }) => { - const timeline = agentManager.getTimeline(agentId); - const snapshot = agentManager.getAgent(agentId); - - const activitiesToCurate = limit ? timeline.slice(-limit) : timeline; - - const curatedContent = curateAgentActivity(activitiesToCurate); - const totalCount = timeline.length; - const shownCount = activitiesToCurate.length; - - let countHeader: string; - if (limit && shownCount < totalCount) { - countHeader = `Showing ${shownCount} of ${totalCount} ${totalCount === 1 ? "activity" : "activities"} (limited to ${limit})`; - } else { - countHeader = `Showing all ${totalCount} ${totalCount === 1 ? "activity" : "activities"}`; - } - - const contentWithCount = `${countHeader}\n\n${curatedContent}`; - - return { - content: [], - structuredContent: ensureValidJson({ - agentId, - updateCount: timeline.length, - currentModeId: snapshot?.currentModeId ?? null, - content: contentWithCount, - }), - }; - }, - ); - - server.registerTool( - "set_agent_mode", - { - title: "Set agent session mode", - description: - "Switch the agent's session mode (plan, bypassPermissions, read-only, auto, etc.).", - inputSchema: { - agentId: z.string(), - modeId: z.string(), - }, - outputSchema: { - success: z.boolean(), - newMode: z.string(), - }, - }, - async ({ agentId, modeId }) => { - await agentManager.setAgentMode(agentId, modeId); - return { - content: [], - structuredContent: ensureValidJson({ success: true, newMode: modeId }), - }; - }, - ); - - server.registerTool( - "list_pending_permissions", - { - title: "List pending permissions", - description: - "Return all pending permission requests across all agents with the normalized payloads.", - inputSchema: {}, - outputSchema: { - permissions: z.array( - z.object({ - agentId: z.string(), - status: AgentStatusEnum, - request: AgentPermissionRequestPayloadSchema, - }), - ), - }, - }, - async () => { - const permissions = agentManager.listAgents().flatMap((agent) => { - const payload = toAgentPayload(agent); - return payload.pendingPermissions.map((request) => ({ - agentId: agent.id, - status: payload.status, - request, - })); - }); - - return { - content: [], - structuredContent: ensureValidJson({ permissions }), - }; - }, - ); - - server.registerTool( - "respond_to_permission", - { - title: "Respond to permission", - description: - "Approve or deny a pending permission request with an AgentManager-compatible response payload.", - inputSchema: { - agentId: z.string(), - requestId: z.string(), - response: AgentPermissionResponseSchema, - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId, requestId, response }) => { - await agentManager.respondToPermission(agentId, requestId, response); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - return server; -} diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 5d89a25f6..899df40f4 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -13,6 +13,9 @@ import type { } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { JsonValue } from "../json-utils.js"; +import type { Logger } from "pino"; +import { buildProviderRegistry } from "./provider-registry.js"; +import { coerceAgentProvider, toAgentPersistenceHandle } from "../persistence-hooks.js"; export type { ManagedAgent }; @@ -128,6 +131,95 @@ export function toAgentPayload( return payload; } +export function buildStoredAgentPayload( + record: StoredAgentRecord, + providerRegistry: ReturnType, + logger: Logger, +): AgentSnapshotPayload { + const defaultCapabilities = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + } as const; + + const createdAt = new Date(record.createdAt); + const updatedAt = new Date(resolveStoredAgentPayloadUpdatedAt(record)); + const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; + + const provider = coerceAgentProvider(logger, providerRegistry, record.provider, record.id); + const runtimeInfo = record.runtimeInfo + ? { + provider: coerceAgentProvider( + logger, + providerRegistry, + record.runtimeInfo.provider, + record.id, + ), + sessionId: record.runtimeInfo.sessionId, + ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model") + ? { model: record.runtimeInfo.model ?? null } + : {}), + ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "thinkingOptionId") + ? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null } + : {}), + ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "modeId") + ? { modeId: record.runtimeInfo.modeId ?? null } + : {}), + ...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}), + } + : undefined; + + return { + id: record.id, + provider, + cwd: record.cwd, + model: record.config?.model ?? null, + thinkingOptionId: record.config?.thinkingOptionId ?? null, + effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({ + runtimeInfo, + configuredThinkingOptionId: record.config?.thinkingOptionId ?? null, + }), + ...(runtimeInfo ? { runtimeInfo } : {}), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null, + status: record.lastStatus, + capabilities: defaultCapabilities, + currentModeId: record.lastModeId ?? null, + availableModes: [], + pendingPermissions: [], + persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence), + lastUsage: undefined, + lastError: undefined, + title: record.title ?? record.config?.title ?? null, + requiresAttention: record.requiresAttention ?? false, + attentionReason: record.attentionReason ?? null, + attentionTimestamp: record.attentionTimestamp ?? null, + archivedAt: record.archivedAt ?? null, + labels: record.labels, + }; +} + +export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string { + const timestamps = [record.updatedAt, record.lastActivityAt] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .map((value) => ({ + raw: value, + parsed: Date.parse(value), + })) + .filter((value) => !Number.isNaN(value.parsed)); + + if (timestamps.length === 0) { + return record.updatedAt; + } + + timestamps.sort((a, b) => b.parsed - a.parsed); + return timestamps[0].raw; +} + function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null { const serializable: SerializableAgentConfig = {}; if (Object.prototype.hasOwnProperty.call(config, "title")) { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index f2394be1b..007068afe 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; -import type { AgentStorage } from "./agent-storage.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { ProviderDefinition } from "./provider-registry.js"; type TestDeps = { @@ -29,10 +29,17 @@ function createTestDeps(): TestDeps { archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }), notifyAgentState: vi.fn(), getAgent: vi.fn(), + listAgents: vi.fn().mockReturnValue([]), + getTimeline: vi.fn().mockReturnValue([]), + resumeAgentFromPersistence: vi.fn(), + hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), + hasInFlightRun: vi.fn().mockReturnValue(false), + subscribe: vi.fn().mockReturnValue(() => {}), streamAgent: vi.fn(() => (async function* noop() {})()), respondToPermission: vi.fn(), cancelAgentRun: vi.fn(), getPendingPermissions: vi.fn(), + getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]), }; const agentStorageSpies = { @@ -40,7 +47,7 @@ function createTestDeps(): TestDeps { setTitle: vi.fn().mockResolvedValue(undefined), upsert: vi.fn().mockResolvedValue(undefined), applySnapshot: vi.fn(), - list: vi.fn(), + list: vi.fn().mockResolvedValue([]), remove: vi.fn(), }; @@ -68,6 +75,43 @@ function createProviderDefinition(overrides: Partial): Provi }; } +function createStoredRecord(overrides: Partial = {}): StoredAgentRecord { + const now = "2026-04-11T00:00:00.000Z"; + return { + id: "stored-agent", + provider: "claude", + cwd: "/tmp/stored-project", + createdAt: now, + updatedAt: now, + lastActivityAt: now, + lastUserMessageAt: null, + title: "Stored agent", + labels: {}, + lastStatus: "closed", + lastModeId: "default", + config: { + modeId: "default", + model: "claude-sonnet-4-20250514", + }, + runtimeInfo: { + provider: "claude", + sessionId: "session-123", + model: "claude-sonnet-4-20250514", + }, + features: [], + persistence: { + provider: "claude", + sessionId: "session-123", + }, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + internal: false, + archivedAt: "2026-04-12T00:00:00.000Z", + ...overrides, + }; +} + describe("create_agent MCP tool", () => { const logger = createTestLogger(); const existingCwd = process.cwd(); @@ -483,4 +527,223 @@ describe("agent snapshot MCP serialization", () => { }); expect(Array.isArray(structured.agents[0].features)).toBe(true); }); + + it("returns archived agent snapshots from storage for get_agent_status", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const record = createStoredRecord({ + id: "archived-agent", + archivedAt: "2026-04-12T00:00:00.000Z", + }); + spies.agentManager.getAgent.mockReturnValue(null); + spies.agentStorage.get.mockResolvedValue(record); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["get_agent_status"]; + const response = await tool.callback({ agentId: "archived-agent" }); + + expect(response.structuredContent).toEqual({ + status: "closed", + snapshot: expect.objectContaining({ + id: "archived-agent", + archivedAt: "2026-04-12T00:00:00.000Z", + title: "Stored agent", + status: "closed", + }), + }); + expect(spies.agentStorage.get).toHaveBeenCalledWith("archived-agent"); + }); + + it("does not expose internal stored agents from get_agent_status", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.getAgent.mockReturnValue(null); + spies.agentStorage.get.mockResolvedValue( + createStoredRecord({ + id: "internal-agent", + internal: true, + }), + ); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["get_agent_status"]; + + await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow( + "Agent internal-agent not found", + ); + }); + + it("includes stored non-archived agents in list_agents by default", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const liveAgent = { + id: "live-agent", + provider: "claude", + cwd: "/tmp/live-project", + config: {}, + runtimeInfo: undefined, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + lastUserMessageAt: null, + lifecycle: "idle", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: false, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + features: [], + pendingPermissions: new Map(), + persistence: null, + labels: {}, + attention: { requiresAttention: false }, + } as unknown as ManagedAgent; + spies.agentManager.listAgents.mockReturnValue([liveAgent]); + spies.agentStorage.list.mockResolvedValue([ + createStoredRecord({ id: "closed-agent", archivedAt: null }), + createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }), + createStoredRecord({ id: "live-agent", archivedAt: null }), + createStoredRecord({ id: "internal-agent", archivedAt: null, internal: true }), + ]); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["list_agents"]; + const response = await tool.callback({}); + + expect(response.structuredContent.agents).toEqual([ + expect.objectContaining({ id: "live-agent" }), + expect.objectContaining({ id: "closed-agent", archivedAt: null }), + ]); + }); + + it("includes archived stored agents in list_agents when requested", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const liveAgent = { + id: "live-agent", + provider: "claude", + cwd: "/tmp/live-project", + config: {}, + runtimeInfo: undefined, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + lastUserMessageAt: null, + lifecycle: "idle", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: false, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + features: [], + pendingPermissions: new Map(), + persistence: null, + labels: {}, + attention: { requiresAttention: false }, + } as unknown as ManagedAgent; + spies.agentManager.listAgents.mockReturnValue([liveAgent]); + spies.agentStorage.list.mockResolvedValue([ + createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }), + createStoredRecord({ id: "live-agent", archivedAt: "2026-04-12T00:00:00.000Z" }), + createStoredRecord({ + id: "internal-archived-agent", + archivedAt: "2026-04-12T00:00:00.000Z", + internal: true, + }), + createStoredRecord({ id: "not-archived-agent", archivedAt: null }), + ]); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["list_agents"]; + const response = await tool.callback({ includeArchived: true }); + + expect(response.structuredContent.agents).toEqual([ + expect.objectContaining({ id: "live-agent" }), + expect.objectContaining({ + id: "archived-agent", + archivedAt: "2026-04-12T00:00:00.000Z", + }), + expect.objectContaining({ + id: "not-archived-agent", + archivedAt: null, + }), + ]); + }); + + it("loads archived agents before reading get_agent_activity", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const record = createStoredRecord({ id: "archived-activity-agent" }); + const snapshot = { + id: "archived-activity-agent", + currentModeId: "default", + } as ManagedAgent; + spies.agentManager.getAgent + .mockReturnValueOnce(null) + .mockReturnValue(snapshot) + .mockReturnValue(snapshot); + spies.agentStorage.get.mockResolvedValue(record); + spies.agentManager.resumeAgentFromPersistence.mockResolvedValue(snapshot); + spies.agentManager.getTimeline.mockReturnValue([ + { + kind: "status", + timestamp: "2026-04-11T00:00:00.000Z", + text: "Agent resumed", + }, + ]); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["get_agent_activity"]; + const response = await tool.callback({ agentId: "archived-activity-agent" }); + + expect(response.structuredContent).toEqual( + expect.objectContaining({ + agentId: "archived-activity-agent", + updateCount: 1, + currentModeId: "default", + }), + ); + expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled(); + expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith( + "archived-activity-agent", + ); + }); }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index d269bd301..a71134b25 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -12,9 +12,10 @@ import { AgentPermissionResponseSchema, AgentSnapshotPayloadSchema, } from "../messages.js"; -import { toAgentPayload } from "./agent-projections.js"; +import { buildStoredAgentPayload, toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import { AgentStorage } from "./agent-storage.js"; +import { ensureAgentLoaded } from "./agent-loading.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, @@ -198,6 +199,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom version: "2.0.0", }); + const requireProviderRegistry = (): Record => { + if (!providerRegistry) { + throw new Error("Provider registry is required to load stored agent records"); + } + return providerRegistry; + }; + const resolveCallerAgent = () => { if (!callerAgentId) { return null; @@ -594,6 +602,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (notifyOnFinish && callerAgentId) { setupFinishNotification({ agentManager, + agentStorage, childAgentId: snapshot.id, callerAgentId, logger: childLogger, @@ -786,6 +795,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (notifyOnFinish && callerAgentId) { setupFinishNotification({ agentManager, + agentStorage, childAgentId: agentId, callerAgentId, logger: childLogger, @@ -849,19 +859,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, async ({ agentId }) => { const snapshot = agentManager.getAgent(agentId); - if (!snapshot) { + if (snapshot) { + const structuredSnapshot = await serializeSnapshotWithMetadata( + agentStorage, + snapshot, + childLogger, + ); + return { + content: [], + structuredContent: ensureValidJson({ + status: snapshot.lifecycle, + snapshot: structuredSnapshot, + }), + }; + } + + const record = await agentStorage.get(agentId); + if (!record || record.internal) { throw new Error(`Agent ${agentId} not found`); } - const structuredSnapshot = await serializeSnapshotWithMetadata( - agentStorage, - snapshot, + const structuredSnapshot = buildStoredAgentPayload( + record, + requireProviderRegistry(), childLogger, ); return { content: [], structuredContent: ensureValidJson({ - status: snapshot.lifecycle, + status: structuredSnapshot.status, snapshot: structuredSnapshot, }), }; @@ -873,21 +899,30 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom { title: "List agents", description: "List all live agents managed by the server.", - inputSchema: {}, + inputSchema: { + includeArchived: z.boolean().optional().default(false), + }, outputSchema: { agents: z.array(AgentSnapshotPayloadSchema), }, }, - async () => { - const snapshots = agentManager.listAgents(); - const agents = await Promise.all( - snapshots.map((snapshot) => + async ({ includeArchived }) => { + const liveSnapshots = agentManager.listAgents(); + const liveAgents = await Promise.all( + liveSnapshots.map((snapshot) => serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger), ), ); + const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id)); + const storedRecords = await agentStorage.list(); + const storedAgents = storedRecords + .filter((record) => !record.internal && !liveIds.has(record.id)) + .filter((record) => includeArchived || !record.archivedAt) + .map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger)); + return { content: [], - structuredContent: ensureValidJson({ agents }), + structuredContent: ensureValidJson({ agents: [...liveAgents, ...storedAgents] }), }; }, ); @@ -1562,6 +1597,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ agentId, limit }) => { + await ensureAgentLoaded(agentId, { + agentManager, + agentStorage, + logger: childLogger, + }); const timeline = agentManager.getTimeline(agentId); const snapshot = agentManager.getAgent(agentId); diff --git a/packages/server/src/server/agent/mcp-shared.test.ts b/packages/server/src/server/agent/mcp-shared.test.ts new file mode 100644 index 000000000..2494e17a9 --- /dev/null +++ b/packages/server/src/server/agent/mcp-shared.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { setupFinishNotification } from "./mcp-shared.js"; +import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; +import type { AgentStorage } from "./agent-storage.js"; + +describe("setupFinishNotification", () => { + it("does not notify archived callers", async () => { + let subscriber: ((event: AgentManagerEvent) => void) | null = null; + + const childAgent = { + id: "child-agent", + lifecycle: "idle", + config: { title: "Child Agent" }, + } as ManagedAgent; + + const agentManager = { + getAgent: vi.fn((agentId: string) => { + if (agentId === "child-agent") { + return childAgent; + } + if (agentId === "caller-agent") { + return { + id: "caller-agent", + lifecycle: "idle", + config: { title: "Caller Agent" }, + } as ManagedAgent; + } + return null; + }), + subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => { + subscriber = callback; + return () => { + subscriber = null; + }; + }), + hasInFlightRun: vi.fn().mockReturnValue(false), + streamAgent: vi.fn(() => (async function* noop() {})()), + replaceAgentRun: vi.fn(() => (async function* noop() {})()), + } as unknown as AgentManager; + + const agentStorage = { + get: vi.fn(async (agentId: string) => + agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null, + ), + } as unknown as AgentStorage; + + setupFinishNotification({ + agentManager, + agentStorage, + childAgentId: "child-agent", + callerAgentId: "caller-agent", + logger: createTestLogger(), + }); + + expect(subscriber).not.toBeNull(); + + childAgent.lifecycle = "running"; + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + + childAgent.lifecycle = "idle"; + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + + await vi.waitFor(() => { + expect(agentStorage.get).toHaveBeenCalledWith("caller-agent"); + }); + + expect((agentManager as any).streamAgent).not.toHaveBeenCalled(); + expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index 6065e5d6e..3edf8f6c7 100644 --- a/packages/server/src/server/agent/mcp-shared.ts +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -187,18 +187,19 @@ export function startAgentRun( interface SetupFinishNotificationParams { agentManager: AgentManager; + agentStorage: AgentStorage; childAgentId: string; callerAgentId: string; logger: Logger; } export function setupFinishNotification(params: SetupFinishNotificationParams): void { - const { agentManager, childAgentId, callerAgentId, logger } = params; + const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params; let hasSeenRunning = false; let fired = false; let unsubscribe: (() => void) | null = null; - function notify(reason: "finished" | "errored" | "needs permission"): void { + async function notify(reason: "finished" | "errored" | "needs permission"): Promise { if (fired) { return; } @@ -209,6 +210,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } + const callerRecord = await agentStorage.get(callerAgentId); + if (callerRecord?.archivedAt) { + return; + } + const title = agentManager.getAgent(childAgentId)?.config?.title ?? childAgentId; const prompt = `\nAgent ${childAgentId} (${title}) ${reason}.\n`; diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index c14881b45..4ece48b1e 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -1,6 +1,11 @@ import type { AgentManager } from "./agent/agent-manager.js"; -import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js"; +import type { + AgentPersistenceHandle, + AgentProvider, + AgentSessionConfig, +} from "./agent/agent-sdk-types.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; +import { buildProviderRegistry } from "./agent/provider-registry.js"; type LoggerLike = { child(bindings: Record): LoggerLike; @@ -8,6 +13,8 @@ type LoggerLike = { warn(...args: any[]): void; }; +const DEFAULT_AGENT_PROVIDER = "claude"; + function getLogger(logger: LoggerLike): LoggerLike { return logger.child({ module: "persistence" }); } @@ -20,6 +27,18 @@ type BuildSessionConfigOptions = { logger?: LoggerLike; }; +type RegisteredProviders = ReturnType | Iterable; + +function isProviderRegistry( + registeredProviders: RegisteredProviders, +): registeredProviders is ReturnType { + return ( + typeof registeredProviders === "object" && + registeredProviders !== null && + !(Symbol.iterator in registeredProviders) + ); +} + /** * Attach AgentStorage persistence to an AgentManager instance so every * agent_state snapshot is flushed to disk. @@ -97,3 +116,58 @@ export function extractTimestamps(record: StoredAgentRecord): { labels: record.labels, }; } + +function hasRegisteredProvider(registeredProviders: RegisteredProviders, value: string): boolean { + if (isProviderRegistry(registeredProviders)) { + return Object.prototype.hasOwnProperty.call(registeredProviders, value); + } + return new Set(registeredProviders).has(value as AgentProvider); +} + +export function isRegisteredProvider( + providerRegistry: ReturnType, + value: string, +): boolean { + return hasRegisteredProvider(providerRegistry, value); +} + +export function coerceAgentProvider( + logger: LoggerLike, + providerRegistry: ReturnType, + value: string, + agentId?: string, +): AgentProvider { + if (isRegisteredProvider(providerRegistry, value)) { + return value; + } + logger.warn( + { value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER }, + `Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`, + ); + return DEFAULT_AGENT_PROVIDER; +} + +export function toAgentPersistenceHandle( + logger: LoggerLike, + registeredProviders: RegisteredProviders, + handle: StoredAgentRecord["persistence"], +): AgentPersistenceHandle | null { + if (!handle) { + return null; + } + const provider = handle.provider; + if (!hasRegisteredProvider(registeredProviders, provider)) { + logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`); + return null; + } + if (!handle.sessionId) { + logger.warn("Ignoring persistence handle missing sessionId"); + return null; + } + return { + provider, + sessionId: handle.sessionId, + nativeHandle: handle.nativeHandle, + metadata: handle.metadata, + } satisfies AgentPersistenceHandle; +} diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 9547d3e7e..0a539667c 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -265,4 +265,62 @@ describe("ScheduleService", () => { expect(inspected.runs).toHaveLength(1); expect(inspected.runs[0]?.status).toBe("succeeded"); }); + + test("rejects archived target agents before loading them", async () => { + const manager = new AgentManager({ logger: createTestLogger() }); + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + now: () => now, + }); + + await agentStorage.upsert({ + id: "archived-agent", + provider: "claude", + cwd: tempDir, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + lastActivityAt: now.toISOString(), + lastUserMessageAt: null, + title: "Archived Agent", + labels: {}, + lastStatus: "closed", + lastModeId: "default", + config: { + modeId: "default", + }, + runtimeInfo: null, + features: [], + persistence: null, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + internal: false, + archivedAt: "2026-01-02T00:00:00.000Z", + }); + + await expect( + (service as any).executeSchedule({ + id: "schedule-1", + name: null, + prompt: "Check archived agent", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "agent", + agentId: "archived-agent", + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }), + ).rejects.toThrow("Agent archived-agent is archived"); + }); }); diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index af2dcbe56..b6b1cc557 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -2,15 +2,10 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; import type { Logger } from "pino"; import { AgentManager } from "../agent/agent-manager.js"; -import type { ManagedAgent } from "../agent/agent-manager.js"; import { AgentStorage } from "../agent/agent-storage.js"; import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js"; import { curateAgentActivity } from "../agent/activity-curator.js"; -import { - buildConfigOverrides, - buildSessionConfig, - extractTimestamps, -} from "../persistence-hooks.js"; +import { ensureAgentLoaded } from "../agent/agent-loading.js"; import { ScheduleStore } from "./store.js"; import { computeNextRunAt, validateScheduleCadence } from "./cron.js"; import type { @@ -21,7 +16,6 @@ import type { } from "./types.js"; const SCHEDULE_TICK_INTERVAL_MS = 1000; -const pendingAgentInitializations = new Map>(); function trimOptionalName(value: string | null | undefined): string | null { if (typeof value !== "string") { @@ -386,7 +380,16 @@ export class ScheduleService { private async executeSchedule(schedule: StoredSchedule): Promise { if (schedule.target.type === "agent") { - const agent = await this.ensureAgentLoaded(schedule.target.agentId); + const record = await this.agentStorage.get(schedule.target.agentId); + if (record?.archivedAt) { + throw new Error(`Agent ${schedule.target.agentId} is archived`); + } + + const agent = await ensureAgentLoaded(schedule.target.agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.logger, + }); if (this.agentManager.hasInFlightRun(agent.id)) { throw new Error(`Agent ${agent.id} already has an active run`); } @@ -435,64 +438,4 @@ export class ScheduleService { }), }; } - - private async ensureAgentLoaded(agentId: string): Promise { - const existing = this.agentManager.getAgent(agentId); - if (existing) { - return existing; - } - - const inflight = pendingAgentInitializations.get(agentId); - if (inflight) { - return inflight; - } - - const initPromise = (async () => { - const record = await this.agentStorage.get(agentId); - if (!record) { - throw new Error(`Agent not found: ${agentId}`); - } - if (record.archivedAt) { - throw new Error(`Agent ${agentId} is archived`); - } - - let snapshot: ManagedAgent; - if (record.persistence?.provider && record.persistence?.sessionId) { - snapshot = await this.agentManager.resumeAgentFromPersistence( - { - provider: record.persistence.provider as AgentSessionConfig["provider"], - sessionId: record.persistence.sessionId, - nativeHandle: record.persistence.nativeHandle, - metadata: record.persistence.metadata, - }, - buildConfigOverrides(record), - agentId, - extractTimestamps(record), - ); - } else { - const config = buildSessionConfig(record, { - validProviders: this.agentManager.getRegisteredProviderIds(), - logger: this.logger, - }); - if (!config) { - throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); - } - snapshot = await this.agentManager.createAgent(config, agentId, { - labels: record.labels, - }); - } - - await this.agentManager.hydrateTimelineFromProvider(agentId); - return this.agentManager.getAgent(agentId) ?? snapshot; - })(); - - pendingAgentInitializations.set(agentId, initPromise); - try { - return await initPromise; - } finally { - if (pendingAgentInitializations.get(agentId) === initPromise) { - pendingAgentInitializations.delete(agentId); - } - } - } } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index e141ceddf..08927cc92 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -61,9 +61,10 @@ import { } from "./voice/voice-turn-controller.js"; import { buildConfigOverrides, - buildSessionConfig, extractTimestamps, + toAgentPersistenceHandle, } from "./persistence-hooks.js"; +import { ensureAgentLoaded } from "./agent/agent-loading.js"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; @@ -83,7 +84,12 @@ import type { ManagedAgent, } from "./agent/agent-manager.js"; import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; -import { resolveEffectiveThinkingOptionId, toAgentPayload } from "./agent/agent-projections.js"; +import { + buildStoredAgentPayload, + resolveEffectiveThinkingOptionId, + resolveStoredAgentPayloadUpdatedAt, + toAgentPayload, +} from "./agent/agent-projections.js"; import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent/agent-title-limits.js"; import { appendTimelineItemIfAgentKnown, @@ -101,14 +107,14 @@ import { generateStructuredAgentResponseWithFallback, } from "./agent/agent-response-loop.js"; import type { + AgentPersistenceHandle, AgentPermissionResponse, + AgentProvider, AgentPromptContentBlock, AgentPromptInput, AgentRunOptions, AgentSessionConfig, AgentStreamEvent, - AgentProvider, - AgentPersistenceHandle, ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; @@ -187,8 +193,6 @@ import { const execAsync = promisify(exec); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); -const pendingAgentInitializations = new Map>(); -const DEFAULT_AGENT_PROVIDER = "claude"; // TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings. // Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject @@ -527,54 +531,6 @@ function convertPCMToWavBuffer( return wavBuffer; } -function isRegisteredProvider( - providerRegistry: ReturnType, - value: string, -): boolean { - return Object.prototype.hasOwnProperty.call(providerRegistry, value); -} - -function coerceAgentProvider( - logger: pino.Logger, - providerRegistry: ReturnType, - value: string, - agentId?: string, -): AgentProvider { - if (isRegisteredProvider(providerRegistry, value)) { - return value; - } - logger.warn( - { value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER }, - `Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`, - ); - return DEFAULT_AGENT_PROVIDER; -} - -function toAgentPersistenceHandle( - logger: pino.Logger, - providerRegistry: ReturnType, - handle: StoredAgentRecord["persistence"], -): AgentPersistenceHandle | null { - if (!handle) { - return null; - } - const provider = handle.provider; - if (!isRegisteredProvider(providerRegistry, provider)) { - logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`); - return null; - } - if (!handle.sessionId) { - logger.warn("Ignoring persistence handle missing sessionId"); - return null; - } - return { - provider, - sessionId: handle.sessionId, - nativeHandle: handle.nativeHandle, - metadata: handle.metadata, - } satisfies AgentPersistenceHandle; -} - /** * Session represents a single connected client session. * It owns all state management, orchestration logic, and message processing. @@ -1097,9 +1053,7 @@ export class Session { const storedRecord = await this.agentStorage.get(agent.id); const title = storedRecord?.title ?? storedRecord?.config?.title ?? null; const payload = toAgentPayload(agent, { title }); - const storedUpdatedAt = storedRecord - ? this.resolveStoredAgentPayloadUpdatedAt(storedRecord) - : null; + const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null; if (storedUpdatedAt) { const liveUpdatedAt = Date.parse(payload.updatedAt); const persistedUpdatedAt = Date.parse(storedUpdatedAt); @@ -1115,161 +1069,7 @@ export class Session { } private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload { - const defaultCapabilities = { - supportsStreaming: false, - supportsSessionPersistence: true, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: true, - } as const; - - const createdAt = new Date(record.createdAt); - const updatedAt = new Date(this.resolveStoredAgentPayloadUpdatedAt(record)); - const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; - - const provider = coerceAgentProvider( - this.sessionLogger, - this.providerRegistry, - record.provider, - record.id, - ); - const runtimeInfo = record.runtimeInfo - ? { - provider: coerceAgentProvider( - this.sessionLogger, - this.providerRegistry, - record.runtimeInfo.provider, - record.id, - ), - sessionId: record.runtimeInfo.sessionId, - ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model") - ? { model: record.runtimeInfo.model ?? null } - : {}), - ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "thinkingOptionId") - ? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null } - : {}), - ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "modeId") - ? { modeId: record.runtimeInfo.modeId ?? null } - : {}), - ...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}), - } - : undefined; - return { - id: record.id, - provider, - cwd: record.cwd, - model: record.config?.model ?? null, - thinkingOptionId: record.config?.thinkingOptionId ?? null, - effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({ - runtimeInfo, - configuredThinkingOptionId: record.config?.thinkingOptionId ?? null, - }), - ...(runtimeInfo ? { runtimeInfo } : {}), - createdAt: createdAt.toISOString(), - updatedAt: updatedAt.toISOString(), - lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null, - status: record.lastStatus, - capabilities: defaultCapabilities, - currentModeId: record.lastModeId ?? null, - availableModes: [], - pendingPermissions: [], - persistence: toAgentPersistenceHandle( - this.sessionLogger, - this.providerRegistry, - record.persistence, - ), - lastUsage: undefined, - lastError: undefined, - title: record.title ?? record.config?.title ?? null, - requiresAttention: record.requiresAttention ?? false, - attentionReason: record.attentionReason ?? null, - attentionTimestamp: record.attentionTimestamp ?? null, - archivedAt: record.archivedAt ?? null, - labels: record.labels, - }; - } - - private resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string { - const timestamps = [record.updatedAt, record.lastActivityAt] - .filter((value): value is string => typeof value === "string" && value.length > 0) - .map((value) => ({ - raw: value, - parsed: Date.parse(value), - })) - .filter((value) => !Number.isNaN(value.parsed)); - - if (timestamps.length === 0) { - return record.updatedAt; - } - - timestamps.sort((a, b) => b.parsed - a.parsed); - return timestamps[0].raw; - } - - private async ensureAgentLoaded(agentId: string): Promise { - const existing = this.agentManager.getAgent(agentId); - if (existing) { - return existing; - } - - const inflight = pendingAgentInitializations.get(agentId); - if (inflight) { - return inflight; - } - - const initPromise = (async () => { - const record = await this.agentStorage.get(agentId); - if (!record) { - throw new Error(`Agent not found: ${agentId}`); - } - - const handle = toAgentPersistenceHandle( - this.sessionLogger, - this.providerRegistry, - record.persistence, - ); - let snapshot: ManagedAgent; - if (handle) { - snapshot = await this.agentManager.resumeAgentFromPersistence( - handle, - buildConfigOverrides(record), - agentId, - extractTimestamps(record), - ); - this.sessionLogger.info( - { agentId, provider: record.provider }, - "Agent resumed from persistence", - ); - } else { - const config = buildSessionConfig(record, { - validProviders: Object.keys(this.providerRegistry), - logger: this.sessionLogger, - }); - if (!config) { - throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); - } - snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels }); - this.sessionLogger.info( - { agentId, provider: record.provider }, - "Agent created from stored config", - ); - } - - await this.agentManager.hydrateTimelineFromProvider(agentId); - return this.agentManager.getAgent(agentId) ?? snapshot; - })(); - - pendingAgentInitializations.set(agentId, initPromise); - - try { - return await initPromise; - } finally { - const current = pendingAgentInitializations.get(agentId); - if (current === initPromise) { - pendingAgentInitializations.delete(agentId); - } - } + return buildStoredAgentPayload(record, this.providerRegistry, this.sessionLogger); } // TODO: Remove once all app store clients are on >=0.1.45. @@ -2698,7 +2498,11 @@ export class Session { private async enableVoiceModeForAgent(agentId: string): Promise { const startedAt = Date.now(); this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start"); - const existing = await this.ensureAgentLoaded(agentId); + const existing = await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); this.sessionLogger.info( { agentId, elapsedMs: Date.now() - startedAt }, "enableVoiceModeForAgent.ensureAgentLoaded.done", @@ -2910,7 +2714,11 @@ export class Session { await this.unarchiveAgentState(agentId); try { - await this.ensureAgentLoaded(agentId); + await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); } catch (error) { this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt"); return { @@ -6428,7 +6236,11 @@ export class Session { : undefined; try { - const snapshot = await this.ensureAgentLoaded(msg.agentId); + const snapshot = await ensureAgentLoaded(msg.agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); const agentPayload = await this.buildAgentPayload(snapshot); let timeline = this.agentManager.fetchTimeline(msg.agentId, { @@ -6583,7 +6395,11 @@ export class Session { const agentId = resolved.agentId; await this.unarchiveAgentState(agentId); - await this.ensureAgentLoaded(agentId); + await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); this.sessionLogger.trace( { agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) }, From a1f32569230495a632adc7bb7482a1c614222619 Mon Sep 17 00:00:00 2001 From: amir <138618889+ethersh@users.noreply.github.com> Date: Thu, 16 Apr 2026 03:59:31 +0100 Subject: [PATCH 11/16] style(app): theme native scrollbars across all web views (#399) * style(app): theme native scrollbars in tool call detail views Apply CSS scrollbar-color to all ScrollViews in ToolCallDetailsContent and DiffViewer so the browser scrollbar matches the app's dark theme instead of showing the default white native scrollbar. * refactor: split useWebScrollbarStyle into .web.ts/.native.ts platform files - Native shim returns undefined without calling useUnistyles - Web variant uses properly typed WebScrollbarStyle interface instead of `as any` - Add .d.ts for TypeScript module resolution --------- Co-authored-by: Mohamed Boudra --- .../app/src/components/artifact-drawer.tsx | 11 ++++- .../app/src/components/diff-scroll.web.tsx | 5 ++- packages/app/src/components/diff-viewer.tsx | 4 ++ packages/app/src/components/file-pane.tsx | 3 ++ .../app/src/components/tool-call-details.tsx | 44 ++++++++++++++++--- .../app/src/components/ui/context-menu.tsx | 3 ++ .../app/src/components/ui/dropdown-menu.tsx | 3 ++ .../src/hooks/use-web-scrollbar-style.d.ts | 1 + .../hooks/use-web-scrollbar-style.native.ts | 5 +++ .../src/hooks/use-web-scrollbar-style.web.ts | 21 +++++++++ .../app/src/screens/startup-splash-screen.tsx | 6 ++- 11 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/hooks/use-web-scrollbar-style.d.ts create mode 100644 packages/app/src/hooks/use-web-scrollbar-style.native.ts create mode 100644 packages/app/src/hooks/use-web-scrollbar-style.web.ts diff --git a/packages/app/src/components/artifact-drawer.tsx b/packages/app/src/components/artifact-drawer.tsx index d6a43a809..55699e1eb 100644 --- a/packages/app/src/components/artifact-drawer.tsx +++ b/packages/app/src/components/artifact-drawer.tsx @@ -2,6 +2,7 @@ import { View, Text, ScrollView, Pressable, Modal } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; export interface Artifact { id: string; @@ -147,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({ })); export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { + const webScrollbarStyle = useWebScrollbarStyle(); + if (!artifact) { return null; } @@ -190,7 +193,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { {/* Content */} {artifact.type === "image" ? ( @@ -200,7 +203,11 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { ) : ( - + {content} diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx index 65c8dce2d..364c752b2 100644 --- a/packages/app/src/components/diff-scroll.web.tsx +++ b/packages/app/src/components/diff-scroll.web.tsx @@ -1,4 +1,5 @@ import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; interface DiffScrollProps { children: React.ReactNode; @@ -14,12 +15,14 @@ export function DiffScroll({ style, contentContainerStyle, }: DiffScrollProps) { + const webScrollbarStyle = useWebScrollbarStyle(); + return ( onScrollViewWidthChange(e.nativeEvent.layout.width)} > diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 7fe88e8df..ea4bb7147 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -4,6 +4,7 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { getCodeInsets } from "./code-insets"; import { isWeb } from "@/constants/platform"; @@ -23,6 +24,7 @@ export function DiffViewer({ fillAvailableHeight = false, }: DiffViewerProps) { const [scrollViewWidth, setScrollViewWidth] = React.useState(0); + const webScrollbarStyle = useWebScrollbarStyle(); if (!diffLines.length) { return ( @@ -38,6 +40,7 @@ export function DiffViewer({ styles.verticalScroll, maxHeight !== undefined && { maxHeight }, fillAvailableHeight && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.verticalContent} nestedScrollEnabled @@ -47,6 +50,7 @@ export function DiffViewer({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + style={webScrollbarStyle} contentContainerStyle={styles.horizontalContent} onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)} > diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 7341bf888..4d5be8744 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -13,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { highlightCode, darkHighlightColors, @@ -125,6 +126,7 @@ function FilePreviewBody({ const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath); const previewScrollRef = useRef(null); + const webScrollbarStyle = useWebScrollbarStyle(); const scrollbar = useWebScrollViewScrollbar(previewScrollRef, { enabled: showDesktopWebScrollbar, }); @@ -216,6 +218,7 @@ function FilePreviewBody({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + style={webScrollbarStyle} contentContainerStyle={styles.previewCodeScrollContent} > {codeLines} diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 75c94aa2e..fbc5614ee 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -6,6 +6,7 @@ import { Fonts } from "@/constants/theme"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; import { isWeb } from "@/constants/platform"; @@ -30,6 +31,7 @@ export function ToolCallDetailsContent({ showLoadingSkeleton = false, }: ToolCallDetailsContentProps) { const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300); + const webScrollbarStyle = useWebScrollbarStyle(); // Compute diff lines for edit type const diffLines = useMemo(() => { @@ -65,6 +67,7 @@ export function ToolCallDetailsContent({ styles.codeVerticalScroll, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.codeVerticalContent} nestedScrollEnabled @@ -74,6 +77,7 @@ export function ToolCallDetailsContent({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + style={webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -99,6 +103,7 @@ export function ToolCallDetailsContent({ styles.codeVerticalScroll, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.codeVerticalContent} nestedScrollEnabled @@ -108,6 +113,7 @@ export function ToolCallDetailsContent({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + style={webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -137,6 +143,7 @@ export function ToolCallDetailsContent({ styles.codeVerticalScroll, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.codeVerticalContent} nestedScrollEnabled @@ -146,6 +153,7 @@ export function ToolCallDetailsContent({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + style={webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -181,12 +189,18 @@ export function ToolCallDetailsContent({ styles.scrollArea, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.scrollContent} nestedScrollEnabled showsVerticalScrollIndicator={true} > - + {detail.content} @@ -204,12 +218,18 @@ export function ToolCallDetailsContent({ styles.scrollArea, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.scrollContent} nestedScrollEnabled showsVerticalScrollIndicator={true} > - + {detail.content} @@ -236,12 +256,18 @@ export function ToolCallDetailsContent({ style={[ styles.scrollArea, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, + webScrollbarStyle, ]} contentContainerStyle={styles.scrollContent} nestedScrollEnabled showsVerticalScrollIndicator > - + {detail.content} @@ -286,12 +312,18 @@ export function ToolCallDetailsContent({ styles.scrollArea, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.scrollContent} nestedScrollEnabled showsVerticalScrollIndicator > - + {detail.result ? `${detail.url}\n\n${detail.result}` : detail.url} @@ -356,7 +388,7 @@ export function ToolCallDetailsContent({ @@ -378,7 +410,7 @@ export function ToolCallDetailsContent({ diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index a799ba9f4..26e9e527c 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -33,6 +33,7 @@ import { Check, CheckCircle } from "lucide-react-native"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { isWeb, isNative } from "@/constants/platform"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; @@ -348,6 +349,7 @@ export function ContextMenuContent({ testID?: string; }>): ReactElement | null { const context = useContextMenuContext("ContextMenuContent"); + const webScrollbarStyle = useWebScrollbarStyle(); const isMobile = useIsCompactFormFactor(); const useMobileSheet = isMobile && mobileMode === "sheet"; const { open, setOpen, triggerRef, anchorRect } = context; @@ -537,6 +539,7 @@ export function ContextMenuContent({ {children} diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 405d6714b..c11dbce7d 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -28,6 +28,7 @@ import Animated, { Keyframe, runOnJS } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; // Action status for menu items with loading/success feedback export type ActionStatus = "idle" | "pending" | "success"; @@ -267,6 +268,7 @@ export function DropdownMenuContent({ }>): ReactElement | null { const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent"); const [modalVisible, setModalVisible] = useState(false); + const webScrollbarStyle = useWebScrollbarStyle(); const [closing, setClosing] = useState(false); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); @@ -411,6 +413,7 @@ export function DropdownMenuContent({ {children} diff --git a/packages/app/src/hooks/use-web-scrollbar-style.d.ts b/packages/app/src/hooks/use-web-scrollbar-style.d.ts new file mode 100644 index 000000000..afafdb944 --- /dev/null +++ b/packages/app/src/hooks/use-web-scrollbar-style.d.ts @@ -0,0 +1 @@ +export * from "./use-web-scrollbar-style.web"; diff --git a/packages/app/src/hooks/use-web-scrollbar-style.native.ts b/packages/app/src/hooks/use-web-scrollbar-style.native.ts new file mode 100644 index 000000000..d2f336ed5 --- /dev/null +++ b/packages/app/src/hooks/use-web-scrollbar-style.native.ts @@ -0,0 +1,5 @@ +import type { ViewStyle } from "react-native"; + +export function useWebScrollbarStyle(): ViewStyle | undefined { + return undefined; +} diff --git a/packages/app/src/hooks/use-web-scrollbar-style.web.ts b/packages/app/src/hooks/use-web-scrollbar-style.web.ts new file mode 100644 index 000000000..38d22c910 --- /dev/null +++ b/packages/app/src/hooks/use-web-scrollbar-style.web.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; +import type { ViewStyle } from "react-native"; +import { useUnistyles } from "react-native-unistyles"; + +// CSS scrollbar properties are supported by React Native Web at runtime +// but are not included in React Native's ViewStyle type definition. +interface WebScrollbarStyle extends ViewStyle { + scrollbarColor: string; + scrollbarWidth: string; +} + +export function useWebScrollbarStyle(): WebScrollbarStyle { + const { theme } = useUnistyles(); + return useMemo( + (): WebScrollbarStyle => ({ + scrollbarColor: `${theme.colors.scrollbarHandle} transparent`, + scrollbarWidth: "thin", + }), + [theme.colors.scrollbarHandle], + ); +} diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index 975c6a8ac..d3932dc2d 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -10,6 +10,7 @@ import { Fonts } from "@/constants/theme"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { isWeb } from "@/constants/platform"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; type StartupSplashScreenProps = { bootstrapState?: { @@ -161,6 +162,7 @@ const styles = StyleSheet.create((theme) => ({ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) { const { theme } = useUnistyles(); + const webScrollbarStyle = useWebScrollbarStyle(); const [daemonLogs, setDaemonLogs] = useState(null); const [logsError, setLogsError] = useState(null); const [isLoadingLogs, setIsLoadingLogs] = useState(false); @@ -282,7 +284,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps @@ -303,7 +305,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps From 165ae58f75602dc3448656ffa09f7037250588b4 Mon Sep 17 00:00:00 2001 From: Aaron Florey Date: Thu, 16 Apr 2026 13:01:56 +1000 Subject: [PATCH 12/16] fix(server): Improve OpenCode permission prompt context (#398) Surface structured permission detail for OpenCode requests so clients can show command intent and richer context instead of generic placeholders. Humanize permission titles and include scope/reason metadata to make approval decisions clearer. --- .../server/agent/providers/opencode-agent.ts | 145 +++++++++++++++++- .../opencode/event-translator.test.ts | 108 +++++++++++++ .../shared/messages.stream-parsing.test.ts | 38 +++++ packages/server/src/shared/messages.ts | 1 + 4 files changed, 289 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index c2953b422..83cc5b7f0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -36,6 +36,7 @@ import type { ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, + ToolCallDetail, ToolCallTimelineItem, } from "../agent-sdk-types.js"; import { @@ -1191,6 +1192,118 @@ function createCompactionTimelineItem( }; } +const PERMISSION_COMMAND_KEYS = ["command", "cmd", "shellCommand"] as const; +const PERMISSION_CWD_KEYS = ["cwd", "directory", "path", "workdir"] as const; +const PERMISSION_REASON_KEYS = ["reason", "purpose", "description", "message"] as const; +const PERMISSION_TITLE_BY_NAME: Record = { + external_directory: "Access external directory", + bash: "Run shell command", + read: "Read files", + read_file: "Read files", + write: "Write files", + write_file: "Write files", + create_file: "Write files", + edit: "Edit files", + apply_patch: "Edit files", + apply_diff: "Edit files", +}; + +function toHumanReadablePermissionTitle(permission: string): string { + const mapped = PERMISSION_TITLE_BY_NAME[permission]; + if (mapped) { + return mapped; + } + + const normalized = permission + .split(/[\s_-]+/) + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join(" "); + return normalized.length > 0 ? normalized : "Permission request"; +} + +function readFirstStringFromRecord( + record: Record | null, + keys: readonly string[], +): string | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = readNonEmptyString(record[key]); + if (value) { + return value; + } + } + return null; +} + +function readPermissionField( + metadata: Record | null, + keys: readonly string[], +): string | null { + const direct = readFirstStringFromRecord(metadata, keys); + if (direct) { + return direct; + } + + const nestedInput = readOpenCodeRecord(metadata?.input); + return readFirstStringFromRecord(nestedInput, keys); +} + +function buildOpenCodePermissionInput(params: { + patterns: string[]; + metadata: Record | null; + tool: Record | null; + command: string | null; +}): Record { + return { + ...(params.patterns.length > 0 ? { patterns: params.patterns } : {}), + ...(params.metadata ? { metadata: params.metadata } : {}), + ...(params.tool ? { tool: params.tool } : {}), + ...(params.command ? { command: params.command } : {}), + }; +} + +function buildOpenCodePermissionDetail(params: { + permission: string; + input: Record; + command: string | null; + cwd: string | null; +}): ToolCallDetail { + if (params.command) { + return { + type: "shell", + command: params.command, + ...(params.cwd ? { cwd: params.cwd } : {}), + }; + } + + return { + type: "unknown", + input: { + permission: params.permission, + ...params.input, + }, + output: null, + }; +} + +function buildOpenCodePermissionDescription(params: { + reason: string | null; + patterns: string[]; +}): string | undefined { + const parts: string[] = []; + if (params.reason) { + parts.push(params.reason); + } + if (params.patterns.length > 0) { + parts.push(`Scope: ${params.patterns.join(", ")}`); + } + return parts.length > 0 ? parts.join(" - ") : undefined; +} + export function translateOpenCodeEvent( event: OpenCodeEvent, state: OpenCodeEventTranslationState, @@ -1353,6 +1466,31 @@ export function translateOpenCodeEvent( break; } + const metadata = readOpenCodeRecord(event.properties.metadata); + const tool = readOpenCodeRecord(event.properties.tool); + const patterns = Array.isArray(event.properties.patterns) + ? event.properties.patterns.filter((value): value is string => typeof value === "string") + : []; + const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS); + const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS); + const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS); + const input = buildOpenCodePermissionInput({ + patterns, + metadata, + tool, + command, + }); + const detail = buildOpenCodePermissionDetail({ + permission: event.properties.permission, + input, + command, + cwd, + }); + const description = buildOpenCodePermissionDescription({ + reason, + patterns, + }); + events.push({ type: "permission_requested", provider: "opencode", @@ -1361,9 +1499,10 @@ export function translateOpenCodeEvent( provider: "opencode", name: event.properties.permission, kind: "tool", - title: event.properties.permission, - description: event.properties.patterns?.join(", "), - input: event.properties.metadata, + title: toHumanReadablePermissionTitle(event.properties.permission), + ...(description ? { description } : {}), + input, + detail, }, }); break; diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 69bd08ce6..7e5522306 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -255,6 +255,114 @@ describe("translateOpenCodeEvent", () => { ]); }); + it("humanizes permission requests and includes shell detail when command metadata exists", () => { + const state = createState(); + + const result = translateOpenCodeEvent( + { + type: "permission.asked", + properties: { + id: "perm-1", + sessionID: "session-1", + permission: "external_directory", + patterns: ["/home/user/secrets/*"], + metadata: { + command: "ls /home/user/secrets", + reason: "Need to inspect generated files", + }, + tool: { + messageID: "message-1", + callID: "call-1", + }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "permission_requested", + provider: "opencode", + request: { + id: "perm-1", + provider: "opencode", + name: "external_directory", + kind: "tool", + title: "Access external directory", + description: "Need to inspect generated files - Scope: /home/user/secrets/*", + input: { + patterns: ["/home/user/secrets/*"], + metadata: { + command: "ls /home/user/secrets", + reason: "Need to inspect generated files", + }, + tool: { + messageID: "message-1", + callID: "call-1", + }, + command: "ls /home/user/secrets", + }, + detail: { + type: "shell", + command: "ls /home/user/secrets", + }, + }, + }, + ]); + }); + + it("falls back to unknown permission detail when command metadata is absent", () => { + const state = createState(); + + const result = translateOpenCodeEvent( + { + type: "permission.asked", + properties: { + id: "perm-2", + sessionID: "session-1", + permission: "external_directory", + patterns: ["/tmp/outside/*"], + metadata: { + reason: "Need to access temporary checkout", + }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "permission_requested", + provider: "opencode", + request: { + id: "perm-2", + provider: "opencode", + name: "external_directory", + kind: "tool", + title: "Access external directory", + description: "Need to access temporary checkout - Scope: /tmp/outside/*", + input: { + patterns: ["/tmp/outside/*"], + metadata: { + reason: "Need to access temporary checkout", + }, + }, + detail: { + type: "unknown", + input: { + permission: "external_directory", + patterns: ["/tmp/outside/*"], + metadata: { + reason: "Need to access temporary checkout", + }, + }, + output: null, + }, + }, + }, + ]); + }); + it("emits usage_updated after step-finish parts", () => { const state = createState(); state.accumulatedUsage.contextWindowMaxTokens = 400_000; diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index c5698c901..4bb6692c7 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -207,6 +207,44 @@ describe("shared messages stream parsing", () => { } }); + it("parses permission request detail compatibly", () => { + const parsed = AgentStreamMessageSchema.parse({ + type: "agent_stream", + payload: { + agentId: "agent_live", + timestamp: "2026-02-08T20:10:00.000Z", + event: { + type: "permission_requested", + provider: "opencode", + request: { + id: "perm-shell-1", + provider: "opencode", + name: "external_directory", + kind: "tool", + title: "Access external directory", + input: { + command: "ls /tmp/outside", + }, + detail: { + type: "shell", + command: "ls /tmp/outside", + cwd: "/home/dev/project", + }, + }, + }, + }, + }); + + expect(parsed.payload.event.type).toBe("permission_requested"); + if (parsed.payload.event.type === "permission_requested") { + expect(parsed.payload.event.request.detail).toEqual({ + type: "shell", + command: "ls /tmp/outside", + cwd: "/home/dev/project", + }); + } + }); + it("rejects removed initialize_agent_request inbound payload", () => { const parsed = SessionInboundMessageSchema.safeParse({ type: "initialize_agent_request", diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index af2280dd7..f9a93e214 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -259,6 +259,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType ToolCallDetailPayloadSchema).optional(), suggestions: z.array(AgentPermissionUpdateSchema).optional(), actions: z.array(AgentPermissionActionSchema).optional(), metadata: z.record(z.unknown()).optional(), From 278acf5fe1b6fb223b09b850f30f8e1d8522538a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Apr 2026 10:03:20 +0700 Subject: [PATCH 13/16] docs: credit external contributors with PR/GitHub links in changelog - Add attribution format requiring PR link and contributor GitHub for each bullet - Skip attribution for core team (@boudra); changelog highlights community work - One bullet can reference multiple PRs and contributors; de-duplicate names - Document ordering: user-facing features first, then QoL, then internal-with-user-benefit --- docs/RELEASE.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index b20aaec68..54e288c70 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -139,6 +139,33 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de - **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix. - **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them. +## Changelog attribution + +Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs. + +Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors: + +```markdown +- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob)) +``` + +Rules: + +- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`. +- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`. +- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet. +- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once. +- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default. +- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username. + +## Changelog ordering + +Entries within each section (Added, Improved, Fixed) are ordered by user impact: + +1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow. +2. **Quality-of-life improvements** — polish, performance, smoother interactions. +3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal). + ## Pre-release sanity check Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs. From 5c6f175db5b54573b42b73ed2cdce878f94d0bbd Mon Sep 17 00:00:00 2001 From: Li Mu Zhi Date: Thu, 16 Apr 2026 11:06:16 +0800 Subject: [PATCH 14/16] fix: make code file preview text selectable on iOS (#447) Add `selectable` prop to the code line Text component so users can long-press to select and copy text on iOS. On web/desktop this is already the default behavior via CSS user-select. Markdown file preview has a similar issue but requires a different fix (custom rules for react-native-markdown-display), left for a follow-up. Refs #238 Related #21 Co-authored-by: muzhi1991 --- packages/app/src/components/file-pane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 4d5be8744..5f9965349 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -72,7 +72,7 @@ const CodeLine = React.memo(function CodeLine({ {String(lineNumber)} - + {tokens.map((token, index) => ( Date: Thu, 16 Apr 2026 13:14:02 +0800 Subject: [PATCH 15/16] feat: provider model freshness TTL and diagnostic UI (#426) * feat: add server-side TTL for provider snapshots and model list UI Provider snapshots cached forever, causing newly available models (e.g. OpenCode Go minimax, glm) to never appear in the picker. Server: ProviderSnapshotManager now returns stale data immediately and kicks off a background re-warm when the snapshot is older than 5 minutes. Injectable TTL/clock for testing. App: Provider settings rows show model count and are tappable to open a diagnostic sheet with a read-only model list (label + ID). * feat: add per-provider refresh for models and diagnostic * refactor: await refresh completion and clean up diagnostic sheet state - ProviderSnapshotManager.refresh is now async; session.ts awaits it so the RPC ACK reflects real completion instead of just queuing work. - Preserve models/modes/fetchedAt on entries during targeted refresh so the list no longer flashes empty mid-refresh. - Show "Updated Xs ago" next to the Models count, plus loading and error states for the list body. - Split resetSnapshotToLoading into full vs targeted branches. - Flatten nested ternary in model list rendering into renderModelsBody. - Drop redundant local refreshing state and cargo useMemo wrappers. * fix(app): widen isImeComposingKeyboardEvent type to accept optional fields TextInputKeyPressEventData has isComposing and keyCode as optional, so Pick was too narrow and broke typecheck on main. --- packages/app/public/index.html | 11 + .../src/components/adaptive-modal-sheet.tsx | 106 ++++- .../components/provider-diagnostic-sheet.tsx | 355 +++++++++++++--- .../src/components/spinning-refresh-icon.tsx | 67 +++ .../app/src/hooks/use-providers-snapshot.ts | 30 +- packages/app/src/screens/settings-screen.tsx | 104 +++-- packages/app/src/utils/keyboard-ime.ts | 9 +- packages/server/src/client/daemon-client.ts | 2 + .../agent/provider-snapshot-manager.test.ts | 401 +++++++++++++++++- .../server/agent/provider-snapshot-manager.ts | 100 ++++- packages/server/src/server/session.ts | 5 +- packages/server/src/shared/messages.ts | 1 + 12 files changed, 1044 insertions(+), 147 deletions(-) create mode 100644 packages/app/src/components/spinning-refresh-icon.tsx diff --git a/packages/app/public/index.html b/packages/app/public/index.html index 688fb0609..00c15da0c 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -51,6 +51,17 @@ [contenteditable='true'] { -webkit-app-region: no-drag !important; } + + /* Suppress the browser's default focus outline — it appears on mouse + clicks and looks out of place against our themed surfaces. Keep a + themed ring for keyboard users via :focus-visible. */ + *:focus { + outline: none; + } + *:focus-visible { + outline: 2px solid #20744A; + outline-offset: 2px; + } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index f8837fcfe..cb709a4e2 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -16,6 +16,35 @@ import { import { X } from "lucide-react-native"; import { isWeb } from "@/constants/platform"; +type EscHandler = () => void; +const escStack: EscHandler[] = []; +let escListenerAttached = false; + +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, @@ -48,10 +77,18 @@ const styles = StyleSheet.create((theme) => ({ borderBottomColor: theme.colors.surface2, }, title: { + flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, }, + headerActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginLeft: theme.spacing[3], + marginRight: theme.spacing[2], + }, closeButton: { padding: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -83,6 +120,18 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[6], gap: theme.spacing[4], }, + bottomSheetStaticContent: { + flex: 1, + padding: theme.spacing[6], + gap: theme.spacing[4], + minHeight: 0, + }, + desktopStaticContent: { + flexShrink: 1, + minHeight: 0, + padding: theme.spacing[6], + gap: theme.spacing[4], + }, })); function SheetBackground({ style }: BottomSheetBackgroundProps) { @@ -106,9 +155,11 @@ export interface AdaptiveModalSheetProps { visible: boolean; onClose: () => void; children: ReactNode; + headerActions?: ReactNode; snapPoints?: string[]; stackBehavior?: "push" | "switch" | "replace"; testID?: string; + scrollable?: boolean; } export function AdaptiveModalSheet({ @@ -116,9 +167,11 @@ export function AdaptiveModalSheet({ visible, onClose, children, + headerActions, snapPoints, stackBehavior, testID, + scrollable = true, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); @@ -157,6 +210,11 @@ export function AdaptiveModalSheet({ [], ); + useEffect(() => { + if (!isWeb || isMobile || !visible) return; + return pushEscHandler(onClose); + }, [visible, isMobile, onClose]); + if (isMobile) { return ( - {title} + + {title} + + {headerActions ? {headerActions} : null} - - {children} - + {scrollable ? ( + + {children} + + ) : ( + {children} + )} ); } @@ -199,19 +264,26 @@ export function AdaptiveModalSheet({ /> - {title} + + {title} + + {headerActions ? {headerActions} : null} - - {children} - + {scrollable ? ( + + {children} + + ) : ( + {children} + )} ); diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index d0e2fffac..418c22a63 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -1,11 +1,16 @@ -import { useCallback, useEffect, useState } from "react"; -import { View, Text, ActivityIndicator, ScrollView } from "react-native"; +import { AlertCircle, Search } from "lucide-react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; +import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; +import { isWeb } from "@/constants/platform"; +import { Fonts } from "@/constants/theme"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { resolveProviderLabel } from "@/utils/provider-definitions"; -import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { formatTimeAgo } from "@/utils/time"; +import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; interface ProviderDiagnosticSheetProps { provider: string; @@ -22,83 +27,333 @@ export function ProviderDiagnosticSheet({ }: ProviderDiagnosticSheetProps) { const { theme } = useUnistyles(); const client = useHostRuntimeClient(serverId); - const { entries: snapshotEntries } = useProvidersSnapshot(serverId); + const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId); const [diagnostic, setDiagnostic] = useState(null); const [loading, setLoading] = useState(false); + const [query, setQuery] = useState(""); const providerLabel = resolveProviderLabel(provider, snapshotEntries); + const providerEntry = useMemo( + () => snapshotEntries?.find((entry) => entry.provider === provider), + [snapshotEntries, provider], + ); + const models = providerEntry?.models ?? []; + const providerSnapshotRefreshing = providerEntry?.status === "loading"; + const providerErrorMessage = + providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null; + const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading; - const fetchDiagnostic = useCallback(async () => { - if (!client || !provider) return; + const [clockTick, setClockTick] = useState(0); + useEffect(() => { + if (!visible) return; + const id = setInterval(() => setClockTick((t) => t + 1), 10_000); + return () => clearInterval(id); + }, [visible]); + const fetchedAtLabel = useMemo(() => { + if (!providerEntry?.fetchedAt) return null; + return formatTimeAgo(new Date(providerEntry.fetchedAt)); + // clockTick triggers re-computation on timer + }, [providerEntry?.fetchedAt, clockTick]); - setLoading(true); - setDiagnostic(null); + const q = query.trim().toLowerCase(); + const filteredModels = q + ? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)) + : models; - try { - const result = await client.getProviderDiagnostic(provider as AgentProvider); - setDiagnostic(result.diagnostic); - } catch (err) { - setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); - } finally { - setLoading(false); - } - }, [client, provider]); + const fetchDiagnostic = useCallback( + async (options?: { keepCurrent?: boolean }) => { + if (!client || !provider) return; + + setLoading(true); + if (!options?.keepCurrent) { + setDiagnostic(null); + } + + try { + const result = await client.getProviderDiagnostic(provider as AgentProvider); + setDiagnostic(result.diagnostic); + } catch (err) { + setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); + } finally { + setLoading(false); + } + }, + [client, provider], + ); + + const handleRefresh = useCallback(() => { + void refresh([provider as AgentProvider]); + void fetchDiagnostic({ keepCurrent: true }); + }, [fetchDiagnostic, provider, refresh]); useEffect(() => { if (visible) { fetchDiagnostic(); } else { setDiagnostic(null); + setQuery(""); } }, [visible, fetchDiagnostic]); + function renderModelsBody() { + if (models.length === 0 && providerSnapshotRefreshing) { + return ( + + + Loading models… + + ); + } + if (models.length === 0 && providerErrorMessage) { + return ( + + + {providerErrorMessage} + + ); + } + if (models.length === 0) { + return ( + + No models detected. + + ); + } + if (filteredModels.length === 0) { + return ( + + + No models match your search + + ); + } + return filteredModels.map((model: AgentModelDefinition, index) => ( + 0 && sheetStyles.modelRowBorder]}> + + {model.label} + + + {model.id} + + + )); + } + return ( - {loading ? ( - - - Fetching diagnostic… - - ) : diagnostic ? ( - [ + sheetStyles.iconButton, + (hovered || pressed) && sheetStyles.iconButtonHovered, + refreshInFlight ? sheetStyles.disabled : null, + ]} + accessibilityRole="button" + accessibilityLabel={`Refresh ${providerLabel}`} > - - {diagnostic} - + + + } + > + + Diagnostic + + {loading && !diagnostic ? ( + + + Running diagnostic… + + ) : diagnostic ? ( + + + + {diagnostic} + + + + ) : ( + + No diagnostic available. + + )} + + + + + + Models + + {models.length} + {fetchedAtLabel ? ( + <> + · + Updated {fetchedAtLabel} + + ) : null} + + + {models.length > 0 ? ( + + + + + ) : null} + + {renderModelsBody()} - ) : null} + ); } const sheetStyles = StyleSheet.create((theme) => ({ - loadingContainer: { + section: { + gap: theme.spacing[2], + }, + sectionTitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, + mutedText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + iconButton: { + width: 30, + height: 30, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + iconButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + disabled: { + opacity: 0.5, + }, + codeBlock: { + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.base, + backgroundColor: theme.colors.surface2, + overflow: "hidden", + maxHeight: 180, + }, + codeScroll: { + maxHeight: 180, + }, + codeContent: { + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + }, + codeText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + lineHeight: 18, + }, + codeBlockLoading: { + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[3], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + modelsSection: { + flex: 1, + minHeight: 0, + gap: theme.spacing[2], + }, + modelsHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + modelsHeaderMeta: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + metaDot: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + countText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + searchContainer: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + paddingHorizontal: theme.spacing[3], + }, + searchInput: { + flex: 1, + paddingVertical: theme.spacing[2], + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + modelsScroll: { + flex: 1, + minHeight: 0, + }, + modelsScrollContent: { + paddingBottom: theme.spacing[2], + }, + modelRow: { + paddingVertical: theme.spacing[3], + }, + modelRowBorder: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + modelLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + modelId: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + fontFamily: Fonts.mono, + marginTop: 2, + }, + emptyState: { paddingVertical: theme.spacing[6], alignItems: "center", gap: theme.spacing[2], }, - loadingText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - scrollContainer: { - flex: 1, - }, - scrollContent: { - paddingBottom: theme.spacing[4], - }, - diagnosticText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - fontFamily: "monospace", - lineHeight: theme.fontSize.sm * 1.6, - }, })); diff --git a/packages/app/src/components/spinning-refresh-icon.tsx b/packages/app/src/components/spinning-refresh-icon.tsx new file mode 100644 index 000000000..9060ac0a5 --- /dev/null +++ b/packages/app/src/components/spinning-refresh-icon.tsx @@ -0,0 +1,67 @@ +import { useEffect } from "react"; +import { RefreshCw } from "lucide-react-native"; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +interface SpinningRefreshIconProps { + spinning: boolean; + size: number; + color: string; +} + +export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) { + const rotation = useSharedValue(0); + + useEffect(() => { + if (spinning) { + rotation.value = 0; + rotation.value = withRepeat( + withTiming(360, { + duration: 1000, + easing: Easing.linear, + }), + -1, + false, + ); + return; + } + + cancelAnimation(rotation); + const remainder = rotation.value % 360; + if (Math.abs(remainder) < 0.001) { + rotation.value = 0; + return; + } + + rotation.value = withTiming(360, { + duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)), + easing: Easing.linear, + }); + }, [rotation, spinning]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + return ( + + + + ); +} diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index afee8096b..af3878a77 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; import type { DaemonClient } from "@server/client/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionForServer } from "./use-session-directory"; @@ -14,9 +14,10 @@ interface UseProvidersSnapshotResult { entries: ProviderSnapshotEntry[] | undefined; isLoading: boolean; isFetching: boolean; + isRefreshing: boolean; error: string | null; supportsSnapshot: boolean; - refresh: () => void; + refresh: (providers?: AgentProvider[]) => Promise; invalidate: () => void; } @@ -43,6 +44,16 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps }, }); + const refreshMutation = useMutation({ + mutationFn: async (providers?: AgentProvider[]) => { + if (!client) { + return; + } + await client.refreshProvidersSnapshot({ providers }); + }, + }); + const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation; + useEffect(() => { if (!supportsSnapshot || !client || !isConnected || !serverId) { return; @@ -60,12 +71,12 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps }); }, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]); - const refresh = useCallback(() => { - if (!client) { - return; - } - void client.refreshProvidersSnapshot(); - }, [client]); + const refresh = useCallback( + async (providers?: AgentProvider[]) => { + await refreshSnapshot(providers); + }, + [refreshSnapshot], + ); const invalidate = useCallback(() => { void queryClient.invalidateQueries({ queryKey }); @@ -75,6 +86,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps entries: snapshotQuery.data?.entries ?? undefined, isLoading: snapshotQuery.isLoading, isFetching: snapshotQuery.isFetching, + isRefreshing, error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null, supportsSnapshot, refresh, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 638d7a9a1..655363eb1 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -70,6 +70,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getProviderIcon } from "@/components/provider-icons"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; +import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; import { StatusBadge } from "@/components/ui/status-badge"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { isWeb } from "@/constants/platform"; @@ -521,9 +522,11 @@ interface ProvidersSectionProps { function ProvidersSection({ routeServerId }: ProvidersSectionProps) { const { theme } = useUnistyles(); const isConnected = useHostRuntimeIsConnected(routeServerId); - const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId); + const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(routeServerId); const [diagnosticProvider, setDiagnosticProvider] = useState(null); const providerDefinitions = buildProviderDefinitions(entries); + const providerRefreshInFlight = + isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false); const hasServer = routeServerId.length > 0; @@ -534,18 +537,25 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) { Providers {hasServer && isConnected ? ( { + void refresh(); + }} + disabled={providerRefreshInFlight} + hitSlop={8} + style={({ hovered, pressed }) => [ + settingsStyles.sectionHeaderLink, + styles.providerRefreshButton, + (hovered || pressed) && styles.providerRefreshButtonHovered, + providerRefreshInFlight ? styles.providerRefreshButtonDisabled : null, + ]} + accessibilityRole="button" + accessibilityLabel="Refresh providers" > - - Refresh - + ) : null} @@ -570,8 +580,15 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) { ? entry.error.trim() : null; + const modelCount = entry?.models?.length ?? 0; + return ( - + setDiagnosticProvider(def.id)} + accessibilityRole="button" + > ) : null} + {status === "ready" && modelCount > 0 ? ( + + {modelCount === 1 ? "1 model" : `${modelCount} models`} + + ) : null} - - - - - + + ); })} @@ -1952,6 +1965,18 @@ const styles = StyleSheet.create((theme) => ({ formButtonPrimaryText: { color: theme.colors.palette.white, }, + providerRefreshButton: { + width: 30, + height: 30, + borderRadius: theme.borderRadius.full, + justifyContent: "center", + }, + providerRefreshButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + providerRefreshButtonDisabled: { + opacity: 0.5, + }, // Audio settings card audioCard: { overflow: "hidden", @@ -1980,11 +2005,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, marginTop: theme.spacing[1], }, - providerActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/utils/keyboard-ime.ts b/packages/app/src/utils/keyboard-ime.ts index 7d1d7edfc..0e94262f0 100644 --- a/packages/app/src/utils/keyboard-ime.ts +++ b/packages/app/src/utils/keyboard-ime.ts @@ -1,5 +1,6 @@ -export function isImeComposingKeyboardEvent( - event: Pick, -): boolean { - return event.isComposing || event.keyCode === 229; +export function isImeComposingKeyboardEvent(event: { + isComposing?: boolean; + keyCode?: number; +}): boolean { + return Boolean(event.isComposing) || event.keyCode === 229; } diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 16edd9038..f0b5abe6c 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -2756,6 +2756,7 @@ export class DaemonClient { async refreshProvidersSnapshot(options?: { cwd?: string; + providers?: AgentProvider[]; requestId?: string; }): Promise { return this.sendCorrelatedSessionRequest({ @@ -2763,6 +2764,7 @@ export class DaemonClient { message: { type: "refresh_providers_snapshot_request", cwd: options?.cwd, + providers: options?.providers, }, responseType: "refresh_providers_snapshot_response", timeout: 5000, diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 43d0cd095..41cc52ac7 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -45,6 +45,7 @@ const TEST_CAPABILITIES = { } as const; describe("ProviderSnapshotManager", () => { + const ttlMs = 5 * 60 * 1_000; const projectCwd = resolve("/tmp/project"); const projectACwd = resolve("/tmp/project-a"); const projectBCwd = resolve("/tmp/project-b"); @@ -269,7 +270,7 @@ describe("ProviderSnapshotManager", () => { ); }); - manager.refresh(projectCwd); + manager.refresh({ cwd: projectCwd }); expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", @@ -291,6 +292,155 @@ describe("ProviderSnapshotManager", () => { manager.destroy(); }); + test("refresh with providers only re-fetches matching providers", async () => { + const codexFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const claudeFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("claude", "sonnet-4")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: codexFetchModels, + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: claudeFetchModels, + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( + "sonnet-4", + ); + }); + + manager.refresh({ cwd: projectCwd, providers: ["codex"] }); + + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("loading"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")).toMatchObject({ + provider: "claude", + status: "ready", + models: [createModel("claude", "sonnet-4")], + }); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + expect(codexFetchModels).toHaveBeenCalledTimes(2); + expect(claudeFetchModels).toHaveBeenCalledTimes(1); + + manager.destroy(); + }); + + test("refresh treats an empty providers list as a full refresh", async () => { + const codexFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const claudeFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("claude", "sonnet-4")]) + .mockResolvedValueOnce([createModel("claude", "sonnet-4.5")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: codexFetchModels, + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: claudeFetchModels, + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready"); + }); + + manager.refresh({ cwd: projectCwd, providers: [] }); + + expect(manager.getSnapshot(projectCwd)).toEqual([ + { + provider: "codex", + status: "loading", + label: "codex", + description: "codex test provider", + defaultModeId: null, + }, + { + provider: "claude", + status: "loading", + label: "claude", + description: "claude test provider", + defaultModeId: null, + }, + ]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( + "sonnet-4.5", + ); + }); + + expect(codexFetchModels).toHaveBeenCalledTimes(2); + expect(claudeFetchModels).toHaveBeenCalledTimes(2); + + manager.destroy(); + }); + + test("refresh ignores provider filters that are not in the registry", async () => { + const codexFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: codexFetchModels, + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + }); + + manager.refresh({ cwd: projectCwd, providers: ["zai"] }); + + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "gpt-5.1")], + }); + expect(codexFetchModels).toHaveBeenCalledTimes(1); + + manager.destroy(); + }); + test("refresh during an in-flight refresh is a no-op", async () => { const fetchModels = deferred(); const fetchModes = deferred(); @@ -305,7 +455,7 @@ describe("ProviderSnapshotManager", () => { const changes: ProviderSnapshotEntry[][] = []; manager.on("change", (entries) => changes.push(entries)); - manager.refresh(projectCwd); + manager.refresh({ cwd: projectCwd }); expect(manager.getSnapshot(projectCwd)).toEqual([ { @@ -317,9 +467,9 @@ describe("ProviderSnapshotManager", () => { }, ]); - manager.refresh(projectCwd); - manager.refresh(projectCwd); - manager.refresh(projectCwd); + manager.refresh({ cwd: projectCwd }); + manager.refresh({ cwd: projectCwd }); + manager.refresh({ cwd: projectCwd }); expect(changes).toHaveLength(1); expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); @@ -342,6 +492,247 @@ describe("ProviderSnapshotManager", () => { manager.destroy(); }); + test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => { + let now = 1_000; + const fetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => fetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + now += ttlMs + 1; + + const staleSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(staleSnapshot, "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "gpt-5.1")], + modes: [createMode("auto")], + }); + + await vi.waitFor(() => { + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + }); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + manager.destroy(); + }); + + test("getSnapshot does not trigger a second warm-up while a stale re-warm is already in flight", async () => { + let now = 2_000; + const staleRefreshModels = deferred(); + const fetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockImplementationOnce(async () => staleRefreshModels.promise); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => fetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + now += ttlMs + 1; + + const firstStaleSnapshot = manager.getSnapshot(projectCwd); + const secondStaleSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(firstStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); + expect(getProviderEntry(secondStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); + + await vi.waitFor(() => { + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + }); + + staleRefreshModels.resolve([createModel("codex", "gpt-5.2")]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + + manager.destroy(); + }); + + test("getSnapshot does not re-warm when the cached snapshot is still fresh", async () => { + let now = 3_000; + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => [createModel("codex", "gpt-5.1")], + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + }); + + now += ttlMs - 1; + + const freshSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(freshSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); + + manager.destroy(); + }); + + test("getSnapshot re-warms snapshots in error and unavailable states after TTL", async () => { + let now = 4_000; + const unavailableFetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValue([createModel("codex", "gpt-5.2")]); + const unavailableIsAvailable = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const errorFetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockRejectedValueOnce(new Error("model lookup failed")) + .mockResolvedValueOnce([createModel("claude", "sonnet")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + isAvailable: unavailableIsAvailable, + fetchModels: async (cwd) => unavailableFetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: async (cwd) => errorFetchModels(cwd), + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe( + "unavailable", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("error"); + }); + + now += ttlMs + 1; + + const staleSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(staleSnapshot, "codex")?.status).toBe("unavailable"); + expect(getProviderEntry(staleSnapshot, "claude")?.status).toBe("error"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready"); + }); + + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( + "sonnet", + ); + + manager.destroy(); + }); + + test("getSnapshot respects an injected TTL", async () => { + let now = 5_000; + const customTtlMs = 100; + const fetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => fetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs: customTtlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + now += customTtlMs - 1; + manager.getSnapshot(projectCwd); + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); + + now += 2; + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + }); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + manager.destroy(); + }); + test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => { const codexModels = deferred(); const { registry, handles } = createRegistry([ diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 150cde42b..51586d5f1 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -7,19 +7,35 @@ import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js" import type { ProviderDefinition } from "./provider-registry.js"; const DEFAULT_CWD_KEY = "__default__"; +const DEFAULT_SNAPSHOT_TTL_MS = 300_000; type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void; +type ProviderSnapshotManagerOptions = { + ttlMs?: number; + now?: () => number; +}; +type ProviderSnapshotRefreshOptions = { + cwd?: string; + providers?: AgentProvider[]; +}; export class ProviderSnapshotManager { private readonly snapshots = new Map>(); + private readonly lastCheckedAts = new Map(); private readonly warmUps = new Map>(); private readonly events = new EventEmitter(); private destroyed = false; + private readonly ttlMs: number; + private readonly now: () => number; constructor( private readonly providerRegistry: Record, private readonly logger: Logger, - ) {} + options: ProviderSnapshotManagerOptions = {}, + ) { + this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS; + this.now = options.now ?? Date.now; + } getSnapshot(cwd?: string): ProviderSnapshotEntry[] { const cwdKey = normalizeCwdKey(cwd); @@ -29,17 +45,24 @@ export class ProviderSnapshotManager { void this.warmUp(cwd); return entriesToArray(loadingEntries); } + if (this.shouldRevalidate(cwdKey)) { + void this.warmUp(cwd); + } return entriesToArray(entries); } - refresh(cwd?: string): void { + async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise { + const { cwd } = options; const cwdKey = normalizeCwdKey(cwd); - if (this.warmUps.has(cwdKey)) { + const inFlight = this.warmUps.get(cwdKey); + if (inFlight) { + await inFlight; return; } - this.resetSnapshotToLoading(cwdKey); + const providers = this.resolveRefreshProviders(options.providers); + this.resetSnapshotToLoading(cwdKey, providers); this.emitChange(cwdKey); - void this.warmUp(cwd); + await this.warmUp(cwd, providers); } on(event: "change", listener: ProviderSnapshotChangeListener): this { @@ -56,6 +79,7 @@ export class ProviderSnapshotManager { this.destroyed = true; this.events.removeAllListeners(); this.snapshots.clear(); + this.lastCheckedAts.clear(); this.warmUps.clear(); } @@ -74,16 +98,21 @@ export class ProviderSnapshotManager { return entries; } - private async warmUp(cwd?: string): Promise { + private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise { const cwdKey = normalizeCwdKey(cwd); const inFlight = this.warmUps.get(cwdKey); if (inFlight) { return inFlight; } + const providersToRefresh = providers ?? this.getProviderIds(); const warmUpPromise = Promise.allSettled( - this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)), - ).then(() => undefined); + providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)), + ).then(() => { + if (!providers) { + this.lastCheckedAts.set(cwdKey, this.now()); + } + }); this.warmUps.set(cwdKey, warmUpPromise); @@ -107,13 +136,6 @@ export class ProviderSnapshotManager { } const snapshot = this.getOrCreateSnapshot(cwdKey); - snapshot.set(provider, { - provider, - status: "loading", - label: definition.label, - description: definition.description, - defaultModeId: definition.defaultModeId, - }); try { const client = definition.createClient(this.logger); @@ -174,6 +196,17 @@ export class ProviderSnapshotManager { this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey)); } + private shouldRevalidate(cwdKey: string): boolean { + if (this.warmUps.has(cwdKey)) { + return false; + } + const lastCheckedAt = this.lastCheckedAts.get(cwdKey); + if (lastCheckedAt === undefined) { + return false; + } + return this.now() - lastCheckedAt > this.ttlMs; + } + private getOrCreateSnapshot(cwdKey: string): Map { const existing = this.snapshots.get(cwdKey); if (existing) { @@ -185,11 +218,31 @@ export class ProviderSnapshotManager { return created; } - private resetSnapshotToLoading(cwdKey: string): Map { + private resetSnapshotToLoading( + cwdKey: string, + providers?: AgentProvider[], + ): Map { const snapshot = this.getOrCreateSnapshot(cwdKey); - snapshot.clear(); - for (const [provider, entry] of this.createLoadingEntries()) { - snapshot.set(provider, entry); + const loadingEntries = this.createLoadingEntries(); + + if (!providers) { + snapshot.clear(); + for (const [provider, entry] of loadingEntries) { + snapshot.set(provider, entry); + } + return snapshot; + } + + for (const provider of providers) { + const loadingEntry = loadingEntries.get(provider); + if (!loadingEntry) continue; + const existing = snapshot.get(provider); + snapshot.set(provider, { + ...loadingEntry, + models: existing?.models, + modes: existing?.modes, + fetchedAt: existing?.fetchedAt, + }); } return snapshot; } @@ -197,6 +250,15 @@ export class ProviderSnapshotManager { private getProviderIds(): AgentProvider[] { return Object.keys(this.providerRegistry) as AgentProvider[]; } + + private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined { + if (!providers || providers.length === 0) { + return undefined; + } + + const providerIds = new Set(this.getProviderIds()); + return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider)); + } } function normalizeCwdKey(cwd?: string): string { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 08927cc92..039e678fe 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3218,7 +3218,10 @@ export class Session { private async handleRefreshProvidersSnapshotRequest( msg: Extract, ): Promise { - this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined); + await this.providerSnapshotManager?.refresh({ + cwd: msg.cwd ? expandTilde(msg.cwd) : undefined, + providers: msg.providers, + }); this.emit({ type: "refresh_providers_snapshot_response", payload: { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index f9a93e214..42d3e8e2a 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -852,6 +852,7 @@ export const GetProvidersSnapshotRequestMessageSchema = z.object({ export const RefreshProvidersSnapshotRequestMessageSchema = z.object({ type: z.literal("refresh_providers_snapshot_request"), cwd: z.string().optional(), + providers: z.array(AgentProviderSchema).optional(), requestId: z.string(), }); From 11b407c804a462f840a6902c971ba7c20b3c453e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Apr 2026 13:14:00 +0700 Subject: [PATCH 16/16] fix(app): hide branch icon on non-git workspace headers Gate the GitBranch icon behind isGitCheckout, and keep the header skeleton visible until the checkout status query resolves so the icon doesn't pop in and cause layout shift. --- packages/app/src/components/branch-switcher.tsx | 2 +- .../app/src/screens/workspace/workspace-screen.tsx | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index aeef7b346..335cbe683 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -45,7 +45,7 @@ export function BranchSwitcher({ const titleContent = ( <> - + {isGitCheckout ? : null} {title} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 6054887d6..7deb16624 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -731,12 +731,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) }; }, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]); + const isCheckoutQueryEnabled = + Boolean(client && isConnected) && + normalizedWorkspaceId.length > 0 && + isAbsolutePath(normalizedWorkspaceId); const checkoutQuery = useQuery({ queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId), - enabled: - Boolean(client && isConnected) && - normalizedWorkspaceId.length > 0 && - isAbsolutePath(normalizedWorkspaceId), + enabled: isCheckoutQueryEnabled, queryFn: async () => { if (!client) { throw new Error("Host is not connected"); @@ -745,6 +746,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) }, staleTime: 15_000, }); + const isCheckoutStatusLoading = + isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError; const workspaceDescriptor = useSessionStore( (state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null, @@ -758,7 +761,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const workspaceHeader = workspaceDescriptor ? resolveWorkspaceHeader({ workspace: workspaceDescriptor }) : null; - const isWorkspaceHeaderLoading = workspaceHeader === null; + const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading; const workspaceHeaderTitle = workspaceHeader?.title ?? ""; const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? ""; const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(