From 809e5fbbdce7c0091ddf999d76be4a6de8ff1de8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 14:53:10 +0700 Subject: [PATCH 1/8] feat(app): Enter key confirms and sends dictation When dictating and focused on an agent, pressing Enter now confirms the dictation and sends the message, matching the behavior of clicking the submit button. --- .../app/src/components/agent-input-area.tsx | 18 +++++++++++++++ packages/app/src/components/message-input.tsx | 22 ++++++++++++++----- .../app/src/hooks/use-keyboard-shortcuts.ts | 10 +++++++++ packages/app/src/keyboard/actions.ts | 1 + .../keyboard/keyboard-action-dispatcher.ts | 4 ++++ .../app/src/keyboard/keyboard-shortcuts.ts | 8 +++++++ 6 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 4f4d6c441..2c4c7261d 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -74,6 +74,8 @@ interface AgentInputAreaProps { autoFocus?: boolean; /** Callback to expose the addImages function to parent components */ onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void; + /** Callback to expose a focus function to parent components (desktop only). */ + onFocusInput?: (focus: () => void) => void; /** Optional draft context for listing commands before an agent exists. */ commandDraftConfig?: DraftCommandConfig; /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ @@ -103,6 +105,7 @@ export function AgentInputArea({ clearDraft, autoFocus = false, onAddImages, + onFocusInput, commandDraftConfig, onMessageSent, onComposerHeightChange, @@ -208,6 +211,15 @@ export function AgentInputArea({ onAddImages?.(addImages); }, [addImages, onAddImages]); + const focusInput = useCallback(() => { + if (Platform.OS !== "web") return; + messageInputRef.current?.focus(); + }, []); + + useEffect(() => { + onFocusInput?.(focusInput); + }, [focusInput, onFocusInput]); + const submitMessage = useCallback( async (text: string, images?: ImageAttachment[]) => { onMessageSent?.(); @@ -402,6 +414,10 @@ export function AgentInputArea({ } switch (action.id) { + case "message-input.send": + return messageInputRef.current?.runKeyboardAction("send") ?? false; + case "message-input.dictation-confirm": + return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false; case "message-input.focus": if (Platform.OS !== "web") { messageInputRef.current?.focus(); @@ -440,8 +456,10 @@ export function AgentInputArea({ handlerId: keyboardHandlerIdRef.current, actions: [ "message-input.focus", + "message-input.send", "message-input.dictation-toggle", "message-input.dictation-cancel", + "message-input.dictation-confirm", "message-input.voice-toggle", "message-input.voice-mute-toggle", ], diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 4794ea5e1..c94bd8fbd 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -100,7 +100,7 @@ export interface MessageInputProps { export interface MessageInputRef { focus: () => void; blur: () => void; - runKeyboardAction: (action: MessageInputKeyboardActionKind) => void; + runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean; /** * Web-only: return the underlying DOM element for focus assertions/retries. * May return null if not mounted or on native. @@ -242,26 +242,35 @@ export const MessageInput = forwardRef(funct runKeyboardAction: (action) => { if (action === "focus") { textInputRef.current?.focus(); - return; + return true; + } + + if (action === "send" || action === "dictation-confirm") { + if (isDictatingRef.current) { + sendAfterTranscriptRef.current = true; + confirmDictation(); + return true; + } + return false; } if (action === "voice-toggle") { handleToggleRealtimeVoiceShortcut(); - return; + return true; } if (action === "voice-mute-toggle") { if (isRealtimeVoiceForCurrentAgent) { voice?.toggleMute(); } - return; + return true; } if (action === "dictation-cancel") { if (isDictatingRef.current) { cancelDictation(); } - return; + return true; } if (action === "dictation-toggle") { @@ -271,7 +280,10 @@ export const MessageInput = forwardRef(funct } else { void startDictationIfAvailable(); } + return true; } + + return false; }, getNativeElement: () => { if (!IS_WEB) return null; diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index c014f828c..edbc739f1 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -129,6 +129,11 @@ export function useKeyboardShortcuts({ id: "message-input.focus", scope: "message-input", }); + case "send": + return keyboardActionDispatcher.dispatch({ + id: "message-input.send", + scope: "message-input", + }); case "dictation-toggle": return keyboardActionDispatcher.dispatch({ id: "message-input.dictation-toggle", @@ -139,6 +144,11 @@ export function useKeyboardShortcuts({ id: "message-input.dictation-cancel", scope: "message-input", }); + case "dictation-confirm": + return keyboardActionDispatcher.dispatch({ + id: "message-input.dictation-confirm", + scope: "message-input", + }); case "voice-toggle": return keyboardActionDispatcher.dispatch({ id: "message-input.voice-toggle", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 2653ac323..e9b8af965 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -11,6 +11,7 @@ export type MessageInputKeyboardActionKind = | "queue" | "dictation-toggle" | "dictation-cancel" + | "dictation-confirm" | "voice-toggle" | "voice-mute-toggle"; diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 817ac7101..90cbcf117 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -2,8 +2,10 @@ export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "work export type KeyboardActionId = | "message-input.focus" + | "message-input.send" | "message-input.dictation-toggle" | "message-input.dictation-cancel" + | "message-input.dictation-confirm" | "message-input.voice-toggle" | "message-input.voice-mute-toggle" | "workspace.tab.new" @@ -27,8 +29,10 @@ export type KeyboardActionId = export type KeyboardActionDefinition = | { id: "message-input.focus"; scope: KeyboardActionScope } + | { id: "message-input.send"; scope: KeyboardActionScope } | { id: "message-input.dictation-toggle"; scope: KeyboardActionScope } | { id: "message-input.dictation-cancel"; scope: KeyboardActionScope } + | { id: "message-input.dictation-confirm"; scope: KeyboardActionScope } | { id: "message-input.voice-toggle"; scope: KeyboardActionScope } | { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope } | { id: "workspace.tab.new"; scope: KeyboardActionScope } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 2540e8549..bc9b65122 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -860,6 +860,14 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + { + id: "message-input-dictation-confirm-enter", + action: "message-input.action", + combo: "Enter", + when: { commandCenter: false, terminal: false }, + payload: { type: "message-input", kind: "dictation-confirm" }, + }, + { id: "message-input-voice-mute-toggle", action: "message-input.action", From 84638ecd9ccd7e1533093a5f3859337bc0719ebb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 14:54:03 +0700 Subject: [PATCH 2/8] feat(app): submit question card answer on Enter key press --- packages/app/src/components/question-form-card.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index f5d711fdc..771b8d4fa 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -119,6 +119,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi }); function handleSubmit() { + if (!allAnswered || isResponding) return; setRespondingAction("submit"); const answers: Record = {}; for (let i = 0; i < questions!.length; i++) { @@ -228,7 +229,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi placeholderTextColor={theme.colors.foregroundMuted} value={otherText} onChangeText={(text) => setOtherText(qIndex, text)} + onSubmitEditing={handleSubmit} editable={!isResponding} + blurOnSubmit={false} /> ); From 6907f6e71d296e604b6c4a3bb1a44ed261008bd7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 15:11:18 +0700 Subject: [PATCH 3/8] fix(desktop): resolve login shell environment at Electron startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, apps launched from Finder/Dock inherit a minimal environment (PATH is just /usr/bin:/bin:/usr/sbin:/sbin). This caused two problems: 1. Agent binaries like codex were not detected (findExecutable failed) 2. Terminals spawned by Paseo had no access to user-installed tools (node, bun, direnv — all "command not found") Fix: at Electron startup, spawn the user's login shell and capture its full environment via JSON.stringify(process.env) using UUID markers. This is the same battle-tested approach VS Code uses. The resolved environment is merged into process.env before the daemon starts, so all child processes — agents, terminals, git operations — inherit the correct environment automatically. This replaces the previous approach of invoking resolveShellEnv() (via the shell-env npm package) at multiple scattered call sites. Now there is a single source of truth: process.env is enriched once at startup. Changes: - New: packages/desktop/src/login-shell-env.ts (VS Code approach) - Removed: resolveShellEnv(), shell-env dependency, $SHELL -lic probes - Simplified: applyProviderEnv() and findExecutable() now trust process.env --- package-lock.json | 131 +----------------- packages/desktop/src/login-shell-env.ts | 113 +++++++++++++++ packages/desktop/src/main.ts | 3 + packages/server/package.json | 1 - .../agent/provider-launch-config.test.ts | 18 +-- .../server/agent/provider-launch-config.ts | 7 +- packages/server/src/utils/checkout-git.ts | 6 +- packages/server/src/utils/executable.test.ts | 20 ++- packages/server/src/utils/executable.ts | 46 +----- 9 files changed, 140 insertions(+), 205 deletions(-) create mode 100644 packages/desktop/src/login-shell-env.ts diff --git a/package-lock.json b/package-lock.json index a17b83ab5..d9d03f465 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15932,18 +15932,6 @@ "node": ">=0.10.0" } }, - "node_modules/default-shell": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz", - "integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -18545,35 +18533,6 @@ "dev": true, "license": "MIT" }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -21859,18 +21818,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -22582,15 +22529,6 @@ "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -23319,6 +23257,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -26558,6 +26497,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -27464,18 +27404,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -27701,6 +27629,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -31124,50 +31053,6 @@ "node": ">=8" } }, - "node_modules/shell-env": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.3.tgz", - "integrity": "sha512-Ioe5h+hCDZ7pKL5+JGzbtPvZ5ESMHePZ8nLxohlDL+twmlcmutttMhRkrQOed8DeLT8mkYBgbwZfohe8pqaA3g==", - "license": "MIT", - "dependencies": { - "default-shell": "^2.0.0", - "execa": "^5.1.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/shell-env/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/shell-env/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -32100,15 +31985,6 @@ "node": ">=4" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/strip-indent": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", @@ -35510,7 +35386,6 @@ "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", - "shell-env": "^4.0.3", "sherpa-onnx": "1.12.28", "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", diff --git a/packages/desktop/src/login-shell-env.ts b/packages/desktop/src/login-shell-env.ts new file mode 100644 index 000000000..448ed3c24 --- /dev/null +++ b/packages/desktop/src/login-shell-env.ts @@ -0,0 +1,113 @@ +// Shell environment resolution adapted from VS Code +// https://github.com/microsoft/vscode/blob/main/src/vs/platform/shell/node/shellEnv.ts +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { userInfo } from "node:os"; +import { basename } from "node:path"; + +const RESOLVE_TIMEOUT_MS = 10_000; + +function getSystemShell(): string { + const shell = process.env.SHELL; + if (shell) return shell; + + try { + const info = userInfo(); + if (info.shell && info.shell !== "/bin/false") return info.shell; + } catch {} + + return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"; +} + +function resolveShellEnv(): Record | undefined { + if (process.platform === "win32") return undefined; + + const savedRunAsNode = process.env.ELECTRON_RUN_AS_NODE; + const savedNoAttach = process.env.ELECTRON_NO_ATTACH_CONSOLE; + + const mark = randomUUID().replace(/-/g, "").slice(0, 12); + const regex = new RegExp(mark + "({.*})" + mark); + + const shell = getSystemShell(); + const name = basename(shell); + + let command: string; + let shellArgs: string[]; + + if (/^(?:pwsh|powershell)(?:-preview)?$/.test(name)) { + command = `& '${process.execPath}' -p '''${mark}'' + JSON.stringify(process.env) + ''${mark}'''`; + shellArgs = ["-Login", "-Command"]; + } else if (name === "nu") { + command = `^'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; + shellArgs = ["-i", "-l", "-c"]; + } else if (name === "xonsh") { + command = `import os, json; print("${mark}", json.dumps(dict(os.environ)), "${mark}")`; + shellArgs = ["-i", "-l", "-c"]; + } else { + command = `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; + if (name === "tcsh" || name === "csh") { + shellArgs = ["-ic"]; + } else { + shellArgs = ["-i", "-l", "-c"]; + } + } + + const result = spawnSync(shell, [...shellArgs, command], { + encoding: "utf8", + timeout: RESOLVE_TIMEOUT_MS, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + ELECTRON_NO_ATTACH_CONSOLE: "1", + }, + }); + + if (result.status !== 0 && result.status !== null) return undefined; + if (!result.stdout) return undefined; + + const match = regex.exec(result.stdout); + if (!match?.[1]) return undefined; + + try { + const env = JSON.parse(match[1]) as Record; + + if (savedRunAsNode) { + env.ELECTRON_RUN_AS_NODE = savedRunAsNode; + } else { + delete env.ELECTRON_RUN_AS_NODE; + } + + if (savedNoAttach) { + env.ELECTRON_NO_ATTACH_CONSOLE = savedNoAttach; + } else { + delete env.ELECTRON_NO_ATTACH_CONSOLE; + } + + delete env.XDG_RUNTIME_DIR; + + return env; + } catch { + return undefined; + } +} + +/** + * On macOS/Linux, Electron inherits a minimal environment when launched from + * Finder/Dock. Spawn the user's login shell and capture its full environment + * via Node's JSON.stringify(process.env), so the daemon and all child processes + * see the same tools and variables as a normal terminal session. + * + * Approach borrowed from VS Code (src/vs/platform/shell/node/shellEnv.ts). + */ +export function inheritLoginShellEnv(): void { + try { + const env = resolveShellEnv(); + if (env) { + Object.assign(process.env, env); + } + } catch { + // Keep inherited environment if shell lookup fails. + } +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 70f3f5489..d5e4106c5 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -1,6 +1,9 @@ import log from "electron-log/main"; log.initialize({ spyRendererConsole: true }); +import { inheritLoginShellEnv } from "./login-shell-env.js"; +inheritLoginShellEnv(); + import path from "node:path"; import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; diff --git a/packages/server/package.json b/packages/server/package.json index adb0968a6..25dea5f01 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -86,7 +86,6 @@ "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", - "shell-env": "^4.0.3", "sherpa-onnx": "1.12.28", "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 8cb7aca71..4d96d37b9 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -66,7 +66,7 @@ describe("applyProviderEnv", () => { }, }; - const env = applyProviderEnv(base, runtime, {}); + const env = applyProviderEnv(base, runtime); expect(env.PATH).toBe("/usr/bin"); expect(env.HOME).toBe("/custom/home"); @@ -74,21 +74,11 @@ describe("applyProviderEnv", () => { expect(Object.keys(env).length).toBeGreaterThanOrEqual(3); }); - test("shell env PATH wins over base env PATH", () => { - const base = { PATH: "/usr/bin:/bin" }; - const shellEnv = { PATH: "/usr/local/bin:/usr/bin:/bin:/home/user/.nvm/bin" }; - - const env = applyProviderEnv(base, undefined, shellEnv); - - expect(env.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/home/user/.nvm/bin"); - }); - - test("runtimeSettings env wins over shell env", () => { + test("runtimeSettings env wins over base env", () => { const base = { PATH: "/usr/bin" }; - const shellEnv = { PATH: "/usr/local/bin:/usr/bin" }; const runtime: ProviderRuntimeSettings = { env: { PATH: "/custom/path" } }; - const env = applyProviderEnv(base, runtime, shellEnv); + const env = applyProviderEnv(base, runtime); expect(env.PATH).toBe("/custom/path"); }); @@ -103,7 +93,7 @@ describe("applyProviderEnv", () => { CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: "true", }; - const env = applyProviderEnv(base, undefined, {}); + const env = applyProviderEnv(base); expect(env.PATH).toBe("/usr/bin"); expect(env.CLAUDECODE).toBeUndefined(); diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index facd7671a..730b591f2 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - isCommandAvailable, - resolveShellEnv, -} from "../../utils/executable.js"; +import { isCommandAvailable } from "../../utils/executable.js"; import type { AgentProvider } from "./agent-sdk-types.js"; import { AgentProviderSchema } from "./provider-manifest.js"; @@ -94,11 +91,9 @@ const PARENT_SESSION_ENV_VARS = [ export function applyProviderEnv( baseEnv: Record, runtimeSettings?: ProviderRuntimeSettings, - shellEnv?: Record, ): Record { const merged: Record = { ...baseEnv, - ...(shellEnv ?? resolveShellEnv()), ...(runtimeSettings?.env ?? {}), }; for (const key of PARENT_SESSION_ENV_VARS) { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 37812924f..3745731ca 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -6,7 +6,7 @@ import { open as openFile, stat as statFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; -import { findExecutable, resolveShellEnv } from "./executable.js"; +import { findExecutable } from "./executable.js"; import { isPaseoOwnedWorktreeCwd } from "./worktree.js"; import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; @@ -1845,7 +1845,7 @@ export async function createPullRequest( await execAsync(`git push -u origin ${head}`, { cwd }); - const ghEnv = { ...resolveShellEnv(), GIT_TERMINAL_PROMPT: "0" }; + const ghEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`]; args.push("-f", `head=${head}`); args.push("-f", `base=${normalizedBase}`); @@ -1912,7 +1912,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise[ function createFindExecutableDependencies(): FindExecutableDependencies { return { execFileSync: vi.fn(), - execSync: vi.fn(), existsSync: vi.fn(), platform: vi.fn(() => "darwin"), - shell: undefined, }; } @@ -77,25 +75,25 @@ describe("findExecutable", () => { expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex"); }); - test("uses the last line from login-shell which output", () => { - findExecutableDependencies.shell = "/bin/zsh"; - findExecutableDependencies.execSync.mockReturnValue( - "echo from profile\n/usr/local/bin/codex\n", + test("on Unix, uses the last line from which output", () => { + findExecutableDependencies.execFileSync.mockReturnValue( + "/usr/local/bin/codex\n", ); expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex"); - expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce(); - expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled(); + expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith( + "which", + ["codex"], + { encoding: "utf8" }, + ); }); test("warns and returns null when the final which line is not an absolute path", () => { - findExecutableDependencies.shell = "/bin/zsh"; - findExecutableDependencies.execSync.mockReturnValue("profile noise\ncodex\n"); findExecutableDependencies.execFileSync.mockReturnValue("codex\n"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(findExecutable("codex", findExecutableDependencies)).toBeNull(); - expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledOnce(); warnSpy.mockRestore(); }); diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts index 4c5fd61a8..57bf5162d 100644 --- a/packages/server/src/utils/executable.ts +++ b/packages/server/src/utils/executable.ts @@ -1,15 +1,11 @@ -import { execFileSync, execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { platform } from "node:os"; import path from "node:path"; -import { shellEnvSync } from "shell-env"; - export interface FindExecutableDependencies { - execSync: typeof execSync; execFileSync: typeof execFileSync; existsSync: typeof existsSync; platform: typeof platform; - shell: string | undefined; } function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] { @@ -42,7 +38,7 @@ function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] { function resolveExecutableFromWhichOutput( name: string, output: string, - source: "login-shell" | "which", + source: "which", ): string | null { const lines = output .split(/\r?\n/) @@ -65,10 +61,8 @@ function resolveExecutableFromWhichOutput( } /** - * On Unix we first try `$SHELL -lic "which "` so that rc-file PATH - * additions (asdf, nvm, homebrew, nix, etc.) are visible — exactly as if the - * user opened a terminal and typed the command. If that fails (e.g. the login - * shell itself errors) we fall back to a plain `which`. + * On Unix we use plain `which` — the daemon's process.env.PATH is enriched + * with the login shell environment at Electron desktop startup. * * On Windows we augment the daemon PATH with machine/user registry PATH values * and return the first `where.exe` match. Launch-time execution decides whether @@ -85,11 +79,9 @@ export function findExecutable( } const deps: FindExecutableDependencies = { - execSync, execFileSync, existsSync, platform, - shell: process.env["SHELL"], ...dependencies, }; @@ -125,25 +117,6 @@ export function findExecutable( } } - // Unix: try the user's login shell so rc-file PATH entries are visible. - const shell = deps.shell; - if (shell) { - try { - const out = deps - .execSync(`${shell} -lic "which ${trimmed}"`, { - encoding: "utf8", - timeout: 5000, - }) - .trim(); - const resolved = resolveExecutableFromWhichOutput(trimmed, out, "login-shell"); - if (resolved) { - return resolved; - } - } catch { - // Login shell failed (broken rc, etc.) — fall through to plain which. - } - } - try { return resolveExecutableFromWhichOutput( trimmed, @@ -184,14 +157,3 @@ export function quoteWindowsArgument(argument: string): string { return `"${argument}"`; } -let cachedShellEnv: Record | null = null; - -export function resolveShellEnv(): Record { - if (cachedShellEnv) return cachedShellEnv; - try { - cachedShellEnv = shellEnvSync(); - } catch { - cachedShellEnv = { ...process.env } as Record; - } - return cachedShellEnv; -} From 0d88ce22708b67b18bbf5bb40d9996de1af27f3e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 15:32:13 +0700 Subject: [PATCH 4/8] feat(app): provider system overhaul with model selector, diagnostics, and UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CombinedModelSelector with two-level drill-down (providers → models), favorites section, search, and single-provider auto-drill mode - Add provider snapshot system (ProviderSnapshotManager) for cached provider/model state - Add provider diagnostics with resolved binary paths and versions across all providers - Add StatusBadge shared component for consistent status visual language - Add ProviderDiagnosticSheet for detailed provider health inspection - Add desktop auto-focus on input after any status bar interaction (model, mode, thinking, features) using focusWithRetries - Fix Combobox flicker on height change by switching to bottom-based CSS positioning once initial floating-ui position resolves - Add dynamic dropdown height: Level 1 fits content, Level 2 calculates from model count - Remove "Other providers" section from model selector (only show available providers) --- .../app/src/components/agent-input-area.tsx | 8 +- .../app/src/components/agent-status-bar.tsx | 69 +-- .../components/combined-model-selector.tsx | 248 ++++++++--- .../components/provider-diagnostic-sheet.tsx | 102 +++++ packages/app/src/components/ui/combobox.tsx | 71 ++- .../app/src/components/ui/status-badge.tsx | 65 +++ packages/app/src/contexts/session-context.tsx | 1 + .../app/src/hooks/use-agent-form-state.ts | 261 +++++++++-- .../src/screens/agent/draft-agent-screen.tsx | 1 + packages/app/src/screens/settings-screen.tsx | 134 +++++- .../workspace/workspace-draft-agent-tab.tsx | 69 ++- packages/app/src/stores/session-store.ts | 15 +- packages/server/src/client/daemon-client.ts | 60 +++ .../src/server/agent/agent-sdk-types.ts | 12 + .../agent/provider-snapshot-manager.test.ts | 409 ++++++++++++++++++ .../server/agent/provider-snapshot-manager.ts | 217 ++++++++++ .../server/agent/providers/claude-agent.ts | 69 ++- .../agent/providers/codex-app-server-agent.ts | 50 +++ .../agent/providers/copilot-acp-agent.ts | 57 +++ .../agent/providers/diagnostic-utils.ts | 80 ++++ .../server/agent/providers/opencode-agent.ts | 64 +++ .../server/agent/providers/pi-acp-agent.ts | 90 +++- packages/server/src/server/session.ts | 110 +++++ .../server/src/server/websocket-server.ts | 16 + packages/server/src/shared/messages.ts | 107 +++++ 25 files changed, 2225 insertions(+), 160 deletions(-) create mode 100644 packages/app/src/components/provider-diagnostic-sheet.tsx create mode 100644 packages/app/src/components/ui/status-badge.tsx create mode 100644 packages/server/src/server/agent/provider-snapshot-manager.test.ts create mode 100644 packages/server/src/server/agent/provider-snapshot-manager.ts create mode 100644 packages/server/src/server/agent/providers/diagnostic-utils.ts diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 2c4c7261d..125a31448 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -213,7 +213,13 @@ export function AgentInputArea({ const focusInput = useCallback(() => { if (Platform.OS !== "web") return; - messageInputRef.current?.focus(); + focusWithRetries({ + focus: () => messageInputRef.current?.focus(), + isFocused: () => { + const el = messageInputRef.current?.getNativeElement?.() ?? null; + return el != null && document.activeElement === el; + }, + }); }, []); useEffect(() => { diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 93cd8e6ca..bca90dcb0 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -87,6 +87,7 @@ type ControlledAgentStatusBarProps = { onToggleFavoriteModel?: (provider: string, modelId: string) => void; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; + onDropdownClose?: () => void; }; export interface DraftAgentStatusBarProps { @@ -108,6 +109,7 @@ export interface DraftAgentStatusBarProps { onSelectThinkingOption: (thinkingOptionId: string) => void; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; + onDropdownClose?: () => void; disabled?: boolean; } @@ -212,6 +214,7 @@ function ControlledStatusBar({ onToggleFavoriteModel, features, onSetFeature, + onDropdownClose, }: ControlledAgentStatusBarProps) { const { theme } = useUnistyles(); const isWeb = Platform.OS === "web"; @@ -331,8 +334,11 @@ function ControlledStatusBar({ const handleOpenChange = useCallback( (selector: StatusSelector) => (nextOpen: boolean) => { setOpenSelector(nextOpen ? selector : null); + if (!nextOpen) { + onDropdownClose?.(); + } }, - [], + [onDropdownClose], ); const handleSelectorPress = useCallback( @@ -555,9 +561,7 @@ function ControlledStatusBar({ - setOpenSelector(open ? `feature-${feature.id}` : null) - } + onOpenChange={handleOpenChange(`feature-${feature.id}`)} > @@ -784,9 +788,7 @@ function ControlledStatusBar({ - setOpenSelector(open ? `feature-${feature.id}` : null) - } + onOpenChange={handleOpenChange(`feature-${feature.id}`)} > @@ -1115,30 +1120,32 @@ export function DraftAgentStatusBar({ })); return ( - onSelectModel(modelId)} - isModelLoading={isAllModelsLoading} - favoriteKeys={favoriteKeys} - onToggleFavoriteModel={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { - console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); - }); - }} - thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} - selectedThinkingOptionId={effectiveSelectedThinkingOption} - onSelectThinkingOption={onSelectThinkingOption} - features={features} - onSetFeature={onSetFeature} - disabled={disabled} - /> + <> + onSelectModel(modelId)} + isModelLoading={isAllModelsLoading} + favoriteKeys={favoriteKeys} + onToggleFavoriteModel={(provider, modelId) => { + void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); + }); + }} + thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} + selectedThinkingOptionId={effectiveSelectedThinkingOption} + onSelectThinkingOption={onSelectThinkingOption} + features={features} + onSetFeature={onSetFeature} + disabled={disabled} + /> + ); } diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 1d62aa555..442be354e 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View, Text, + TextInput, Pressable, Platform, ActivityIndicator, type GestureResponderEvent, } from "react-native"; +import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowLeft, @@ -15,9 +17,12 @@ import { Search, Star, } from "lucide-react-native"; -import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; +import type { + AgentModelDefinition, + AgentProvider, +} from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; -import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox"; +import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { getProviderIcon } from "@/components/provider-icons"; import type { FavoriteModelRow } from "@/hooks/use-form-preferences"; @@ -29,8 +34,6 @@ import { type SelectorModelRow, } from "./combined-model-selector.utils"; -const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY; - type SelectorView = | { kind: "all" } | { kind: "provider"; providerId: string; providerLabel: string }; @@ -51,6 +54,7 @@ interface CombinedModelSelectorProps { disabled: boolean; isOpen: boolean; }) => React.ReactNode; + onClose?: () => void; disabled?: boolean; } @@ -67,7 +71,6 @@ interface SelectorContentProps { canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; - onBack?: () => void; } function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string { @@ -99,6 +102,22 @@ function partitionRows( return { favoriteRows, regularRows }; } +function sortFavoritesFirst( + rows: SelectorModelRow[], + favoriteKeys: Set, +): SelectorModelRow[] { + const favorites: SelectorModelRow[] = []; + const rest: SelectorModelRow[] = []; + for (const row of rows) { + if (favoriteKeys.has(row.favoriteKey)) { + favorites.push(row); + } else { + rest.push(row); + } + } + return [...favorites, ...rest]; +} + function groupRowsByProvider( rows: SelectorModelRow[], ): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> { @@ -126,6 +145,7 @@ function ModelRow({ isSelected, isFavorite, disabled = false, + elevated = false, onPress, onToggleFavorite, }: { @@ -133,6 +153,7 @@ function ModelRow({ isSelected: boolean; isFavorite: boolean; disabled?: boolean; + elevated?: boolean; onPress: () => void; onToggleFavorite?: (provider: string, modelId: string) => void; }) { @@ -153,8 +174,9 @@ function ModelRow({ label={row.modelLabel} selected={isSelected} disabled={disabled} + elevated={elevated} onPress={onPress} - leadingSlot={} + leadingSlot={} trailingSlot={ onToggleFavorite && !disabled ? ( + Favorites @@ -239,11 +261,11 @@ function FavoritesSection({ isSelected={row.provider === selectedProvider && row.modelId === selectedModel} isFavorite={favoriteKeys.has(row.favoriteKey)} disabled={!canSelectProvider(row.provider)} + elevated onPress={() => onSelect(row.provider, row.modelId)} onToggleFavorite={onToggleFavorite} /> ))} - ); } @@ -258,6 +280,7 @@ function GroupedProviderRows({ canSelectProvider, onToggleFavorite, onDrillDown, + viewKind, }: { providerDefinitions: AgentProviderDefinition[]; groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>; @@ -268,6 +291,7 @@ function GroupedProviderRows({ canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; + viewKind: SelectorView["kind"]; }) { const { theme } = useUnistyles(); @@ -276,19 +300,14 @@ function GroupedProviderRows({ {groupedRows.map((group, index) => { const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId); const ProvIcon = getProviderIcon(group.providerId); - const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD; + const isInline = viewKind === "provider"; return ( {index > 0 ? : null} {isInline ? ( <> - - - {providerDefinition?.label ?? group.providerLabel} - - - {group.rows.map((row) => ( + {sortFavoritesFirst(group.rows, favoriteKeys).map((row) => ( - + {group.providerLabel} - {group.rows.length} - + + {group.rows.length} {group.rows.length === 1 ? "model" : "models"} + + )} @@ -324,6 +345,46 @@ function GroupedProviderRows({ ); } +function ProviderSearchInput({ + value, + onChangeText, + autoFocus = false, +}: { + value: string; + onChangeText: (text: string) => void; + autoFocus?: boolean; +}) { + const { theme } = useUnistyles(); + const inputRef = useRef(null); + const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; + + useEffect(() => { + if (autoFocus && Platform.OS === "web" && inputRef.current) { + const timer = setTimeout(() => { + inputRef.current?.focus(); + }, 50); + return () => clearTimeout(timer); + } + }, [autoFocus]); + + return ( + + + + + ); +} + function SelectorContent({ view, providerDefinitions, @@ -337,8 +398,8 @@ function SelectorContent({ canSelectProvider, onToggleFavorite, onDrillDown, - onBack, }: SelectorContentProps) { + const { theme } = useUnistyles(); const allRows = useMemo( () => buildModelRows(providerDefinitions, allProviderModels), [allProviderModels, providerDefinitions], @@ -363,35 +424,41 @@ function SelectorContent({ [favoriteKeys, visibleRows], ); - const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]); + // Group ALL visible rows by provider — favorites are a cross-cutting view, + // not a partition. A model being favorited doesn't remove it from its provider. + const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]); + + // When searching at Level 1, filter grouped rows to only providers whose name or models match + const filteredGroupedRows = useMemo(() => { + if (view.kind === "provider" || !normalizedQuery) { + return allGroupedRows; + } + return allGroupedRows.filter( + (group) => + group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0, + ); + }, [allGroupedRows, normalizedQuery, view.kind]); + + const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0; return ( - {view.kind === "provider" ? ( - + {view.kind === "all" ? ( + ) : null} - - - - - {groupedRegularRows.length > 0 ? ( + {filteredGroupedRows.length > 0 ? ( ) : null} - {favoriteRows.length === 0 && groupedRegularRows.length === 0 ? ( + {!hasResults ? ( - + No models match your search ) : null} @@ -437,8 +505,8 @@ function ProviderBackButton({ pressed && styles.backButtonPressed, ]} > - - + + {providerLabel} ); @@ -455,6 +523,7 @@ export function CombinedModelSelector({ favoriteKeys = new Set(), onToggleFavorite, renderTrigger, + onClose, disabled = false, }: CombinedModelSelectorProps) { const { theme } = useUnistyles(); @@ -465,25 +534,35 @@ export function CombinedModelSelector({ const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); + // Single-provider mode: only one provider with models → skip Level 1 entirely + const singleProviderView = useMemo(() => { + const providers = Array.from(allProviderModels.keys()); + if (providers.length !== 1) return null; + const providerId = providers[0]!; + const label = resolveProviderLabel(providerDefinitions, providerId); + return { kind: "provider", providerId, providerLabel: label }; + }, [allProviderModels, providerDefinitions]); + const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); - setView({ kind: "all" }); + setView(singleProviderView ?? { kind: "all" }); if (!open) { setSearchQuery(""); + onClose?.(); } }, - [], + [onClose, singleProviderView], ); const handleSelect = useCallback( (provider: string, modelId: string) => { onSelect(provider as AgentProvider, modelId); setIsOpen(false); - setView({ kind: "all" }); + setView(singleProviderView ?? { kind: "all" }); setSearchQuery(""); }, - [onSelect], + [onSelect, singleProviderView], ); const ProviderIcon = getProviderIcon(selectedProvider); @@ -501,6 +580,15 @@ export function CombinedModelSelector({ return model?.label ?? resolveDefaultModelLabel(models); }, [allProviderModels, isLoading, selectedModel, selectedProvider]); + const desktopFixedHeight = useMemo(() => { + if (view.kind !== "provider") { + return undefined; + } + const models = allProviderModels.get(view.providerId); + const modelCount = models?.length ?? 0; + return Math.min(80 + modelCount * 40, 400); + }, [allProviderModels, view]); + const triggerLabel = useMemo(() => { if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") { return selectedModelLabel; @@ -568,7 +656,30 @@ export function CombinedModelSelector({ stackBehavior="push" anchorRef={anchorRef} desktopPlacement="top-start" + desktopMinWidth={360} + desktopFixedHeight={desktopFixedHeight} title="Select model" + stickyHeader={ + view.kind === "provider" ? ( + + {!singleProviderView ? ( + { + setView({ kind: "all" }); + setSearchQuery(""); + }} + /> + ) : null} + + + ) : undefined + } > {isContentReady ? ( { setView({ kind: "provider", providerId, providerLabel }); }} - onBack={ - view.kind === "provider" - ? () => { - setView({ kind: "all" }); - } - : undefined - } /> ) : ( @@ -634,17 +738,22 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: 0, height: "auto", }, + favoritesContainer: { + backgroundColor: theme.colors.surface1, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, separator: { height: 1, backgroundColor: theme.colors.border, - marginVertical: theme.spacing[1], }, sectionHeading: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], }, sectionHeadingText: { fontSize: theme.fontSize.xs, @@ -667,8 +776,8 @@ const styles = StyleSheet.create((theme) => ({ }, drillDownText: { flex: 1, - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, }, drillDownTrailing: { flexDirection: "row", @@ -679,6 +788,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, + level2Header: { + backgroundColor: theme.colors.surface1, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, backButton: { flexDirection: "row", alignItems: "center", @@ -689,7 +803,7 @@ const styles = StyleSheet.create((theme) => ({ borderBottomColor: theme.colors.border, }, backButtonHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surface2, }, backButtonPressed: { backgroundColor: theme.colors.surface2, @@ -734,4 +848,16 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + providerSearchContainer: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: theme.spacing[3], + gap: theme.spacing[2], + }, + providerSearchInput: { + flex: 1, + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, })); diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx new file mode 100644 index 000000000..20ff08967 --- /dev/null +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useState } from "react"; +import { View, Text, ActivityIndicator, ScrollView } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; + +interface ProviderDiagnosticSheetProps { + provider: string; + visible: boolean; + onClose: () => void; + serverId: string; +} + +export function ProviderDiagnosticSheet({ + provider, + visible, + onClose, + serverId, +}: ProviderDiagnosticSheetProps) { + const { theme } = useUnistyles(); + const client = useHostRuntimeClient(serverId); + const [diagnostic, setDiagnostic] = useState(null); + const [loading, setLoading] = useState(false); + + const providerLabel = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider; + + const fetchDiagnostic = useCallback(async () => { + if (!client || !provider) return; + + setLoading(true); + 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]); + + useEffect(() => { + if (visible) { + fetchDiagnostic(); + } else { + setDiagnostic(null); + } + }, [visible, fetchDiagnostic]); + + return ( + + {loading ? ( + + + Fetching diagnostic… + + ) : diagnostic ? ( + + + {diagnostic} + + + ) : null} + + ); +} + +const sheetStyles = StyleSheet.create((theme) => ({ + loadingContainer: { + 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/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 179f94c15..ab4ec98f8 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -73,6 +73,12 @@ export interface ComboboxProps { * for that combobox instance to avoid animation overriding hidden opacity. */ desktopPreventInitialFlash?: boolean; + /** Minimum width for the desktop popover (overrides trigger-based width). */ + desktopMinWidth?: number; + /** Fixed height for the desktop popover (overrides default 400px max). */ + desktopFixedHeight?: number; + /** Content rendered above the scroll area on desktop (sticky header). */ + stickyHeader?: ReactNode; anchorRef: React.RefObject; children?: ReactNode; } @@ -150,6 +156,8 @@ export interface ComboboxItemProps { selected?: boolean; active?: boolean; disabled?: boolean; + /** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */ + elevated?: boolean; onPress: () => void; testID?: string; } @@ -163,6 +171,7 @@ export function ComboboxItem({ selected, active, disabled, + elevated, onPress, testID, }: ComboboxItemProps): ReactElement { @@ -187,8 +196,8 @@ export function ComboboxItem({ onPress={onPress} style={({ pressed, hovered = false }) => [ styles.comboboxItem, - hovered && styles.comboboxItemHovered, - pressed && styles.comboboxItemPressed, + hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered), + pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed), active && styles.comboboxItemActive, disabled && styles.comboboxItemDisabled, ]} @@ -246,6 +255,9 @@ export function Combobox({ stackBehavior, desktopPlacement = "top-start", desktopPreventInitialFlash = true, + desktopMinWidth, + desktopFixedHeight, + stickyHeader, anchorRef, children, }: ComboboxProps): ReactElement { @@ -390,12 +402,27 @@ export function Combobox({ ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition; const shouldUseDesktopFade = !desktopPreventInitialFlash; + // For top-placed popups: once position resolves, use bottom-based CSS positioning + // so height changes grow upward naturally without floating-ui needing to reposition. + const useStableBottom = + !isDesktopAboveSearch && + IS_WEB && + !isMobile && + hasResolvedDesktopPosition && + desktopPlacement.startsWith("top") && + referenceTop !== null; + const desktopPositionStyle = isDesktopAboveSearch ? { left: floatingLeft ?? 0, bottom: desktopAboveSearchBottom ?? 0, } - : floatingStyles; + : useStableBottom + ? { + left: floatingLeft ?? 0, + bottom: Math.max(windowHeight - referenceTop!, collisionPadding), + } + : floatingStyles; useEffect(() => { if (!isMobile) return; @@ -662,6 +689,7 @@ export function Combobox({ {title} + {stickyHeader} update()} > {children ? ( - - {content} - + <> + {stickyHeader} + + {content} + + ) : ( <> {effectiveOptionsPosition === "above-search" ? ( @@ -783,9 +817,15 @@ const styles = StyleSheet.create((theme) => ({ comboboxItemHovered: { backgroundColor: theme.colors.surface1, }, + comboboxItemHoveredElevated: { + backgroundColor: theme.colors.surface2, + }, comboboxItemPressed: { backgroundColor: theme.colors.surface1, }, + comboboxItemPressedElevated: { + backgroundColor: theme.colors.surface2, + }, comboboxItemActive: { backgroundColor: theme.colors.surface1, }, @@ -876,6 +916,9 @@ const styles = StyleSheet.create((theme) => ({ desktopScrollContent: { paddingVertical: theme.spacing[1], }, + desktopChildrenScrollContent: { + // No padding — custom children (e.g. model selector) control their own spacing + }, desktopScrollContentAboveSearch: { flexGrow: 1, justifyContent: "flex-end", diff --git a/packages/app/src/components/ui/status-badge.tsx b/packages/app/src/components/ui/status-badge.tsx new file mode 100644 index 000000000..ca80ebb7a --- /dev/null +++ b/packages/app/src/components/ui/status-badge.tsx @@ -0,0 +1,65 @@ +import { View, Text } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; + +type StatusBadgeVariant = "success" | "error" | "muted"; + +interface StatusBadgeProps { + label: string; + variant?: StatusBadgeVariant; +} + +export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) { + const { theme } = useUnistyles(); + + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + pill: { + flexDirection: "row", + alignItems: "center", + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface3, + paddingHorizontal: theme.spacing[2], + paddingVertical: 3, + }, + pillSuccess: { + backgroundColor: theme.colors.palette.green[900], + borderColor: theme.colors.palette.green[800], + }, + pillError: { + backgroundColor: theme.colors.palette.red[900], + borderColor: theme.colors.palette.red[800], + }, + pillText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + pillTextSuccess: { + color: theme.colors.palette.green[400], + }, + pillTextError: { + color: theme.colors.palette.red[500], + }, +})); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 96f2993fc..9c36e8132 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1105,6 +1105,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider hostname: serverInfo.hostname, version: serverInfo.version, ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), + ...(serverInfo.features ? { features: serverInfo.features } : {}), }); return; } diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index badebe2b6..812e3fd0a 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQuery, useQueries } from "@tanstack/react-query"; +import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query"; import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, @@ -8,9 +8,11 @@ import type { AgentMode, AgentModelDefinition, AgentProvider, + ProviderSnapshotEntry, } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useSessionForServer } from "./use-session-directory"; import { useFormPreferences, mergeProviderPreferences, @@ -84,6 +86,7 @@ type UseAgentFormStateResult = { providerDefinitions: AgentProviderDefinition[]; providerDefinitionMap: Map; agentDefinition?: AgentProviderDefinition; + allProviderEntries?: ProviderSnapshotEntry[]; modeOptions: AgentMode[]; availableModels: AgentModelDefinition[]; allProviderModels: Map; @@ -319,6 +322,10 @@ function combineInitialValues( return initialValues; } +function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) { + return ["providersSnapshot", serverId, cwd ?? ""] as const; +} + export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { const { initialServerId = null, @@ -336,6 +343,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg } = useFormPreferences(); const daemons = useHosts(); + const queryClient = useQueryClient(); // Build a set of valid server IDs for preference validation const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]); @@ -371,11 +379,138 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg // Session state for provider model listing const client = useHostRuntimeClient(formState.serverId ?? ""); const isConnected = useHostRuntimeIsConnected(formState.serverId ?? ""); + const supportsProvidersSnapshot = useSessionForServer( + formState.serverId, + (session) => session?.serverInfo?.features?.providersSnapshot === true, + ); + const [debouncedCwd, setDebouncedCwd] = useState(undefined); + useEffect(() => { + const trimmed = formState.workingDir.trim(); + const next = trimmed.length > 0 ? trimmed : undefined; + const timer = setTimeout(() => setDebouncedCwd(next), 180); + return () => clearTimeout(timer); + }, [formState.workingDir]); + + const snapshotQueryKey = useMemo( + () => providersSnapshotQueryKey(formState.serverId, debouncedCwd), + [debouncedCwd, formState.serverId], + ); + + const providersSnapshotQuery = useQuery({ + queryKey: snapshotQueryKey, + enabled: Boolean( + supportsProvidersSnapshot && + isVisible && + isTargetDaemonReady && + formState.serverId && + client && + isConnected, + ), + staleTime: 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + return client.getProvidersSnapshot({ cwd: debouncedCwd }); + }, + }); + + useEffect(() => { + if ( + !supportsProvidersSnapshot || + !client || + !isConnected || + !isVisible || + !isTargetDaemonReady || + !formState.serverId + ) { + return; + } + + return client.on("providers_snapshot_update", (message) => { + if (message.type !== "providers_snapshot_update") { + return; + } + if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) { + return; + } + queryClient.setQueryData(snapshotQueryKey, { + entries: message.payload.entries, + generatedAt: message.payload.generatedAt, + requestId: "providers_snapshot_update", + }); + }); + }, [ + client, + debouncedCwd, + formState.serverId, + isConnected, + isTargetDaemonReady, + isVisible, + queryClient, + snapshotQueryKey, + supportsProvidersSnapshot, + ]); + + const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined; + const allProviderEntries = useMemo( + () => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined), + [snapshotEntries, supportsProvidersSnapshot], + ); + const snapshotProviderDefinitions = useMemo(() => { + if (!supportsProvidersSnapshot) { + return []; + } + const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider)); + return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id)); + }, [snapshotEntries, supportsProvidersSnapshot]); + const snapshotProviderDefinitionMap = useMemo( + () => + new Map( + snapshotProviderDefinitions.map((definition) => [definition.id, definition]), + ), + [snapshotProviderDefinitions], + ); + const snapshotSelectableProviderDefinitionMap = useMemo(() => { + const readyProviders = new Set( + (snapshotEntries ?? []) + .filter((entry) => entry.status === "ready") + .map((entry) => entry.provider), + ); + return new Map( + snapshotProviderDefinitions + .filter((definition) => readyProviders.has(definition.id)) + .map((definition) => [definition.id, definition]), + ); + }, [snapshotEntries, snapshotProviderDefinitions]); + const snapshotAllProviderModels = useMemo(() => { + const map = new Map(); + for (const entry of snapshotEntries ?? []) { + map.set(entry.provider, entry.models ?? []); + } + return map; + }, [snapshotEntries]); + const snapshotSelectedEntry = useMemo( + () => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null, + [formState.provider, snapshotEntries], + ); + const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null; + const snapshotSelectedProviderModes = + snapshotSelectedEntry?.modes ?? + snapshotProviderDefinitionMap.get(formState.provider)?.modes ?? + []; + + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots const availableProvidersQuery = useQuery({ queryKey: ["availableProviders", formState.serverId], enabled: Boolean( - isVisible && isTargetDaemonReady && formState.serverId && client && isConnected, + !supportsProvidersSnapshot && + isVisible && + isTargetDaemonReady && + formState.serverId && + client && + isConnected, ), staleTime: 60 * 1000, queryFn: async () => { @@ -389,43 +524,33 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider); }, }); - - const providerDefinitions = useMemo(() => { + const legacyProviderDefinitions = useMemo(() => { const availableProviders = availableProvidersQuery.data; if (!availableProviders) { return []; } const available = new Set(availableProviders); - return allProviderDefinitions.filter((definition) => - available.has(definition.id as AgentProvider), - ); + return allProviderDefinitions.filter((definition) => available.has(definition.id)); }, [availableProvidersQuery.data]); - - const providerDefinitionMap = useMemo( + const legacyProviderDefinitionMap = useMemo( () => new Map( - providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]), + legacyProviderDefinitions.map((definition) => [definition.id, definition]), ), - [providerDefinitions], + [legacyProviderDefinitions], ); - const [debouncedCwd, setDebouncedCwd] = useState(undefined); - useEffect(() => { - const trimmed = formState.workingDir.trim(); - const next = trimmed.length > 0 ? trimmed : undefined; - const timer = setTimeout(() => setDebouncedCwd(next), 180); - return () => clearTimeout(timer); - }, [formState.workingDir]); - - const providerModelsQuery = useQuery({ + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots + const legacySelectedProviderModelsQuery = useQuery({ queryKey: ["providerModels", formState.serverId, formState.provider], enabled: Boolean( + !supportsProvidersSnapshot && isVisible && isTargetDaemonReady && formState.serverId && client && isConnected && - providerDefinitionMap.has(formState.provider), + legacyProviderDefinitionMap.has(formState.provider), ), staleTime: 5 * 60 * 1000, queryFn: async () => { @@ -441,18 +566,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg return payload.models ?? []; }, }); + const legacySelectedProviderModels = legacySelectedProviderModelsQuery.data ?? null; - const availableModels = providerModelsQuery.data ?? null; - + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots const providerModesQuery = useQuery({ queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd], enabled: Boolean( - isVisible && + !supportsProvidersSnapshot && + isVisible && isTargetDaemonReady && formState.serverId && client && isConnected && - providerDefinitionMap.has(formState.provider), + legacyProviderDefinitionMap.has(formState.provider), ), staleTime: 5 * 60 * 1000, queryFn: async () => { @@ -469,8 +595,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }, }); + // COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots const allProviderModelQueries = useQueries({ - queries: providerDefinitions.map((def) => ({ + queries: (supportsProvidersSnapshot ? [] : legacyProviderDefinitions).map((def) => ({ queryKey: ["providerModels", formState.serverId, def.id], enabled: Boolean( isVisible && isTargetDaemonReady && formState.serverId && client && isConnected, @@ -490,19 +617,40 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }, })), }); - - const allProviderModels = useMemo(() => { + const legacyAllProviderModels = useMemo(() => { const map = new Map(); - for (let i = 0; i < providerDefinitions.length; i++) { + for (let i = 0; i < legacyProviderDefinitions.length; i++) { const query = allProviderModelQueries[i]; if (query?.data) { - map.set(providerDefinitions[i]!.id, query.data); + map.set(legacyProviderDefinitions[i]!.id, query.data); } } return map; - }, [allProviderModelQueries, providerDefinitions]); + }, [allProviderModelQueries, legacyProviderDefinitions]); + const legacySelectedProviderModes = + providerModesQuery.data ?? legacyProviderDefinitionMap.get(formState.provider)?.modes ?? []; - const isAllModelsLoading = allProviderModelQueries.some((q) => q.isLoading); + const providerDefinitions = supportsProvidersSnapshot + ? snapshotProviderDefinitions + : legacyProviderDefinitions; + const providerDefinitionMap = supportsProvidersSnapshot + ? snapshotProviderDefinitionMap + : legacyProviderDefinitionMap; + const selectableProviderDefinitionMap = supportsProvidersSnapshot + ? snapshotSelectableProviderDefinitionMap + : legacyProviderDefinitionMap; + const allProviderModels = supportsProvidersSnapshot + ? snapshotAllProviderModels + : legacyAllProviderModels; + const availableModels = supportsProvidersSnapshot + ? snapshotSelectedProviderModels + : legacySelectedProviderModels; + const modeOptions = supportsProvidersSnapshot + ? snapshotSelectedProviderModes + : legacySelectedProviderModes; + const isAllModelsLoading = supportsProvidersSnapshot + ? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching + : allProviderModelQueries.some((q) => q.isLoading); // Combine initialValues with initialServerId for resolution const combinedInitialValues = useMemo((): FormInitialValues | undefined => { @@ -527,7 +675,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg userModified, formStateRef.current, validServerIds, - providerDefinitionMap, + selectableProviderDefinitionMap, ); // Only update if something changed @@ -552,7 +700,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg availableModels, userModified, validServerIds, - providerDefinitionMap, + selectableProviderDefinitionMap, ]); // Auto-select the first online host when: @@ -592,8 +740,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const setProviderFromUser = useCallback( (provider: AgentProvider) => { + if (!selectableProviderDefinitionMap.has(provider)) { + return; + } const providerModels = allProviderModels.get(provider) ?? null; - const providerDef = providerDefinitionMap.get(provider); + const providerDef = selectableProviderDefinitionMap.get(provider); const providerPrefs = preferences?.providerPreferences?.[provider]; const isValidModel = (m: string) => @@ -631,12 +782,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg thinkingOptionId: nextThinkingOptionId, })); }, - [allProviderModels, preferences?.providerPreferences, providerDefinitionMap, updatePreferences], + [ + allProviderModels, + preferences?.providerPreferences, + selectableProviderDefinitionMap, + updatePreferences, + ], ); const setProviderAndModelFromUser = useCallback( (provider: AgentProvider, modelId: string) => { - const providerDef = providerDefinitionMap.get(provider); + if (!selectableProviderDefinitionMap.has(provider)) { + return; + } + const providerDef = selectableProviderDefinitionMap.get(provider); const providerModels = allProviderModels.get(provider) ?? null; const normalizedModelId = normalizeSelectedModelId(modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels); @@ -656,7 +815,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg setUserModified((prev) => ({ ...prev, provider: true, model: true })); void updatePreferences({ provider }); }, - [allProviderModels, providerDefinitionMap, updatePreferences], + [allProviderModels, selectableProviderDefinitionMap, updatePreferences], ); const setModeFromUser = useCallback( @@ -713,8 +872,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }, []); const refreshProviderModels = useCallback(() => { - void providerModelsQuery.refetch(); - }, [providerModelsQuery]); + if (supportsProvidersSnapshot) { + if (!client) { + return; + } + void client.refreshProvidersSnapshot({ cwd: debouncedCwd }); + return; + } + void legacySelectedProviderModelsQuery.refetch(); + }, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]); const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); @@ -747,13 +913,20 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ]); const agentDefinition = providerDefinitionMap.get(formState.provider); - const modeOptions = providerModesQuery.data ?? agentDefinition?.modes ?? []; const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModelId = effectiveModel?.id ?? formState.model; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; - const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching; + const isModelLoading = supportsProvidersSnapshot + ? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching + : legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching; const modelError = - providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null; + supportsProvidersSnapshot + ? providersSnapshotQuery.error instanceof Error + ? providersSnapshotQuery.error.message + : null + : legacySelectedProviderModelsQuery.error instanceof Error + ? legacySelectedProviderModelsQuery.error.message + : null; const workingDirIsEmpty = !formState.workingDir.trim(); @@ -776,6 +949,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg providerDefinitions, providerDefinitionMap, agentDefinition, + allProviderEntries, modeOptions, availableModels: availableModels ?? [], allProviderModels, @@ -806,6 +980,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg providerDefinitions, providerDefinitionMap, agentDefinition, + allProviderEntries, modeOptions, availableModels, allProviderModels, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 1ca014d32..69f7c0c05 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -220,6 +220,7 @@ function DraftAgentScreenContent({ modeOptions, availableModels, allProviderModels, + allProviderEntries, isAllModelsLoading, availableThinkingOptions, isModelLoading, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index c5fabd2da..bb6b2ace6 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -21,6 +21,7 @@ import { Info, Shield, Puzzle, + Blocks, } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; @@ -62,6 +63,11 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { isCompactFormFactor } from "@/constants/layout"; +import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; +import { getProviderIcon } from "@/components/provider-icons"; +import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; +import { StatusBadge } from "@/components/ui/status-badge"; +import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; // --------------------------------------------------------------------------- // Section definitions @@ -72,6 +78,7 @@ type SettingsSectionId = | "appearance" | "shortcuts" | "integrations" + | "providers" | "diagnostics" | "about" | "permissions" @@ -99,6 +106,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio } sections.push( + { id: "providers", label: "Providers", icon: Blocks }, { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, { id: "about", label: "About", icon: Info }, ); @@ -418,6 +426,116 @@ function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionPro } +interface ProvidersSectionProps { + routeServerId: string; +} + +function ProvidersSection({ routeServerId }: ProvidersSectionProps) { + const { theme } = useUnistyles(); + const client = useHostRuntimeClient(routeServerId); + const isConnected = useHostRuntimeIsConnected(routeServerId); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [diagnosticProvider, setDiagnosticProvider] = useState(null); + + useEffect(() => { + if (!client || !isConnected) { + setEntries([]); + return; + } + + let cancelled = false; + setLoading(true); + client + .getProvidersSnapshot() + .then((result) => { + if (!cancelled) setEntries(result.entries); + }) + .catch(() => { + if (!cancelled) setEntries([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [client, isConnected]); + + const hasServer = routeServerId.length > 0; + + return ( + <> + + Providers + {!hasServer || !isConnected ? ( + + Connect to a host to see providers + + ) : loading ? ( + + Loading... + + ) : ( + + {AGENT_PROVIDER_DEFINITIONS.map((def) => { + const entry = entries.find((e) => e.provider === def.id); + const status = entry?.status ?? "unavailable"; + const ProviderIcon = getProviderIcon(def.id); + + return ( + + + + {def.label} + + + + + + + ); + })} + + )} + + + {diagnosticProvider ? ( + setDiagnosticProvider(null)} + serverId={routeServerId} + /> + ) : null} + + ); +} + interface DiagnosticsSectionProps { voiceAudioEngine: ReturnType; isPlaybackTestRunning: boolean; @@ -486,6 +604,7 @@ interface SettingsSectionContentProps { sectionId: SettingsSectionId; hostsProps: HostsSectionProps; appearanceProps: AppearanceSectionProps; + providersProps: ProvidersSectionProps; diagnosticsProps: DiagnosticsSectionProps; aboutProps: AboutSectionProps; appVersion: string | null; @@ -497,6 +616,7 @@ function SettingsSectionContent({ sectionId, hostsProps, appearanceProps, + providersProps, diagnosticsProps, aboutProps, appVersion, @@ -510,6 +630,8 @@ function SettingsSectionContent({ return ; case "shortcuts": return ; + case "providers": + return ; case "diagnostics": return ; case "about": @@ -567,7 +689,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout const isSelected = section.id === selectedSectionId; const IconComponent = section.icon; const showSeparator = - section.id === "integrations" || section.id === "diagnostics"; + section.id === "integrations" || section.id === "providers"; return ( {showSeparator ? : null} @@ -951,9 +1073,14 @@ export default function SettingsScreen() { isDesktopApp, }; + const providersProps: ProvidersSectionProps = { + routeServerId, + }; + const sectionContentProps: Omit = { hostsProps, appearanceProps, + providersProps, diagnosticsProps, aboutProps, appVersion, @@ -1761,6 +1888,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.base, }, + providerActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 7ec57e62a..c681c62eb 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -75,6 +75,7 @@ export function WorkspaceDraftAgentTab({ modeOptions, availableModels, allProviderModels, + allProviderEntries, isAllModelsLoading, availableThinkingOptions, isModelLoading, @@ -250,6 +251,60 @@ export function WorkspaceDraftAgentTab({ addImagesRef.current = addImages; }, []); + const focusInputRef = useRef<(() => void) | null>(null); + + const handleFocusInputCallback = useCallback((focus: () => void) => { + focusInputRef.current = focus; + }, []); + + const handleProviderSelectWithFocus = useCallback( + (provider: string) => { + setProviderFromUser(provider); + focusInputRef.current?.(); + }, + [setProviderFromUser], + ); + + const handleModeSelectWithFocus = useCallback( + (modeId: string) => { + setModeFromUser(modeId); + focusInputRef.current?.(); + }, + [setModeFromUser], + ); + + const handleModelSelectWithFocus = useCallback( + (modelId: string) => { + setModelFromUser(modelId); + focusInputRef.current?.(); + }, + [setModelFromUser], + ); + + const handleProviderAndModelSelectWithFocus = useCallback( + (provider: string, modelId: string) => { + setProviderAndModelFromUser(provider, modelId); + focusInputRef.current?.(); + }, + [setProviderAndModelFromUser], + ); + + const handleThinkingOptionSelectWithFocus = useCallback( + (optionId: string) => { + setThinkingOptionFromUser(optionId); + focusInputRef.current?.(); + }, + [setThinkingOptionFromUser], + ); + + const handleSetFeatureWithFocus = useCallback( + (featureId: string, value: unknown) => { + setDraftFeatureValue(featureId, value); + focusInputRef.current?.(); + }, + [setDraftFeatureValue], + ); + return ( @@ -296,26 +351,28 @@ export function WorkspaceDraftAgentTab({ clearDraft={draftInput.clear} autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} onAddImages={handleAddImagesCallback} + onFocusInput={handleFocusInputCallback} commandDraftConfig={draftCommandConfig} statusControls={{ providerDefinitions, selectedProvider, - onSelectProvider: setProviderFromUser, + onSelectProvider: handleProviderSelectWithFocus, modeOptions, selectedMode, - onSelectMode: setModeFromUser, + onSelectMode: handleModeSelectWithFocus, models: availableModels, selectedModel, - onSelectModel: setModelFromUser, + onSelectModel: handleModelSelectWithFocus, isModelLoading, allProviderModels, isAllModelsLoading, - onSelectProviderAndModel: setProviderAndModelFromUser, + onSelectProviderAndModel: handleProviderAndModelSelectWithFocus, thinkingOptions: availableThinkingOptions, selectedThinkingOptionId, - onSelectThinkingOption: setThinkingOptionFromUser, + onSelectThinkingOption: handleThinkingOptionSelectWithFocus, features: draftFeatures, - onSetFeature: setDraftFeatureValue, + onSetFeature: handleSetFeatureWithFocus, + onDropdownClose: () => focusInputRef.current?.(), disabled: isSubmitting, }} /> diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index e80908e99..11071eb16 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -21,6 +21,7 @@ import type { import type { FileDownloadTokenResponse, GitSetupOptions, + ServerInfoStatusPayload, ProjectPlacementPayload, ServerCapabilities, WorkspaceDescriptorPayload, @@ -190,6 +191,7 @@ export type DaemonServerInfo = { hostname: string | null; version: string | null; capabilities?: ServerCapabilities; + features?: ServerInfoStatusPayload["features"]; }; export interface AgentTimelineCursorState { @@ -412,6 +414,13 @@ function areServerCapabilitiesEqual( return JSON.stringify(current ?? null) === JSON.stringify(next ?? null); } +function areServerInfoFeaturesEqual( + current: ServerInfoStatusPayload["features"] | undefined, + next: ServerInfoStatusPayload["features"] | undefined, +): boolean { + return JSON.stringify(current ?? null) === JSON.stringify(next ?? null); +} + export const useSessionStore = create()( subscribeWithSelector((set, get) => { const commitActivityUpdates: AgentLastActivityCommitter = (updates) => { @@ -527,12 +536,15 @@ export const useSessionStore = create()( const prevVersion = session.serverInfo?.version?.trim() || null; const nextCapabilities = info.capabilities; const prevCapabilities = session.serverInfo?.capabilities; + const nextFeatures = info.features; + const prevFeatures = session.serverInfo?.features; if ( session.serverInfo?.serverId === info.serverId && prevHostname === nextHostname && prevVersion === nextVersion && - areServerCapabilitiesEqual(prevCapabilities, nextCapabilities) + areServerCapabilitiesEqual(prevCapabilities, nextCapabilities) && + areServerInfoFeaturesEqual(prevFeatures, nextFeatures) ) { return prev; } @@ -548,6 +560,7 @@ export const useSessionStore = create()( hostname: nextHostname, version: nextVersion, ...(nextCapabilities ? { capabilities: nextCapabilities } : {}), + ...(nextFeatures ? { features: nextFeatures } : {}), }, }, }, diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 9230d5fa1..942319554 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -43,6 +43,9 @@ import type { ListAvailableProvidersResponse, ListTerminalsResponse, CreateTerminalResponse, + GetProvidersSnapshotResponseMessage, + ProviderDiagnosticResponseMessage, + RefreshProvidersSnapshotResponseMessage, SubscribeTerminalResponse, TerminalState, CloseItemsResponse, @@ -147,6 +150,10 @@ export type DaemonEvent = requestId: string; resolution: AgentPermissionResponse; } + | { + type: "providers_snapshot_update"; + payload: Extract["payload"]; + } | { type: "error"; message: string }; export type DaemonEventHandler = (event: DaemonEvent) => void; @@ -222,6 +229,9 @@ type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload" type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; +type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"]; +type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; +type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< AgentSessionConfig, @@ -2552,6 +2562,51 @@ export class DaemonClient { }); } + async getProvidersSnapshot(options?: { + cwd?: string; + requestId?: string; + }): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "get_providers_snapshot_request", + cwd: options?.cwd, + }, + responseType: "get_providers_snapshot_response", + timeout: 10000, + }); + } + + async refreshProvidersSnapshot(options?: { + cwd?: string; + requestId?: string; + }): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "refresh_providers_snapshot_request", + cwd: options?.cwd, + }, + responseType: "refresh_providers_snapshot_response", + timeout: 5000, + }); + } + + async getProviderDiagnostic( + provider: AgentProvider, + options?: { requestId?: string }, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "provider_diagnostic_request", + provider, + }, + responseType: "provider_diagnostic_response", + timeout: 30000, + }); + } + async listCommands(agentId: string, requestId?: string): Promise; async listCommands(agentId: string, options?: ListCommandsOptions): Promise; async listCommands( @@ -3637,6 +3692,11 @@ export class DaemonClient { requestId: msg.payload.requestId, resolution: msg.payload.resolution, }; + case "providers_snapshot_update": + return { + type: "providers_snapshot_update", + payload: msg.payload, + }; default: return null; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 7823ae02f..5b91fa1e2 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -45,6 +45,8 @@ export type AgentMode = { description?: string; }; +export type ProviderStatus = "ready" | "loading" | "error" | "unavailable"; + export type AgentModelDefinition = { provider: AgentProvider; id: string; @@ -64,6 +66,15 @@ export type AgentSelectOption = { metadata?: AgentMetadata; }; +export interface ProviderSnapshotEntry { + provider: AgentProvider; + status: ProviderStatus; + error?: string; + models?: AgentModelDefinition[]; + modes?: AgentMode[]; + fetchedAt?: string; +} + export type AgentFeatureToggle = { type: "toggle"; id: string; @@ -468,4 +479,5 @@ export interface AgentClient { * Returns true if available, false otherwise. */ isAvailable(): Promise; + getDiagnostic?(): Promise<{ diagnostic: string }>; } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts new file mode 100644 index 000000000..4ea80bf3b --- /dev/null +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import type { + AgentClient, + AgentMode, + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "./agent-sdk-types.js"; +import type { ProviderDefinition } from "./provider-registry.js"; +import { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +type MockProviderOptions = { + provider: AgentProvider; + isAvailable?: () => Promise; + fetchModels?: (cwd?: string) => Promise; + fetchModes?: (cwd?: string) => Promise; +}; + +type MockProviderHandle = { + definition: ProviderDefinition; + isAvailable: ReturnType; + fetchModels: ReturnType; + fetchModes: ReturnType; +}; + +const TEST_CAPABILITIES = { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +} as const; + +describe("ProviderSnapshotManager", () => { + test("getSnapshot returns all providers in loading state initially and triggers warmUp", async () => { + const codexModels = deferred(); + const claudeModels = deferred(); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => codexModels.promise, + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => claudeModels.promise, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + const snapshot = manager.getSnapshot("/tmp/project"); + + expect(snapshot).toEqual([ + { provider: "claude", status: "loading" }, + { provider: "codex", status: "loading" }, + ]); + + await vi.waitFor(() => { + expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1); + expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); + }); + + manager.destroy(); + codexModels.resolve([]); + claudeModels.resolve([]); + }); + + test("after warmUp completes, getSnapshot returns ready entries with models", async () => { + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => [createModel("codex", "gpt-5.2")], + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => [createModel("claude", "sonnet")], + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "claude")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); + }); + + const snapshot = manager.getSnapshot("/tmp/project"); + expect(getProviderEntry(snapshot, "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "gpt-5.2")], + modes: [createMode("auto")], + }); + expect(getProviderEntry(snapshot, "claude")).toMatchObject({ + provider: "claude", + status: "ready", + models: [createModel("claude", "sonnet")], + modes: [createMode("default")], + }); + expect(getProviderEntry(snapshot, "codex")?.fetchedAt).toEqual(expect.any(String)); + + manager.destroy(); + }); + + test("provider that fails isAvailable shows as unavailable", async () => { + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + isAvailable: async () => false, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { provider: "codex", status: "unavailable" }, + ]); + }); + + expect(handles.codex?.fetchModels).not.toHaveBeenCalled(); + expect(handles.codex?.fetchModes).not.toHaveBeenCalled(); + + manager.destroy(); + }); + + test("provider that fails fetchModels shows as error with error message", async () => { + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => { + throw new Error("model lookup failed"); + }, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { + provider: "codex", + status: "error", + error: "model lookup failed", + }, + ]); + }); + + manager.destroy(); + }); + + test("change event fires for each provider as it resolves", async () => { + const codexModels = deferred(); + const claudeModels = deferred(); + const codexModes = deferred(); + const claudeModes = deferred(); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => codexModels.promise, + fetchModes: async () => codexModes.promise, + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => claudeModels.promise, + fetchModes: async () => claudeModes.promise, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + const changes: Array<{ cwd: string; entries: ProviderSnapshotEntry[] }> = []; + const listener = (entries: ProviderSnapshotEntry[], cwd: string) => { + changes.push({ cwd, entries }); + }; + manager.on("change", listener); + + manager.getSnapshot("/tmp/project"); + + claudeModels.resolve([createModel("claude", "sonnet")]); + claudeModes.resolve([createMode("default")]); + + await vi.waitFor(() => { + expect(changes).toHaveLength(1); + }); + + expect(changes[0]?.cwd).toBe("/tmp/project"); + expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready"); + expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading"); + + codexModels.resolve([createModel("codex", "gpt-5.2")]); + codexModes.resolve([createMode("auto")]); + + await vi.waitFor(() => { + expect(changes).toHaveLength(2); + }); + + expect(getProviderEntry(changes[1]?.entries ?? [], "codex")?.status).toBe("ready"); + expect(getProviderEntry(changes[1]?.entries ?? [], "claude")?.status).toBe("ready"); + + manager.off("change", listener); + manager.destroy(); + }); + + test("refresh re-fetches and updates entries", async () => { + const codexFetchModels = vi + .fn<(options?: { cwd?: string }) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => codexFetchModels({ cwd }), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + manager.refresh("/tmp/project"); + expect(manager.getSnapshot("/tmp/project")).toEqual([ + { provider: "codex", status: "loading" }, + ]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + expect(codexFetchModels).toHaveBeenCalledTimes(2); + + manager.destroy(); + }); + + test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => { + const codexModels = deferred(); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => codexModels.promise, + }), + createMockProvider({ + provider: "claude", + fetchModels: async () => [], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project"); + manager.getSnapshot("/tmp/project"); + manager.getSnapshot("/tmp/project"); + + await vi.waitFor(() => { + expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); + expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1); + expect(handles.claude?.fetchModels).toHaveBeenCalledTimes(1); + }); + + codexModels.resolve([createModel("codex", "gpt-5.2")]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); + }); + + manager.destroy(); + }); + + test("different cwd keys get independent snapshots", async () => { + const seenCwds: string[] = []; + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => { + seenCwds.push(cwd ?? "__missing__"); + return [createModel("codex", `model:${cwd}`)]; + }, + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot("/tmp/project-a"); + manager.getSnapshot("/tmp/project-b"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe("ready"); + }); + + expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe( + "model:/tmp/project-a", + ); + expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.models?.[0]?.id).toBe( + "model:/tmp/project-b", + ); + expect(seenCwds).toEqual(["/tmp/project-a", "/tmp/project-b"]); + + manager.destroy(); + }); +}); + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function createRegistry(handles: MockProviderHandle[]): { + registry: Record; + handles: Record; +} { + return { + registry: Object.fromEntries( + handles.map((handle) => [handle.definition.id, handle.definition]), + ) as Record, + handles: Object.fromEntries( + handles.map((handle) => [handle.definition.id, handle]), + ) as Record, + }; +} + +function createMockProvider(options: MockProviderOptions): MockProviderHandle { + const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true); + const fetchModels = vi.fn(async (listOptions?: { cwd?: string }) => + options.fetchModels?.(listOptions?.cwd) ?? [createModel(options.provider, `${options.provider}-default`)], + ); + const fetchModes = vi.fn(async (listOptions?: { cwd?: string }) => + options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)], + ); + + const definition: ProviderDefinition = { + id: options.provider, + label: options.provider, + description: `${options.provider} test provider`, + defaultModeId: null, + modes: [], + createClient: () => + ({ + provider: options.provider, + capabilities: TEST_CAPABILITIES, + async createSession() { + throw new Error("not implemented"); + }, + async resumeSession() { + throw new Error("not implemented"); + }, + async listModels() { + return []; + }, + async isAvailable() { + return isAvailable(); + }, + }) satisfies AgentClient, + fetchModels, + fetchModes, + }; + + return { + definition, + isAvailable, + fetchModels, + fetchModes, + }; +} + +function createModel(provider: AgentProvider, id: string): AgentModelDefinition { + return { + provider, + id, + label: id, + }; +} + +function createMode(id: string): AgentMode { + return { + id, + label: id, + }; +} + +function getProviderEntry( + entries: ProviderSnapshotEntry[], + provider: AgentProvider, +): ProviderSnapshotEntry | undefined { + return entries.find((entry) => entry.provider === provider); +} diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts new file mode 100644 index 000000000..dded5a2b4 --- /dev/null +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -0,0 +1,217 @@ +import { EventEmitter } from "node:events"; +import { resolve } from "node:path"; + +import type { Logger } from "pino"; + +import type { + AgentProvider, + ProviderSnapshotEntry, +} from "./agent-sdk-types.js"; +import type { ProviderDefinition } from "./provider-registry.js"; +import { AGENT_PROVIDER_IDS } from "./provider-manifest.js"; + +const DEFAULT_CWD_KEY = "__default__"; + +type ProviderSnapshotChangeListener = ( + entries: ProviderSnapshotEntry[], + cwd?: string, +) => void; + +export class ProviderSnapshotManager { + private readonly snapshots = new Map>(); + private readonly warmUps = new Map>(); + private readonly events = new EventEmitter(); + private destroyed = false; + + constructor( + private readonly providerRegistry: Record, + private readonly logger: Logger, + ) {} + + getSnapshot(cwd?: string): ProviderSnapshotEntry[] { + const cwdKey = normalizeCwdKey(cwd); + const entries = this.snapshots.get(cwdKey); + if (!entries) { + const loadingEntries = this.createLoadingEntries(); + this.snapshots.set(cwdKey, loadingEntries); + void this.warmUp(cwd); + return entriesToArray(loadingEntries); + } + return entriesToArray(entries); + } + + refresh(cwd?: string): void { + const cwdKey = normalizeCwdKey(cwd); + this.snapshots.set(cwdKey, this.createLoadingEntries()); + void this.warmUp(cwd); + } + + on(event: "change", listener: ProviderSnapshotChangeListener): this { + this.events.on(event, listener); + return this; + } + + off(event: "change", listener: ProviderSnapshotChangeListener): this { + this.events.off(event, listener); + return this; + } + + destroy(): void { + this.destroyed = true; + this.events.removeAllListeners(); + this.snapshots.clear(); + this.warmUps.clear(); + } + + private createLoadingEntries(): Map { + const entries = new Map(); + for (const provider of this.getProviderIds()) { + entries.set(provider, { + provider, + status: "loading", + }); + } + return entries; + } + + private async warmUp(cwd?: string): Promise { + const cwdKey = normalizeCwdKey(cwd); + const inFlight = this.warmUps.get(cwdKey); + if (inFlight) { + return inFlight; + } + + const warmUpPromise = Promise.allSettled( + this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)), + ).then(() => undefined); + + this.warmUps.set(cwdKey, warmUpPromise); + + try { + await warmUpPromise; + } finally { + if (this.warmUps.get(cwdKey) === warmUpPromise) { + this.warmUps.delete(cwdKey); + } + } + } + + private async refreshProvider( + cwdKey: string, + provider: AgentProvider, + cwd?: string, + ): Promise { + const definition = this.providerRegistry[provider]; + if (!definition) { + return; + } + + const snapshot = this.getOrCreateSnapshot(cwdKey); + snapshot.set(provider, { + provider, + status: "loading", + }); + + try { + const client = definition.createClient(this.logger); + const available = await client.isAvailable(); + if (!available) { + snapshot.set(provider, { + provider, + status: "unavailable", + }); + this.emitChange(cwdKey); + return; + } + + const [models, modes] = await Promise.all([ + definition.fetchModels({ cwd }), + definition.fetchModes({ cwd }), + ]); + + snapshot.set(provider, { + provider, + status: "ready", + models, + modes, + fetchedAt: new Date().toISOString(), + }); + this.emitChange(cwdKey); + } catch (error) { + snapshot.set(provider, { + provider, + status: "error", + error: toErrorMessage(error), + }); + this.logger.warn({ err: error, provider, cwd: cwdKey }, "Failed to refresh provider snapshot"); + this.emitChange(cwdKey); + } + } + + private emitChange(cwdKey: string): void { + if (this.destroyed) { + return; + } + const snapshot = this.snapshots.get(cwdKey); + if (!snapshot) { + return; + } + this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey)); + } + + private getOrCreateSnapshot(cwdKey: string): Map { + const existing = this.snapshots.get(cwdKey); + if (existing) { + return existing; + } + + const created = this.createLoadingEntries(); + this.snapshots.set(cwdKey, created); + return created; + } + + private getProviderIds(): AgentProvider[] { + return AGENT_PROVIDER_IDS.filter((provider) => this.providerRegistry[provider]); + } +} + +function normalizeCwdKey(cwd?: string): string { + if (!cwd) { + return DEFAULT_CWD_KEY; + } + + const trimmed = cwd.trim(); + if (!trimmed) { + return DEFAULT_CWD_KEY; + } + + return resolve(trimmed); +} + +function denormalizeCwdKey(cwdKey: string): string | undefined { + return cwdKey === DEFAULT_CWD_KEY ? undefined : cwdKey; +} + +function entriesToArray( + entries: Map, +): ProviderSnapshotEntry[] { + return Array.from(entries.values(), cloneEntry); +} + +function cloneEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry { + return { + ...entry, + models: entry.models?.map((model) => ({ ...model })), + modes: entry.modes?.map((mode) => ({ ...mode })), + }; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === "string" && error) { + return error; + } + return "Unknown error"; +} diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index e1b4e8b19..68ddee65e 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; @@ -39,6 +39,12 @@ import { } from "./claude/claude-models.js"; import { parsePartialJsonObject } from "./claude/partial-json.js"; import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; import type { AgentCapabilityFlags, @@ -1086,6 +1092,42 @@ export class ClaudeAgentClient implements AgentClient { return true; } + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const resolvedBinary = findExecutable("claude") ?? "not found"; + const available = await this.isAvailable(); + const version = resolveClaudeVersion(this.runtimeSettings); + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + } + + return { + diagnostic: formatProviderDiagnostic("Claude Code", [ + { label: "Binary", value: resolvedBinary }, + ...(version ? [{ label: "Version", value: version }] : []), + { label: "Models", value: modelsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Claude Code", error), + }; + } + } + private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig { if (config.provider !== "claude") { throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`); @@ -1094,6 +1136,31 @@ export class ClaudeAgentClient implements AgentClient { } } +function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string | null { + const command = runtimeSettings?.command; + + try { + if (command?.mode === "replace") { + return execFileSync(command.argv[0]!, [...command.argv.slice(1), "--version"], { + encoding: "utf8", + timeout: 5_000, + }).trim() || null; + } + + const executable = findExecutable("claude"); + if (!executable) { + return null; + } + + return execFileSync(executable, ["--version"], { + encoding: "utf8", + timeout: 5_000, + }).trim() || null; + } catch { + return null; + } +} + class ClaudeAgentSession implements AgentSession { readonly provider: "claude" = "claude"; readonly capabilities = CLAUDE_CAPABILITIES; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index bf8d3e086..1b49b62df 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -52,6 +52,13 @@ import { } from "../../../utils/executable.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000; @@ -3947,6 +3954,49 @@ export class CodexAppServerAgentClient implements AgentClient { } return true; } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const resolvedBinary = findExecutable("codex"); + const entries: Array<{ label: string; value: string }> = [ + { + label: "Binary", + value: resolvedBinary ?? "not found", + }, + { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + ]; + let status = formatDiagnosticStatus(available); + + if (!available) { + entries.push({ label: "Models", value: "Not checked" }); + } else { + try { + const models = await this.listModels(); + entries.push({ label: "Models", value: String(models.length) }); + } catch (error) { + entries.push({ + label: "Models", + value: `Error - ${toDiagnosticErrorMessage(error)}`, + }); + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + } + + entries.push({ label: "Status", value: status }); + + return { + diagnostic: formatProviderDiagnostic("Codex", entries), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Codex", error), + }; + } + } } export const __codexAppServerInternals = { diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts index e282c51cf..395508336 100644 --- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -2,7 +2,15 @@ import type { Logger } from "pino"; import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; +import { findExecutable } from "../../../utils/executable.js"; import { ACPAgentClient } from "./acp-agent.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const COPILOT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -51,4 +59,53 @@ export class CopilotACPAgentClient extends ACPAgentClient { override async isAvailable(): Promise { return super.isAvailable(); } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const resolvedBinary = findExecutable("copilot"); + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + + if (!modelsValue.startsWith("Error -")) { + try { + await this.listModes(); + } catch (error) { + status = formatDiagnosticStatus(available, { + source: "mode fetch", + cause: error, + }); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("Copilot", [ + { + label: "Binary", + value: resolvedBinary ?? "not found", + }, + { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + { label: "Models", value: modelsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Copilot", error), + }; + } + } } diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.ts b/packages/server/src/server/agent/providers/diagnostic-utils.ts new file mode 100644 index 000000000..af077b271 --- /dev/null +++ b/packages/server/src/server/agent/providers/diagnostic-utils.ts @@ -0,0 +1,80 @@ +import { execFileSync } from "node:child_process"; + +import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; + +type DiagnosticEntry = { + label: string; + value: string; +}; + +export function formatProviderDiagnostic( + providerName: string, + entries: DiagnosticEntry[], +): string { + return [providerName, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n"); +} + +export function formatProviderDiagnosticError( + providerName: string, + error: unknown, +): string { + return formatProviderDiagnostic(providerName, [ + { + label: "Error", + value: error instanceof Error ? error.message : String(error), + }, + ]); +} + +export function formatAvailabilityStatus(available: boolean): string { + return available ? "Available" : "Unavailable"; +} + +export function formatDiagnosticStatus( + available: boolean, + error?: { source: string; cause: unknown }, +): string { + if (error) { + return `Error (${error.source} failed: ${toDiagnosticErrorMessage(error.cause)})`; + } + return formatAvailabilityStatus(available); +} + +export function toDiagnosticErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + return "Unknown error"; +} + +export function resolveBinaryVersion(binaryPath: string): string { + try { + return ( + execFileSync(binaryPath, ["--version"], { + encoding: "utf8", + timeout: 5_000, + }).trim() || "unknown" + ); + } catch { + return "unknown"; + } +} + +export function formatConfiguredCommand( + defaultArgv: readonly string[], + runtimeSettings?: ProviderRuntimeSettings, +): string { + const command = runtimeSettings?.command; + if (!command || command.mode === "default") { + return `${defaultArgv.join(" ")} (default)`; + } + + if (command.mode === "append") { + return [defaultArgv[0], ...(command.args ?? []), ...defaultArgv.slice(1)].join(" "); + } + + return command.argv.join(" "); +} diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d0f47b3e5..c022caa57 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -43,6 +43,13 @@ import { quoteWindowsCommand, } from "../../../utils/executable.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -615,6 +622,63 @@ export class OpenCodeAgentClient implements AgentClient { return true; } + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const resolvedBinary = findExecutable("opencode"); + let serverStatus = "Not running"; + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + try { + const { url } = await this.serverManager.ensureRunning(); + serverStatus = `Running (${url})`; + } catch (error) { + serverStatus = `Unavailable (${normalizeTurnFailureError(error)})`; + } + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + + if (!modelsValue.startsWith("Error -")) { + try { + await this.listModes(); + } catch (error) { + status = formatDiagnosticStatus(available, { + source: "mode fetch", + cause: error, + }); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("OpenCode", [ + { + label: "Binary", + value: resolvedBinary ?? "not found", + }, + { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + { label: "Server", value: serverStatus }, + { label: "Models", value: modelsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("OpenCode", error), + }; + } + } private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig { if (config.provider !== "opencode") { throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); diff --git a/packages/server/src/server/agent/providers/pi-acp-agent.ts b/packages/server/src/server/agent/providers/pi-acp-agent.ts index baab3b48a..7b3d99ad1 100644 --- a/packages/server/src/server/agent/providers/pi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/pi-acp-agent.ts @@ -29,12 +29,19 @@ import type { AgentFeatureSelect, } from "../agent-sdk-types.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { isCommandAvailable } from "../../../utils/executable.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { ACPAgentClient, type ACPToolSnapshot, type SessionStateResponse, } from "./acp-agent.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const require = createRequire(import.meta.url); const resolvedPiAcpPath = require.resolve("pi-acp"); @@ -329,4 +336,85 @@ export class PiACPAgentClient extends ACPAgentClient { existsSync(join(homedir(), ".pi", "agent", "auth.json")) ); } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const piCommand = process.env.PI_ACP_PI_COMMAND ?? "pi"; + const piCliPath = findExecutable(piCommand); + const piVersion = piCliPath ? resolveBinaryVersion(piCliPath) : "unknown"; + const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); + const available = await this.isAvailable(); + let modelsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (available) { + try { + const models = await this.listModels(); + modelsValue = String(models.length); + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + + if (!modelsValue.startsWith("Error -")) { + try { + await this.listModes(); + } catch (error) { + status = formatDiagnosticStatus(available, { + source: "mode fetch", + cause: error, + }); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("Pi", [ + { + label: "pi-acp module", + value: existsSync(resolvedPiAcpPath) ? "found" : "not found", + }, + { + label: "Binary", + value: piCliPath ?? "not found", + }, + { + label: "Version", + value: piVersion, + }, + { + label: "OPENAI_API_KEY", + value: process.env.OPENAI_API_KEY ? "set" : "not set", + }, + { + label: "ANTHROPIC_API_KEY", + value: process.env.ANTHROPIC_API_KEY ? "set" : "not set", + }, + { + label: "OPENROUTER_API_KEY", + value: process.env.OPENROUTER_API_KEY ? "set" : "not set", + }, + { + label: "Auth config (~/.pi/agent/auth.json)", + value: existsSync(authConfigPath) ? "found" : "not found", + }, + { + label: "Models", + value: modelsValue, + }, + { + label: "Status", + value: status, + }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Pi", error), + }; + } + } } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 37fa4ee4a..a647e6495 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,6 +68,7 @@ export type AgentMcpTransportFactory = () => Promise; import { buildProviderRegistry } from "./agent/provider-registry.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { AgentManager } from "./agent/agent-manager.js"; +import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { AgentTimelineCursor, AgentTimelineFetchDirection, @@ -101,6 +102,7 @@ import type { AgentStreamEvent, AgentProvider, AgentPersistenceHandle, + ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; @@ -398,6 +400,7 @@ export type SessionOptions = { stt: Resolvable; tts: Resolvable; terminalManager: TerminalManager | null; + providerSnapshotManager?: ProviderSnapshotManager; voice?: { voiceAgentMcpStdio?: VoiceMcpStdioConfig | null; turnDetection?: Resolvable; @@ -594,6 +597,8 @@ export class Session { } | null = null; private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly terminalManager: TerminalManager | null; + private readonly providerSnapshotManager: ProviderSnapshotManager | null; + private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private readonly subscribedTerminalDirectories = new Set(); private unsubscribeTerminalsChanged: (() => void) | null = null; private terminalExitSubscriptions: Map void> = new Map(); @@ -646,6 +651,7 @@ export class Session { stt, tts, terminalManager, + providerSnapshotManager, voice, voiceBridge, dictation, @@ -671,11 +677,32 @@ export class Session { this.checkoutDiffManager = checkoutDiffManager; this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; + this.providerSnapshotManager = providerSnapshotManager ?? null; if (this.terminalManager) { this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => this.handleTerminalsChanged(event), ); } + if (this.providerSnapshotManager) { + const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd?: string) => { + // COMPAT(providersSnapshot): keep provider visibility gating for older clients. + const visibleEntries = entries.filter((entry) => + this.isProviderVisibleToClient(entry.provider), + ); + this.emit({ + type: "providers_snapshot_update", + payload: { + cwd, + entries: visibleEntries, + generatedAt: new Date().toISOString(), + }, + }); + }; + this.providerSnapshotManager.on("change", handleProviderSnapshotChange); + this.unsubscribeProviderSnapshotEvents = () => { + this.providerSnapshotManager?.off("change", handleProviderSnapshotChange); + }; + } this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null; this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null); this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler; @@ -1751,6 +1778,18 @@ export class Session { await this.handleListAvailableProvidersRequest(msg); break; + case "get_providers_snapshot_request": + await this.handleGetProvidersSnapshotRequest(msg); + break; + + case "refresh_providers_snapshot_request": + await this.handleRefreshProvidersSnapshotRequest(msg); + break; + + case "provider_diagnostic_request": + await this.handleProviderDiagnosticRequest(msg); + break; + case "clear_agent_attention": await this.handleClearAgentAttention(msg.agentId); break; @@ -3250,6 +3289,73 @@ export class Session { } } + private async handleGetProvidersSnapshotRequest( + msg: Extract, + ): Promise { + // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. + const entries = this.providerSnapshotManager + ? this.providerSnapshotManager + .getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined) + .filter((entry) => this.isProviderVisibleToClient(entry.provider)) + : []; + + this.emit({ + type: "get_providers_snapshot_response", + payload: { + entries, + generatedAt: new Date().toISOString(), + requestId: msg.requestId, + }, + }); + } + + private async handleRefreshProvidersSnapshotRequest( + msg: Extract, + ): Promise { + this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined); + this.emit({ + type: "refresh_providers_snapshot_response", + payload: { + acknowledged: true, + requestId: msg.requestId, + }, + }); + } + + private async handleProviderDiagnosticRequest( + msg: Extract, + ): Promise { + try { + const client = this.providerRegistry[msg.provider].createClient(this.sessionLogger); + const diagnostic = client.getDiagnostic + ? (await client.getDiagnostic()).diagnostic + : "No diagnostic available for this provider."; + this.emit({ + type: "provider_diagnostic_response", + payload: { + provider: msg.provider, + diagnostic, + requestId: msg.requestId, + }, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.sessionLogger.error( + { err, provider: msg.provider }, + `Failed to get provider diagnostic for ${msg.provider}`, + ); + this.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: `Failed to get provider diagnostic: ${err.message}`, + code: "provider_diagnostic_failed", + }, + }); + } + } + private assertSafeGitRef(ref: string, label: string): void { if (!/^[A-Za-z0-9._/-]+$/.test(ref)) { throw new Error(`Invalid ${label}: ${ref}`); @@ -7014,6 +7120,10 @@ export class Session { this.unsubscribeAgentEvents(); this.unsubscribeAgentEvents = null; } + if (this.unsubscribeProviderSnapshotEvents) { + this.unsubscribeProviderSnapshotEvents(); + this.unsubscribeProviderSnapshotEvents = null; + } // Abort any ongoing operations this.abortController.abort(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7b024e446..00c636e34 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -31,6 +31,8 @@ import { isHostAllowed } from "./allowed-hosts.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; +import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { buildProviderRegistry } from "./agent/provider-registry.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; @@ -253,6 +255,7 @@ export class VoiceAssistantWebSocketServer { private readonly voiceSpeakHandlers = new Map(); private readonly voiceCallerContexts = new Map(); private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; + private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private serverCapabilities: ServerCapabilities | undefined; private runtimeWindowStartedAt = Date.now(); @@ -343,6 +346,13 @@ export class VoiceAssistantWebSocketServer { this.voice = voice ?? null; this.dictation = dictation ?? null; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; + const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" }); + this.providerSnapshotManager = new ProviderSnapshotManager( + buildProviderRegistry(providerSnapshotLogger, { + runtimeSettings: this.agentProviderRuntimeSettings, + }), + providerSnapshotLogger, + ); this.onLifecycleIntent = onLifecycleIntent ?? null; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, @@ -496,6 +506,7 @@ export class VoiceAssistantWebSocketServer { } await Promise.all(cleanupPromises); + this.providerSnapshotManager.destroy(); this.checkoutDiffManager.dispose(); this.pendingConnections.clear(); this.sessions.clear(); @@ -646,6 +657,7 @@ export class VoiceAssistantWebSocketServer { stt: () => this.speech?.resolveStt() ?? null, tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, + providerSnapshotManager: this.providerSnapshotManager, voice: { ...(this.voice ?? {}), turnDetection: () => this.speech?.resolveTurnDetection() ?? null, @@ -792,6 +804,10 @@ export class VoiceAssistantWebSocketServer { hostname: getHostname(), version: this.daemonVersion, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), + features: { + // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. + providersSnapshot: true, + }, }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 9e256dbf2..156ce6510 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -54,6 +54,8 @@ import type { AgentPermissionRequest, AgentPermissionResponse, AgentPersistenceHandle, + ProviderSnapshotEntry, + ProviderStatus, AgentRuntimeInfo, AgentTimelineItem, ToolCallDetail, @@ -69,6 +71,13 @@ const AgentModeSchema: z.ZodType = z.object({ description: z.string().optional(), }); +const ProviderStatusSchema: z.ZodType = z.enum([ + "ready", + "loading", + "error", + "unavailable", +]); + const AgentSelectOptionSchema = z.object({ id: z.string(), label: z.string(), @@ -114,6 +123,15 @@ const AgentModelDefinitionSchema: z.ZodType = z.object({ defaultThinkingOptionId: z.string().optional(), }); +const ProviderSnapshotEntrySchema: z.ZodType = z.object({ + provider: AgentProviderSchema, + status: ProviderStatusSchema, + error: z.string().optional(), + models: z.array(AgentModelDefinitionSchema).optional(), + modes: z.array(AgentModeSchema).optional(), + fetchedAt: z.string().optional(), +}); + const AgentCapabilityFlagsSchema: z.ZodType = z.object({ supportsStreaming: z.boolean(), supportsSessionPersistence: z.boolean(), @@ -772,6 +790,24 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({ requestId: z.string(), }); +export const GetProvidersSnapshotRequestMessageSchema = z.object({ + type: z.literal("get_providers_snapshot_request"), + cwd: z.string().optional(), + requestId: z.string(), +}); + +export const RefreshProvidersSnapshotRequestMessageSchema = z.object({ + type: z.literal("refresh_providers_snapshot_request"), + cwd: z.string().optional(), + requestId: z.string(), +}); + +export const ProviderDiagnosticRequestMessageSchema = z.object({ + type: z.literal("provider_diagnostic_request"), + provider: AgentProviderSchema, + requestId: z.string(), +}); + export const ResumeAgentRequestMessageSchema = z.object({ type: z.literal("resume_agent_request"), handle: AgentPersistenceHandleSchema, @@ -1275,6 +1311,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModesRequestMessageSchema, ListProviderFeaturesRequestMessageSchema, ListAvailableProvidersRequestMessageSchema, + GetProvidersSnapshotRequestMessageSchema, + RefreshProvidersSnapshotRequestMessageSchema, + ProviderDiagnosticRequestMessageSchema, ResumeAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, CancelAgentRequestMessageSchema, @@ -1500,6 +1539,12 @@ export const ServerInfoStatusPayloadSchema = z hostname: ServerInfoHostnameSchema.optional(), version: ServerInfoVersionSchema.optional(), capabilities: ServerCapabilitiesFromUnknownSchema, + // COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot + features: z + .object({ + providersSnapshot: z.boolean().optional(), + }) + .optional(), }) .passthrough() .transform((payload) => ({ @@ -2235,6 +2280,45 @@ export const ListAvailableProvidersResponseSchema = z.object({ }), }); +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot +export const GetProvidersSnapshotResponseMessageSchema = z.object({ + type: z.literal("get_providers_snapshot_response"), + payload: z.object({ + entries: z.array(ProviderSnapshotEntrySchema), + generatedAt: z.string(), + requestId: z.string(), + }), +}); + +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot +export const ProvidersSnapshotUpdateMessageSchema = z.object({ + type: z.literal("providers_snapshot_update"), + payload: z.object({ + cwd: z.string().optional(), + entries: z.array(ProviderSnapshotEntrySchema), + generatedAt: z.string(), + }), +}); + +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot +export const RefreshProvidersSnapshotResponseMessageSchema = z.object({ + type: z.literal("refresh_providers_snapshot_response"), + payload: z.object({ + requestId: z.string(), + acknowledged: z.boolean(), + }), +}); + +// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot +export const ProviderDiagnosticResponseMessageSchema = z.object({ + type: z.literal("provider_diagnostic_response"), + payload: z.object({ + provider: AgentProviderSchema, + diagnostic: z.string(), + requestId: z.string(), + }), +}); + const AgentSlashCommandSchema = z.object({ name: z.string(), description: z.string(), @@ -2428,6 +2512,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModesResponseMessageSchema, ListProviderFeaturesResponseMessageSchema, ListAvailableProvidersResponseSchema, + GetProvidersSnapshotResponseMessageSchema, + ProvidersSnapshotUpdateMessageSchema, + RefreshProvidersSnapshotResponseMessageSchema, + ProviderDiagnosticResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, TerminalsChangedSchema, @@ -2507,6 +2595,16 @@ export type ListProviderFeaturesResponseMessage = z.infer< typeof ListProviderFeaturesResponseMessageSchema >; export type ListAvailableProvidersResponse = z.infer; +export type GetProvidersSnapshotResponseMessage = z.infer< + typeof GetProvidersSnapshotResponseMessageSchema +>; +export type ProvidersSnapshotUpdateMessage = z.infer; +export type RefreshProvidersSnapshotResponseMessage = z.infer< + typeof RefreshProvidersSnapshotResponseMessageSchema +>; +export type ProviderDiagnosticResponseMessage = z.infer< + typeof ProviderDiagnosticResponseMessageSchema +>; export type ChatCreateResponse = z.infer; export type ChatListResponse = z.infer; export type ChatInspectResponse = z.infer; @@ -2554,6 +2652,15 @@ export type ListProviderFeaturesRequestMessage = z.infer< export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema >; +export type GetProvidersSnapshotRequestMessage = z.infer< + typeof GetProvidersSnapshotRequestMessageSchema +>; +export type RefreshProvidersSnapshotRequestMessage = z.infer< + typeof RefreshProvidersSnapshotRequestMessageSchema +>; +export type ProviderDiagnosticRequestMessage = z.infer< + typeof ProviderDiagnosticRequestMessageSchema +>; export type ChatCreateRequest = z.infer; export type ChatListRequest = z.infer; export type ChatInspectRequest = z.infer; From 8c2ea33da8cfc7971effce822e6385d3de40cd72 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 15:38:22 +0700 Subject: [PATCH 5/8] fix(app): restore input focus for running agents and align mobile model selector - Add onDropdownClose callback to AgentStatusBar and wire through ControlledStatusBar to CombinedModelSelector so running agents focus input after any dropdown closes - Fix mobile misalignment in model selector by adding marginHorizontal: spacing[1] to sectionHeading, drillDownRow, backButton, and providerSearchContainer to match ComboboxItem's mobile margins --- packages/app/src/components/agent-input-area.tsx | 2 +- packages/app/src/components/agent-status-bar.tsx | 6 +++++- packages/app/src/components/combined-model-selector.tsx | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 125a31448..1e41fd620 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -640,7 +640,7 @@ export function AgentInputArea({ resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( ) : ( - + ); return ( diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index bca90dcb0..25bd5f5eb 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -116,6 +116,7 @@ export interface DraftAgentStatusBarProps { interface AgentStatusBarProps { agentId: string; serverId: string; + onDropdownClose?: () => void; } function findOptionLabel( @@ -409,6 +410,7 @@ function ControlledStatusBar({ onToggleFavorite={onToggleFavoriteModel} isLoading={isModelLoading} disabled={modelDisabled} + onClose={onDropdownClose} /> @@ -655,6 +657,7 @@ function ControlledStatusBar({ onToggleFavorite={onToggleFavoriteModel} isLoading={isModelLoading} disabled={modelDisabled} + onClose={onDropdownClose} renderTrigger={({ selectedModelLabel }) => ( { @@ -1026,6 +1029,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { }); }} isModelLoading={isProviderModelsQueryLoading(modelsQuery)} + onDropdownClose={onDropdownClose} disabled={!client} /> ); diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 442be354e..a8aaa051d 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -22,6 +22,8 @@ import type { AgentProvider, } from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; +const IS_WEB = Platform.OS === "web"; + import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { getProviderIcon } from "@/components/provider-icons"; @@ -754,6 +756,7 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[1], + ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, sectionHeadingText: { fontSize: theme.fontSize.xs, @@ -767,6 +770,7 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], minHeight: 36, + ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, drillDownRowHovered: { backgroundColor: theme.colors.surface1, @@ -801,6 +805,7 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, + ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, backButtonHovered: { backgroundColor: theme.colors.surface2, @@ -853,6 +858,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", paddingHorizontal: theme.spacing[3], gap: theme.spacing[2], + ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, providerSearchInput: { flex: 1, From 0130a637c800e7723f392e48db67e55be1084708 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 15:39:55 +0700 Subject: [PATCH 6/8] docs: add 0.1.48 changelog --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a97f1ea..d1c22d94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.1.48 - 2026-04-05 + +### Added +- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available. +- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline. +- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow. +- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app. + +### Improved +- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile. +- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow. +- Removed noisy agent lifecycle toasts that fired on every state change. + +### Fixed +- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution. +- Input field on running agent screens now correctly receives keyboard focus. +- Mobile model selector alignment and sizing. + ## 0.1.47 - 2026-04-05 ### Fixed From c4cccf5bd2740b30eb7be67d0a4b459c62168055 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 15:40:54 +0700 Subject: [PATCH 7/8] chore(release): cut 0.1.48 --- 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 d9d03f465..3a83c6663 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.47", + "version": "0.1.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.47", + "version": "0.1.48", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34906,16 +34906,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.47", + "version": "0.1.48", "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.47", - "@getpaseo/highlight": "0.1.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/expo-two-way-audio": "0.1.48", + "@getpaseo/highlight": "0.1.48", + "@getpaseo/server": "0.1.48", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35032,11 +35032,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.47", + "version": "0.1.48", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/relay": "0.1.48", + "@getpaseo/server": "0.1.48", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35077,11 +35077,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.47", + "version": "0.1.48", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/cli": "0.1.48", + "@getpaseo/server": "0.1.48", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35115,7 +35115,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.47", + "version": "0.1.48", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35316,7 +35316,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.47", + "version": "0.1.48", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35342,7 +35342,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.47", + "version": "0.1.48", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35358,14 +35358,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.47", + "version": "0.1.48", "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.47", - "@getpaseo/relay": "0.1.47", + "@getpaseo/highlight": "0.1.48", + "@getpaseo/relay": "0.1.48", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35764,7 +35764,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.47", + "version": "0.1.48", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index c3b9f4162..ce9c87bc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.47", + "version": "0.1.48", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index bbf1ee2ca..cdaf19c1b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.47", + "version": "0.1.48", "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.47", - "@getpaseo/highlight": "0.1.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/expo-two-way-audio": "0.1.48", + "@getpaseo/highlight": "0.1.48", + "@getpaseo/server": "0.1.48", "@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 a0008a23b..0219ade5d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.47", + "version": "0.1.48", "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.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/relay": "0.1.48", + "@getpaseo/server": "0.1.48", "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 651eeca73..39fd3571f 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.47", + "version": "0.1.48", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.47", - "@getpaseo/server": "0.1.47", + "@getpaseo/cli": "0.1.48", + "@getpaseo/server": "0.1.48", "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 3319eb6c2..43e2e1144 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.47", + "version": "0.1.48", "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 970f94e6d..68b05ea78 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.47", + "version": "0.1.48", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index a91b06fd6..aaa606040 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.47", + "version": "0.1.48", "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 25dea5f01..e55f219df 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.47", + "version": "0.1.48", "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.47", - "@getpaseo/relay": "0.1.47", + "@getpaseo/highlight": "0.1.48", + "@getpaseo/relay": "0.1.48", "@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 ba65a9033..9e82cb856 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.47", + "version": "0.1.48", "private": true, "type": "module", "scripts": { From 3397e6c5899895937d3fbe14245ebe610f7235c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 5 Apr 2026 08:42:35 +0000 Subject: [PATCH 8/8] 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 eec96c76f..b136d5e96 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-ZLwOacIYQ6rUT2sYs3vNvOh7wTkwQ92cpOJDm29GLWs="; + npmDepsHash = "sha256-epuepM6DMEpOfzvPiS6K/qKJqGM89FqdsKHovWi2LS8="; # 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).