From df8a0faff6c262e157e0b8ef8e3adda7b4c70e17 Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Thu, 28 May 2026 13:09:55 +0800 Subject: [PATCH] feat(runtimes): register AMR (vela) as an ACP stdio agent (#2355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(runtimes): register AMR (vela) as an ACP stdio agent AMR is the vela CLI's ACP runtime mode. `vela agent run --runtime opencode` speaks ACP JSON-RPC over stdio (see vela's `specs/current/runtime/manual-agent-run-openrouter.md`); per `docs/new-agent-runtime-acp.md` we expose it through the same `streamFormat: 'acp-json-rpc'` transport that already powers Hermes, Devin, Kimi, etc. The new `defs/amr.ts` is the entire wiring — `buildArgs` returns `['agent', 'run', '--runtime', 'opencode']`, `fetchModels` reuses `detectAcpModels`, and the fallback list seeds the OpenRouter ids vela's e2e baseline uses. `executables.ts`/`app-config.ts`/`metadata.ts` get the matching `VELA_BIN`/`VELA_LINK_URL`/`VELA_RUNTIME_KEY`/`VELA_OPENCODE_BIN` allowlist + install/docs URLs, so users can configure the per-agent env in Settings without leaking into other adapters. Coverage: `tests/fixtures/fake-vela.mjs` is a minimal ACP stub that returns the documented `initialize` / `session/new` / `session/set_model` / `session/prompt` shapes; `tests/amr-acp-integration.test.ts` spawns it via `child_process.spawn` and drives a full turn through `attachAcpSession` and `detectAcpModels`, so the ACP transport contract for AMR is end-to-end verified locally even before a real `vela` binary is installed. Validated: - pnpm guard - pnpm typecheck (all workspace projects) - pnpm --filter @open-design/daemon test (2881/2881) Deferred: real OpenRouter-backed turn through a built `vela` binary — the runtime def needs no changes for that path, only `VELA_RUNTIME_KEY` and `VELA_LINK_URL` in env (or Settings). * fix(runtimes/amr): pin a concrete default model and bare openai ids End-to-end validation against a freshly-built `vela` (nexu-io/vela@main) + OpenRouter surfaced two contract details the first AMR runtime def got wrong: 1. vela rejects `session/prompt` with `session/set_model must be called before session/prompt`. attachAcpSession in apps/daemon/src/acp.ts skips set_model whenever the picked model is the synthetic 'default' id, so AMR's fallback list must NOT include DEFAULT_MODEL_OPTION. The def now ships a concrete `gpt-5.4-mini` as both `fetchModels`' default option and `fallbackModels[0]`, which makes attachAcpSession always send a real `session/set_model` for AMR turns. 2. `vela --runtime opencode` auto-prepends `openai/` to whatever modelId it forwards to opencode's openai provider. With OpenRouter-style ids like `openai/gpt-5.4-mini`, opencode receives the double-prefixed `openai/openai/gpt-5.4-mini` and replies `ProviderModelNotFoundError`. The new fallback list ships the bare ids opencode's openai registry actually knows about (gpt-5.4, gpt-5.4-mini, gpt-5.4-fast, etc.). Stub + tests: - tests/fixtures/fake-vela.mjs now enforces the set_model gate the same way real vela does, so a regression that silently goes back to model: 'default' would surface as a fatal error in tests instead of a hidden production failure. - tests/amr-acp-integration.test.ts pins both contracts: no 'default' / no 'openai/' prefix in fallbackModels, and a negative case that asserts session/prompt fails when no model is set. Adds `apps/daemon/scripts/verify-amr-real-vela.mjs` — a small dev-time runner that drives `attachAcpSession` against a real `vela` binary and prints the daemon's chat events, so future protocol drift can be checked against an actual OpenRouter call. Verified locally: `vela agent run --runtime opencode` + OpenRouter returns the prompted string ("AMR-E2E-PASS") through the full daemon pipeline; daemon test suite stays 2883/2883. * fix(runtimes/amr): substitute concrete model when chat run sends 'default' A plugin-driven AMR run from the UI surfaced a real-world hole in the prior commit: json-rpc id 3: session/set_model must be called before session/prompt The Default-design-router plugin (and any caller that doesn't pin a real model) sends `model: 'default'` straight through, which the AMR runtime def cannot accept — vela rejects `session/prompt` without `session/set_model` and attachAcpSession skips set_model whenever model === 'default'. Just leaving DEFAULT_MODEL_OPTION out of the adapter's `fallbackModels` is not enough: the chat-run handler in server.ts still forwarded 'default' verbatim. This adds `resolveModelForAgent(def, resolved, env?)` as the single source of truth for the substitution: 1. If the caller picked a real id, pass it through. 2. Else, if `def.defaultModelEnvVar` is set and the daemon process env has a non-empty value for it, return that (operator escape hatch — see below). 3. Else, if the def's `fallbackModels` does NOT contain a 'default' id, return `fallbackModels[0].id`. 4. Else, return the original value (the historic shape — defs that list 'default' themselves are untouched). AMR sets `defaultModelEnvVar: 'VELA_DEFAULT_MODEL'`, so when opencode's openai-provider registry deprecates `gpt-5.4-mini` upstream, an operator can swap the fallback id without a code change by exporting `VELA_DEFAULT_MODEL=gpt-5.5` before launching tools-dev / od. Worth noting the env var must live in the daemon's `process.env` (Settings-UI per-agent env values only reach the spawned child, not the daemon's resolver) — the new field's docblock spells this out. Coverage: - `tests/runtimes/resolve-model.test.ts` — 8 unit tests covering all four resolver branches plus the env-override happy path / fallback / ignore-when-user-picked-a-real-id case. - `pnpm --filter @open-design/daemon typecheck` clean. * chore(runtimes/amr): move AMR to the top of the base agent list So `AMR (vela)` shows up first in the agent picker / status views, ahead of claude / codex. Pure ordering change; no behavior delta. * feat(amr): Sign-in / Sign-out button on the AMR Settings card The first half of the AMR work assumed the operator would set VELA_RUNTIME_KEY / VELA_LINK_URL on the daemon process and never surfaced login state to users. This adds the missing UX so a fresh install can drive the full path from Settings: - GET /api/integrations/vela/status reads ~/.vela/config.json for the active profile and returns { loggedIn, profile, user } (without leaking the runtime/control keys themselves). - POST /api/integrations/vela/login spawns `vela login` once (409 if one is already in flight). The vela CLI opens the user's browser to the device-authorization page itself — Open Design only needs to kick the subprocess off. - POST /api/integrations/vela/logout removes ~/.vela/config.json so the next status read returns logged-out. `AmrAgentCard` is a dedicated agent-card component for AMR because the existing ` + + + ); +} diff --git a/apps/web/src/components/AmrLoginPill.tsx b/apps/web/src/components/AmrLoginPill.tsx new file mode 100644 index 000000000..f456d2eea --- /dev/null +++ b/apps/web/src/components/AmrLoginPill.tsx @@ -0,0 +1,450 @@ +import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'; +import { + cancelVelaLogin, + fetchVelaLoginStatus, + startVelaLogin, + velaLogout, + type VelaLoginStatus, +} from '../providers/daemon'; +import { useI18n } from '../i18n'; +import { + AMR_LOGIN_STATUS_EVENT, + AMR_LOGIN_POLL_INTERVAL_MS, + AMR_LOGIN_STARTUP_SETTLE_MS, + amrLoginPollOutcome, + amrLoginStatusEventReason, + notifyAmrLoginStatusChanged, +} from './amrLoginPolling'; + +interface AmrLoginPillProps { + className?: string; + hideSignedOutStatus?: boolean; + hideSignedInStatus?: boolean; + initialStatus?: VelaLoginStatus | null; + skipInitialRefresh?: boolean; + signInLabel?: string; + revealPendingCancelAction?: boolean; + onStatusChange?: (status: VelaLoginStatus | null) => void; +} + +export type AmrAccountControlStatus = + | 'signed-out' + | 'signing-in' + | 'canceled' + | 'signed-in' + | 'error'; + +export interface AmrAccountControlProps { + status: AmrAccountControlStatus; + className?: string; + compact?: boolean; + email?: string; + errorMessage?: string | null; + profile?: string; + showProfileBadge?: boolean; + showSignInAction?: boolean; + hideSignedOutStatus?: boolean; + hideSignedInStatus?: boolean; + signInLabel?: string; + showCancelSignInAction?: boolean; + onSignIn?: (event: MouseEvent) => void; + onSignOut?: (event: MouseEvent) => void; + onCancelSignIn?: (event: MouseEvent) => void; + signInDisabled?: boolean; + signOutDisabled?: boolean; + cancelSignInDisabled?: boolean; +} + +const AMR_CANCELED_RESET_MS = 1500; + +function closeAmrActivationWindowBestEffort(): boolean { + if (typeof window === 'undefined') return false; + if (window.opener == null) return false; + try { + window.close(); + return true; + } catch { + return false; + } +} + +function profileBadgeLabel(profile: string | undefined): string | null { + if (profile === 'test') return 'TEST'; + if (profile === 'local') return 'LOCAL'; + return null; +} + +function classNames(...names: Array): string { + return names.filter(Boolean).join(' '); +} + +export function AmrAccountControl({ + status, + className, + compact = false, + email = '', + profile, + showProfileBadge = false, + showSignInAction = true, + hideSignedOutStatus = false, + hideSignedInStatus = false, + signInLabel, + showCancelSignInAction = false, + onSignIn, + onSignOut, + onCancelSignIn, + signInDisabled = false, + signOutDisabled = false, + cancelSignInDisabled = false, +}: AmrAccountControlProps) { + const { t } = useI18n(); + const badgeLabel = showProfileBadge ? profileBadgeLabel(profile) : null; + const isSignedIn = status === 'signed-in'; + const isSigningIn = status === 'signing-in'; + const isCanceled = status === 'canceled'; + const hasError = status === 'error'; + const statusText = isSignedIn + ? hideSignedInStatus + ? '' + : email || t('settings.amrSignedIn') + : isSigningIn + ? t('settings.amrSigningIn') + : isCanceled + ? t('designs.status.canceled') + : hideSignedOutStatus + ? '' + : t('settings.amrNotSignedIn'); + const canSignIn = showSignInAction && (status === 'signed-out' || hasError); + + return ( +
+ {statusText ? ( + {statusText} + ) : null} + {isSignedIn && onSignOut ? ( + + ) : null} + {isSigningIn && showCancelSignInAction && onCancelSignIn ? ( + + ) : null} + {canSignIn ? ( + + ) : null} + {badgeLabel ? ( + {badgeLabel} + ) : null} + {hasError ? ( + + {t('settings.amrLoginErrorCompact')} + + ) : null} +
+ ); +} + +// AMR-specific login pill that lives as a sibling inside the installed +// agent card. The pill polls `/api/integrations/vela/status` after a Sign-in +// click until the daemon reports loggedIn=true. +export function AmrLoginPill({ + className, + hideSignedOutStatus = false, + hideSignedInStatus = false, + initialStatus = null, + skipInitialRefresh = false, + signInLabel, + revealPendingCancelAction = false, + onStatusChange, +}: AmrLoginPillProps) { + const { t } = useI18n(); + const [status, setStatus] = useState(initialStatus); + const [pending, setPending] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [canceledVisible, setCanceledVisible] = useState(false); + const pollRef = useRef(null); + const loginStartedAtRef = useRef(null); + const loginPendingRef = useRef(false); + + const stopPolling = useCallback(() => { + if (pollRef.current !== null) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + const refresh = useCallback(async () => { + const next = await fetchVelaLoginStatus(); + if (next) setStatus(next); + return next; + }, []); + + useEffect(() => { + if (!skipInitialRefresh) void refresh(); + return () => { + loginPendingRef.current = false; + loginStartedAtRef.current = null; + stopPolling(); + }; + }, [refresh, skipInitialRefresh, stopPolling]); + + useEffect(() => { + setStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (!canceledVisible) return; + const timeout = window.setTimeout(() => { + setCanceledVisible(false); + }, AMR_CANCELED_RESET_MS); + return () => window.clearTimeout(timeout); + }, [canceledVisible]); + + useEffect(() => { + onStatusChange?.(status); + }, [onStatusChange, status]); + + const startPolling = useCallback((startedAt = Date.now()) => { + stopPolling(); + loginStartedAtRef.current = startedAt; + const tick = async () => { + const next = await refresh(); + const outcome = amrLoginPollOutcome(next, startedAt); + if (outcome === 'signed-in') { + stopPolling(); + loginStartedAtRef.current = null; + loginPendingRef.current = false; + setPending(null); + return; + } + if (outcome === 'stopped' || outcome === 'timed-out') { + stopPolling(); + if (outcome === 'timed-out') { + void cancelVelaLogin().then(() => + notifyAmrLoginStatusChanged('login-canceled'), + ); + } + loginStartedAtRef.current = null; + loginPendingRef.current = false; + setPending(null); + setErrorMessage(t('settings.amrLoginErrorCompact')); + } + }; + pollRef.current = window.setInterval(() => { + void tick(); + }, AMR_LOGIN_POLL_INTERVAL_MS); + }, [refresh, stopPolling, t]); + + useEffect(() => { + const onStatusChange = (event: Event) => { + const reason = amrLoginStatusEventReason(event); + if (reason === 'login-started') { + const startedAt = Date.now(); + loginStartedAtRef.current = startedAt; + setErrorMessage(null); + setPending('login'); + startPolling(startedAt); + } else if (reason === 'login-canceled') { + loginStartedAtRef.current = null; + loginPendingRef.current = false; + stopPolling(); + setPending(null); + // Skip the daemon refresh below. `cancelVelaLogin()` only sends + // SIGTERM (escalating to SIGKILL after 2s) and keeps the child + // in `activeLoginProcs` until it actually exits, so an + // immediate `/api/integrations/vela/status` read can legally + // still return `loginInFlight: true`. Falling through to the + // refresh + restart-polling branch below would bounce the pill + // back into 'Signing in…' and could surface the timeout/error + // path even though the user already canceled. Trust the cancel + // locally on every subscribed pill instance instead — the next + // explicit refresh (mount, user interaction, or a + // `status-changed` event) will pick up the daemon's confirmed + // state once the child has actually exited. + setStatus((current) => ( + current ? { ...current, loginInFlight: false } : current + )); + return; + } + void refresh().then((next) => { + if (!next) return; + if (next.loggedIn) { + stopPolling(); + loginStartedAtRef.current = null; + loginPendingRef.current = false; + setPending(null); + setCanceledVisible(false); + setErrorMessage(null); + return; + } + if (next.loginInFlight) { + setErrorMessage(null); + setPending('login'); + startPolling(); + return; + } + const pendingStartup = + loginStartedAtRef.current !== null && + Date.now() - loginStartedAtRef.current < AMR_LOGIN_STARTUP_SETTLE_MS; + if (!pendingStartup) { + loginStartedAtRef.current = null; + loginPendingRef.current = false; + setPending(null); + } + }); + }; + window.addEventListener(AMR_LOGIN_STATUS_EVENT, onStatusChange); + return () => { + window.removeEventListener(AMR_LOGIN_STATUS_EVENT, onStatusChange); + }; + }, [refresh, startPolling, stopPolling]); + + const handleLogin = useCallback( + async (event: MouseEvent) => { + event.stopPropagation(); + if (loginPendingRef.current) return; + loginPendingRef.current = true; + const startedAt = Date.now(); + loginStartedAtRef.current = startedAt; + setErrorMessage(null); + setPending('login'); + const result = await startVelaLogin(); + if (!result.ok && !result.alreadyRunning) { + loginStartedAtRef.current = null; + loginPendingRef.current = false; + setPending(null); + setErrorMessage(result.error || t('settings.amrLoginErrorCompact')); + return; + } + notifyAmrLoginStatusChanged('login-started'); + startPolling(startedAt); + }, + [startPolling, t], + ); + + const handleCancelLogin = useCallback( + async (event: MouseEvent) => { + event.stopPropagation(); + stopPolling(); + setErrorMessage(null); + setPending('cancel'); + const result = await cancelVelaLogin(); + closeAmrActivationWindowBestEffort(); + loginStartedAtRef.current = null; + loginPendingRef.current = false; + if (!result.ok) { + setPending(null); + setErrorMessage(t('settings.amrLoginErrorCompact')); + return; + } + setStatus((current) => ( + current + ? { ...current, loggedIn: false, loginInFlight: false, user: null } + : { + loggedIn: false, + loginInFlight: false, + profile: 'default', + user: null, + configPath: '', + } + )); + setPending(null); + setCanceledVisible(true); + notifyAmrLoginStatusChanged('login-canceled'); + }, + [stopPolling, t], + ); + + const handleLogout = useCallback( + async (event: MouseEvent) => { + event.stopPropagation(); + setErrorMessage(null); + setPending('logout'); + const result = await velaLogout(); + loginStartedAtRef.current = null; + loginPendingRef.current = false; + setPending(null); + if (!result.ok) { + setErrorMessage(t('settings.amrLoginErrorCompact')); + return; + } + await refresh(); + notifyAmrLoginStatusChanged('status-changed'); + }, + [refresh, t], + ); + + const loggedIn = status?.loggedIn === true; + const userEmail = status?.user?.email ?? ''; + const loginInFlight = + pending === 'login' || (status?.loggedIn !== true && status?.loginInFlight === true); + const logoutInFlight = pending === 'logout'; + const cancelInFlight = pending === 'cancel'; + const accountStatus: AmrAccountControlStatus = errorMessage + ? 'error' + : loggedIn + ? 'signed-in' + : canceledVisible + ? 'canceled' + : loginInFlight + ? 'signing-in' + : 'signed-out'; + + return ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + +
+ ); +} diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index 708db93b5..443dd73b5 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -297,6 +297,10 @@ interface Props { // interactivity on this so older forms render as a locked "answered" // capsule instead of being re-submittable. isLast?: boolean; + // Assistant message id whose run-failure error is rendered as ChatPane's + // top-level error card; that message's per-message error pill is suppressed + // to avoid duplication. Other messages keep their error pill. + errorCardOwnerId?: string | null; // The user message that immediately follows this assistant turn (if // any). Used to detect that a form was already answered so we can // render its locked state with the user's picks visible. @@ -332,6 +336,7 @@ export function AssistantMessage({ activePluginActionPaths = new Set(), hiddenPluginActionPaths = new Set(), isLast, + errorCardOwnerId = null, nextUserContent, onSubmitForm, onContinueRemainingTasks, @@ -350,7 +355,9 @@ export function AssistantMessage({ // above the composer, so we strip any TodoWrite tool-groups out of the // per-message flow to avoid the same task list rendering twice. const blocks = stripTodoToolGroups( - suppressAskUserQuestionFallbackText(buildBlocks(events)), + suppressDuplicateQuestionForms( + suppressAskUserQuestionFallbackText(buildBlocks(events)), + ), ); const fileOps = useMemo(() => deriveFileOps(events), [events]); const produced = message.producedFiles ?? []; @@ -568,8 +575,15 @@ export function AssistantMessage({ /> ); } - if (b.kind === "status") + if (b.kind === "status") { + // Suppress this message's gray error pill ONLY when ChatPane is + // rendering the top-level error card for it (the last failed run). + // Other failed turns — older history, or once a follow-up makes + // this no longer the last assistant message — keep their pill so + // the error detail still survives reload / history review. + if (b.label === "error" && message.id === errorCardOwnerId) return null; return ; + } return null; })} {!streaming && displayedProduced.length > 0 && projectId ? ( @@ -1821,11 +1835,52 @@ function StatusPill({ return (
{label} - {detail ? {detail} : null} + {detail ? {renderStatusDetail(detail)} : null}
); } +function renderStatusDetail(detail: string): ReactNode { + const segments: ReactNode[] = []; + const urlRe = /(https?:\/\/[^\s)<>]+)/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + let key = 0; + + while ((match = urlRe.exec(detail))) { + if (match.index > lastIndex) { + segments.push(detail.slice(lastIndex, match.index)); + } + const [href, suffix] = splitStatusDetailUrlPunctuation(match[1]!); + segments.push( + + {href} + , + ); + if (suffix) segments.push(suffix); + lastIndex = urlRe.lastIndex; + } + + if (lastIndex < detail.length) { + segments.push(detail.slice(lastIndex)); + } + + return <>{segments}; +} + +function splitStatusDetailUrlPunctuation(url: string): [string, string] { + const match = /([.,!?;:,。!?;:、'"」』】》〉)]+)$/.exec(url); + if (!match?.[1]) return [url, '']; + const trimmed = url.slice(0, -match[1].length); + return trimmed ? [trimmed, match[1]] : [url, '']; +} + interface ToolItem { use: Extract; result?: Extract; @@ -2096,6 +2151,31 @@ function stripTodoToolGroups(blocks: Block[]): Block[] { }); } +// The prompt asks for one discovery form and then a stop, but LLMs can still +// emit a tailored discovery form followed by the default Quick brief in the +// same assistant turn. Keep the first form for each id and drop later repeats. +function suppressDuplicateQuestionForms(blocks: Block[]): Block[] { + const seenFormIds = new Set(); + return blocks.map((block) => { + if (block.kind !== "text") return block; + const segments = splitOnQuestionForms(block.text); + let changed = false; + const nextText = segments + .map((segment) => { + if (segment.kind === "text") return segment.text; + const formKey = segment.form.id.trim().toLowerCase(); + if (seenFormIds.has(formKey)) { + changed = true; + return ""; + } + seenFormIds.add(formKey); + return segment.raw; + }) + .join(""); + return changed ? { ...block, text: nextText } : block; + }); +} + // Hide text blocks that follow an `AskUserQuestion` tool use in the same // assistant message. Claude tends to also write the same questions as // markdown text alongside the tool call. The card already shows the diff --git a/apps/web/src/components/AvatarMenu.tsx b/apps/web/src/components/AvatarMenu.tsx index 81ae81c23..547b7090b 100644 --- a/apps/web/src/components/AvatarMenu.tsx +++ b/apps/web/src/components/AvatarMenu.tsx @@ -22,6 +22,10 @@ interface Props { onBack?: () => void; } +function displayAgentName(agent: Pick): string { + return agent.id === 'amr' ? 'Open Design AMR' : agent.name; +} + /** * Compact settings control at the right of the project header. Click opens a dropdown * with current execution mode, the agent picker (when in daemon mode), and @@ -115,7 +119,15 @@ export function AvatarMenu({ {config.mode === 'api' ? safeHost(config.baseUrl) : currentAgent - ? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}${currentModelLabel && currentModelId !== 'default' ? ` · ${currentModelLabel}` : ''}` + ? `${displayAgentName(currentAgent)}${ + currentAgent.id !== 'amr' && currentAgent.version + ? ` · ${currentAgent.version}` + : '' + }${ + currentModelLabel && currentModelId !== 'default' + ? ` · ${currentModelLabel}` + : '' + }` : t('avatar.noAgentSelected')} @@ -191,12 +203,12 @@ export function AvatarMenu({ }} > - {a.name} + {displayAgentName(a)} {selected ? ( {t('avatar.metaSelected')} - ) : a.version ? ( + ) : a.id !== 'amr' && a.version ? ( {a.version} ) : null} {selected ? ( diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 21e0c6e7e..31e8017d9 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -19,6 +19,8 @@ import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Cha import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime'; import { commentTargetDisplayName, commentsToAttachments, simplePositionLabel } from '../comments'; import { AssistantMessage } from './AssistantMessage'; +import { AmrGuidance } from './AmrGuidance'; +import { AMR_RECHARGE_URL, resolveRunFailureUi } from '../runtime/amr-guidance'; import { ChatComposer, type ChatComposerHandle, @@ -277,6 +279,8 @@ interface Props { // Composer settings/CLI button forwards to here. The dialog lives in App // (it owns the AppConfig lifecycle) so we just pass the open trigger. onOpenSettings?: (section?: SettingsSection) => void; + onOpenAmrSettings?: () => void; + onSwitchToAmrAndRetry?: (failedAssistant: ChatMessage) => void; // Same dialog, but landing on the External MCP tab. Forwarded to the // composer's `/mcp` slash and MCP picker button. onOpenMcpSettings?: () => void; @@ -371,6 +375,8 @@ export function ChatPane({ onDeleteConversation, onRenameConversation, onOpenSettings, + onOpenAmrSettings, + onSwitchToAmrAndRetry, onOpenMcpSettings, connectRepoNeeded, githubConnected, @@ -422,6 +428,45 @@ export function ChatPane({ (m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus), ); const retryAssistant = retryableAssistantMessage(messages, lastAssistantId, streaming); + // The failed run's error event lives on the (persisted) assistant message, so + // the error card + AMR card survive a reload — unlike the ephemeral global + // `error` state. Drive both off this event. + const failedRunErrorEvent = (() => { + const evs = retryAssistant?.events ?? []; + for (let i = evs.length - 1; i >= 0; i--) { + const ev = evs[i]; + if (ev?.kind === 'status' && ev.label === 'error') return ev; + } + return null; + })(); + // Per-case failure UI (button + copy + whether to promote AMR). Only + // meaningful for a failed run (retryAssistant present). + const runFailureUi = retryAssistant + ? resolveRunFailureUi(failedRunErrorEvent?.code, retryAssistant.agentId) + : null; + // Prefer a case-specific message (AMR auth / balance) over the raw upstream + // string; fall back to the live global error (also covers conversation-load + // / audio errors) then the persisted run error so a reload still shows it. + const rawError = error ?? failedRunErrorEvent?.detail ?? null; + const displayError = runFailureUi?.messageKey ? t(runFailureUi.messageKey) : rawError; + // The failed run whose error this top-level card represents. AssistantMessage + // suppresses only THIS message's per-message error pill (to avoid the + // duplicate); other failed turns — older history, or once a follow-up makes + // this no longer the last assistant — keep their pill so the error survives. + const errorCardOwnerId = + retryAssistant && failedRunErrorEvent ? retryAssistant.id : null; + // AMR promotion card payload (only the non-AMR model/auth/quota case). + const amrSwitchPayload = + runFailureUi?.showSwitchCard && retryAssistant && failedRunErrorEvent?.code + ? { + errorCode: failedRunErrorEvent.code, + projectId: projectId ?? '', + projectKind: projectKindForTracking, + conversationId: activeConversationId, + assistantMessageId: retryAssistant.id, + runId: retryAssistant.runId ?? null, + } + : null; const composerDraftStorageKey = projectId && activeConversationId ? `od:chat-composer:draft:${projectId}:${activeConversationId}` : undefined; @@ -1099,6 +1144,7 @@ export function ChatPane({ activePluginActionPaths={activePluginActionPaths} hiddenPluginActionPaths={hiddenPluginActionPaths} isLast={m.id === lastAssistantId} + errorCardOwnerId={errorCardOwnerId} nextUserContent={nextUserContentByAssistantId.get(m.id)} suppressDirectionForms={hasActiveDesignSystem} hasDesignSystemContext={hasActiveDesignSystem || !!activeDesignSystem} @@ -1122,20 +1168,61 @@ export function ChatPane({ ); })} - {error ? ( + {displayError ? (
- {error} - {retryAssistant && onRetry ? ( - + {displayError} + {retryAssistant && onRetry && runFailureUi ? ( +
+ {runFailureUi.primaryAction === 'authorize' ? ( + + ) : runFailureUi.primaryAction === 'recharge' ? ( + + ) : null} + {runFailureUi.primaryAction === 'retry' || runFailureUi.secondaryRetry ? ( + + ) : null} +
) : null}
) : null} + {amrSwitchPayload ? ( + { + if (retryAssistant && onSwitchToAmrAndRetry) { + onSwitchToAmrAndRetry(retryAssistant); + } else { + onOpenAmrSettings?.(); + } + }} + /> + ) : null} {/* Always mounted so the CSS transition can play in both directions; the `chat-jump-btn-active` class flips the diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index d2e339372..b6520d74a 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -8,7 +8,14 @@ // can be rebased without touching this file. `EntryView` becomes a // thin wrapper that passes data and callbacks through to this shell. -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from 'react'; import { defaultScenarioPluginIdForProjectMetadata, type ConnectorDetail, @@ -96,6 +103,17 @@ import { KNOWN_PROVIDERS } from '../state/config'; import type { KnownProvider } from '../state/config'; import { testApiProvider } from '../providers/connection-test'; import { fetchProviderModels } from '../providers/provider-models'; +import { + cancelVelaLogin, + fetchVelaLoginStatus, + startVelaLogin, + type VelaLoginStatus, +} from '../providers/daemon'; +import { AmrAccountControl } from './AmrLoginPill'; +import { + AMR_LOGIN_POLL_INTERVAL_MS, + amrLoginPollOutcome, +} from './amrLoginPolling'; // The topbar chips (GitHub star, model switcher, Use everywhere) // collapse into the settings dropdown when the viewport gets @@ -771,10 +789,13 @@ function OnboardingView({ const t = useT(); const analytics = useAnalytics(); const [step, setStep] = useState(0); - const [runtime, setRuntime] = useState<'local' | 'byok' | null>(null); + const [runtime, setRuntime] = useState<'amr' | 'local' | 'byok' | null>(null); const [designSource, setDesignSource] = useState<'github' | 'upload' | 'prompt' | null>(null); const [apiKeyVisible, setApiKeyVisible] = useState(false); const [cliScanStatus, setCliScanStatus] = useState<'idle' | 'scanning' | 'done'>('idle'); + const [amrStatus, setAmrStatus] = useState(null); + const [amrLoginPending, setAmrLoginPending] = useState(false); + const [amrLoginError, setAmrLoginError] = useState(false); const [visibleAgentIds, setVisibleAgentIds] = useState([]); const [providerTestState, setProviderTestState] = useState< | { status: 'idle' } @@ -809,6 +830,8 @@ function OnboardingView({ }, [profile]); const agentRevealTimersRef = useRef>>([]); const cliScanTokenRef = useRef(0); + const amrLoginPollCancelledRef = useRef(false); + const amrAgentRefreshAttemptedRef = useRef(false); const apiProtocol = config.apiProtocol ?? 'anthropic'; const providerTestInputKey = [ apiProtocol, @@ -849,18 +872,53 @@ function OnboardingView({ provider.baseUrl === (config.apiProviderBaseUrl ?? config.baseUrl), ) ?? null; const visibleAgents = agents.filter( - (agent) => agent.available && visibleAgentIds.includes(agent.id), + (agent) => agent.available && agent.id !== 'amr' && visibleAgentIds.includes(agent.id), ); + const amrAgent = agents.find((agent) => agent.id === 'amr' && agent.available) ?? null; + const showAmrCloudOption = amrAgent !== null || agents.length === 0; + const amrSignedIn = amrStatus?.loggedIn === true; + const amrSelectedAndSignedOut = runtime === 'amr' && !amrSignedIn; const selectedAgent = visibleAgents.find((agent) => agent.id === config.agentId) ?? null; const selectedAgentChoice = selectedAgent ? (config.agentModels?.[selectedAgent.id] ?? {}) : {}; useEffect(() => { return () => { + amrLoginPollCancelledRef.current = true; agentRevealTimersRef.current.forEach((timer) => clearTimeout(timer)); agentRevealTimersRef.current = []; }; }, []); + useEffect(() => { + if (!amrAgent || runtime !== null) return; + setRuntime('amr'); + onModeChange('daemon'); + onAgentChange('amr'); + }, [amrAgent, onAgentChange, onModeChange, runtime]); + + useEffect(() => { + if (amrAgent || amrAgentRefreshAttemptedRef.current) return; + amrAgentRefreshAttemptedRef.current = true; + void Promise.resolve(onRefreshAgents()).catch(() => undefined); + }, [amrAgent, onRefreshAgents]); + + useEffect(() => { + if (!amrAgent) return; + let cancelled = false; + void fetchVelaLoginStatus().then((next) => { + if (!cancelled && next) setAmrStatus(next); + }); + return () => { + cancelled = true; + }; + }, [amrAgent]); + + useEffect(() => { + if (runtime === 'amr') return; + amrLoginPollCancelledRef.current = true; + setAmrLoginPending(false); + }, [runtime]); + // Onboarding step exposure. Design-system intake used to live here // as step 3, but it is temporarily removed from first-run // onboarding and remains available from the app surfaces. @@ -911,6 +969,7 @@ function OnboardingView({ const onboardingStartedAtRef = useRef(Date.now()); const lifecycleReportedRef = useRef(false); function currentRuntimeType(): TrackingOnboardingRuntimeType { + if (runtime === 'amr') return 'amr_cloud'; if (runtime === 'local') return 'local_cli'; if (runtime === 'byok') return 'byok'; return 'none'; @@ -1230,6 +1289,10 @@ function OnboardingView({ setStep((current) => current - 1); } function handlePrimaryAction() { + if (step === 0 && amrSelectedAndSignedOut) { + void handleAmrSignInToContinue(); + return; + } if (isLastStep) { // Emit the About-you survey snapshot FIRST, before the // continue/complete pair. This is the bombproof carrier for the @@ -1256,6 +1319,51 @@ function OnboardingView({ setStep((current) => current + 1); } + async function handleAmrSignInToContinue() { + if (amrLoginPending) return; + amrLoginPollCancelledRef.current = false; + setAmrLoginError(false); + setAmrLoginPending(true); + try { + const currentStatus = await fetchVelaLoginStatus(); + if (currentStatus) setAmrStatus(currentStatus); + if (currentStatus?.loggedIn) { + setStep((current) => current + 1); + return; + } + const loginResult = await startVelaLogin(); + if (!loginResult.ok && !loginResult.alreadyRunning) { + setAmrLoginError(true); + return; + } + if (await pollAmrLoginCompletion()) { + setStep((current) => current + 1); + } + } finally { + setAmrLoginPending(false); + } + } + + async function pollAmrLoginCompletion(): Promise { + const startedAt = Date.now(); + while (!amrLoginPollCancelledRef.current) { + await new Promise((resolve) => + window.setTimeout(resolve, AMR_LOGIN_POLL_INTERVAL_MS), + ); + if (amrLoginPollCancelledRef.current) return false; + const nextStatus = await fetchVelaLoginStatus(); + if (nextStatus) setAmrStatus(nextStatus); + const outcome = amrLoginPollOutcome(nextStatus, startedAt); + if (outcome === 'signed-in') return true; + if (outcome === 'stopped' || outcome === 'timed-out') { + if (outcome === 'timed-out') void cancelVelaLogin(); + setAmrLoginError(true); + return false; + } + } + return false; + } + // Survey snapshot. Reads `profileRef.current` rather than `profile` // because Finish-setup may fire within the same render commit as the // user's last dropdown pick, before React has rebound the closure to @@ -1308,7 +1416,16 @@ function OnboardingView({ try { const nextAgents = await onRefreshAgents(); if (cliScanTokenRef.current !== scanToken) return; - const availableAgents = nextAgents.filter((agent) => agent.available); + const availableAgents = nextAgents.filter((agent) => agent.available && agent.id !== 'amr'); + // If the user previously had AMR selected (e.g. it was auto-picked once + // we detected vela) and they have now chosen the Local CLI path, the + // persisted agentId is still 'amr' and would survive Continue without + // an explicit click on a local agent card. Switch the selection to the + // first available local agent as soon as we have one, so the runtime + // and the persisted agent always agree. + if (config.agentId === 'amr' && availableAgents[0]) { + onAgentChange(availableAgents[0].id); + } // Scan-result semantics: zero available CLIs is a `failed` outcome // because the user's runtime path is blocked, even though the // detect call itself returned successfully. `detected_cli_count` @@ -1433,9 +1550,13 @@ function OnboardingView({ } } - const primaryActionLabel = isLastStep - ? t('settings.onboardingFinish') - : t('settings.onboardingContinue'); + const primaryActionLabel = step === 0 && amrSelectedAndSignedOut + ? t('settings.amrSignInToContinue') + : step === 1 + ? t('settings.onboardingContinue') + : isLastStep + ? t('settings.onboardingFinish') + : t('settings.onboardingContinue'); return (
@@ -1465,6 +1586,54 @@ function OnboardingView({ body={t('settings.onboardingConnectBody')} />
+ {showAmrCloudOption ? ( +
+ + ) : null + } + featured + selected={runtime === 'amr'} + onClick={() => { + setRuntime('amr'); + onModeChange('daemon'); + onAgentChange('amr'); + }} + /> +
+ ) : null}
{runtimeItems.map((item) => ( {primaryActionLabel} -
)} @@ -2265,48 +2434,98 @@ function OnboardingDropdown(props: OnboardingDropdownProps) { function OnboardingChoiceCard({ icon, + agentIconId, title, body, + benefits, actionLabel, selected, badge, + officialLabel, + statusSlot, featured, onClick, }: { icon: 'orbit' | 'hammer' | 'sliders' | 'github' | 'upload' | 'sparkles'; + agentIconId?: string; title: string; body: string; + benefits?: string[]; actionLabel?: string; selected: boolean; badge?: string; + officialLabel?: string; + statusSlot?: ReactNode; featured?: boolean; onClick: () => void; }) { + function handleKeyDown(event: ReactKeyboardEvent) { + if (event.target !== event.currentTarget) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onClick(); + } + return ( - +
); } diff --git a/apps/web/src/components/InlineModelSwitcher.tsx b/apps/web/src/components/InlineModelSwitcher.tsx index a8ab1bb0d..2fb6afd9c 100644 --- a/apps/web/src/components/InlineModelSwitcher.tsx +++ b/apps/web/src/components/InlineModelSwitcher.tsx @@ -8,13 +8,28 @@ // upward through the same callbacks `AvatarMenu` already uses, so the // switcher inherits autosave + daemon sync without re-implementing it. -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../i18n'; import { KNOWN_PROVIDERS } from '../state/config'; +import { + cancelVelaLogin, + fetchVelaLoginStatus, + startVelaLogin, + type VelaLoginStatus, +} from '../providers/daemon'; import type { AgentInfo, ApiProtocol, AppConfig, ExecMode } from '../types'; import { apiProtocolLabel } from '../utils/apiProtocol'; import { AgentIcon } from './AgentIcon'; import { Icon } from './Icon'; +import { + AMR_LOGIN_STATUS_EVENT, + AMR_LOGIN_POLL_INTERVAL_MS, + AMR_LOGIN_STARTUP_SETTLE_MS, + amrLoginPollOutcome, + amrLoginStatusEventReason, + notifyAmrLoginStatusChanged, +} from './amrLoginPolling'; +import { normalizeAgentModelChoice } from './agentModelSelection'; import { renderModelOptions } from './modelOptions'; interface Props { @@ -49,6 +64,41 @@ const API_PROTOCOL_TABS: Array<{ id: ApiProtocol; title: string }> = [ { id: 'google', title: 'Google' }, ]; +const AMR_REMINDER_SEEN_KEY = 'open-design:inline-amr-cli-reminder-seen:v2'; +let amrReminderSeenFallback = false; + +function readAmrReminderSeen(): boolean { + if (typeof window === 'undefined') return true; + try { + return window.localStorage + ? window.localStorage.getItem(AMR_REMINDER_SEEN_KEY) === '1' + : amrReminderSeenFallback; + } catch { + return amrReminderSeenFallback; + } +} + +function markAmrReminderSeen(): void { + if (typeof window === 'undefined') return; + try { + if (window.localStorage) { + window.localStorage.setItem(AMR_REMINDER_SEEN_KEY, '1'); + return; + } + } catch { + // Ignore storage failures; the reminder is purely advisory UI. + } + amrReminderSeenFallback = true; +} + +function displayAgentName(agent: Pick): string { + return agent.id === 'amr' ? 'Open Design AMR' : agent.name; +} + +function displayAgentChipName(agent: Pick): string { + return agent.id === 'amr' ? 'AMR' : displayAgentName(agent); +} + export function InlineModelSwitcher({ config, agents, @@ -63,6 +113,117 @@ export function InlineModelSwitcher({ const t = useT(); const [open, setOpen] = useState(false); const wrapRef = useRef(null); + const [amrStatus, setAmrStatus] = useState(null); + const [amrLoginPending, setAmrLoginPending] = useState(false); + const [amrLoginError, setAmrLoginError] = useState(false); + const [amrReminderSeen, setAmrReminderSeen] = useState(readAmrReminderSeen); + const [showAmrReminderInPopover, setShowAmrReminderInPopover] = + useState(false); + const amrPollRef = useRef(null); + const amrLoginStartedAtRef = useRef(null); + + const stopAmrPolling = useCallback(() => { + if (amrPollRef.current !== null) { + window.clearInterval(amrPollRef.current); + amrPollRef.current = null; + } + }, []); + + const refreshAmrStatus = useCallback(async () => { + const next = await fetchVelaLoginStatus(); + if (next) { + setAmrStatus(next); + const pendingStartup = + amrLoginStartedAtRef.current !== null && + Date.now() - amrLoginStartedAtRef.current < AMR_LOGIN_STARTUP_SETTLE_MS; + if (next.loggedIn) { + amrLoginStartedAtRef.current = null; + setAmrLoginPending(false); + } else if (next.loginInFlight) { + setAmrLoginPending(true); + } else if (!pendingStartup) { + amrLoginStartedAtRef.current = null; + setAmrLoginPending(false); + } + } + return next; + }, []); + + const startAmrPolling = useCallback((startedAt = Date.now()) => { + stopAmrPolling(); + amrLoginStartedAtRef.current = startedAt; + const tick = async () => { + const next = await refreshAmrStatus(); + const outcome = amrLoginPollOutcome(next, startedAt); + if (outcome === 'signed-in') { + stopAmrPolling(); + amrLoginStartedAtRef.current = null; + setAmrLoginPending(false); + return; + } + if (outcome === 'stopped' || outcome === 'timed-out') { + stopAmrPolling(); + if (outcome === 'timed-out') { + void cancelVelaLogin().then(() => + notifyAmrLoginStatusChanged('login-canceled'), + ); + } + amrLoginStartedAtRef.current = null; + setAmrLoginPending(false); + setAmrLoginError(true); + } + }; + amrPollRef.current = window.setInterval(() => { + void tick(); + }, AMR_LOGIN_POLL_INTERVAL_MS); + }, [refreshAmrStatus, stopAmrPolling]); + + const handleAmrSignIn = useCallback(async () => { + const startedAt = Date.now(); + amrLoginStartedAtRef.current = startedAt; + setAmrLoginError(false); + setAmrLoginPending(true); + const result = await startVelaLogin(); + if (!result.ok && !result.alreadyRunning) { + amrLoginStartedAtRef.current = null; + setAmrLoginPending(false); + setAmrLoginError(true); + return; + } + notifyAmrLoginStatusChanged('login-started'); + startAmrPolling(startedAt); + }, [startAmrPolling]); + + const handleAmrCancelLogin = useCallback(async () => { + stopAmrPolling(); + amrLoginStartedAtRef.current = null; + setAmrLoginError(false); + setAmrLoginPending(false); + await cancelVelaLogin(); + notifyAmrLoginStatusChanged('login-canceled'); + await refreshAmrStatus(); + }, [refreshAmrStatus, stopAmrPolling]); + + const handleAgentButtonClick = useCallback( + async (agentId: string) => { + onAgentChange?.(agentId); + if (agentId !== 'amr') return; + if (amrLoginPending) { + await handleAmrCancelLogin(); + return; + } + const latest = await refreshAmrStatus(); + if (latest?.loggedIn) return; + await handleAmrSignIn(); + }, + [ + amrLoginPending, + handleAmrCancelLogin, + handleAmrSignIn, + onAgentChange, + refreshAmrStatus, + ], + ); useEffect(() => { if (!open) return; @@ -81,6 +242,42 @@ export function InlineModelSwitcher({ }; }, [open]); + useEffect(() => { + if (open && agents.some((agent) => agent.id === 'amr' && agent.available)) { + void refreshAmrStatus(); + } + return () => stopAmrPolling(); + }, [agents, open, refreshAmrStatus, stopAmrPolling]); + + useEffect(() => { + const onStatusChange = (event: Event) => { + const reason = amrLoginStatusEventReason(event); + if (reason === 'login-started') { + const startedAt = Date.now(); + amrLoginStartedAtRef.current = startedAt; + setAmrLoginError(false); + setAmrLoginPending(true); + startAmrPolling(startedAt); + } else if (reason === 'login-canceled') { + amrLoginStartedAtRef.current = null; + stopAmrPolling(); + setAmrLoginPending(false); + } + void refreshAmrStatus().then((next) => { + if (next?.loggedIn) { + amrLoginStartedAtRef.current = null; + stopAmrPolling(); + return; + } + if (next?.loginInFlight) startAmrPolling(); + }); + }; + window.addEventListener(AMR_LOGIN_STATUS_EVENT, onStatusChange); + return () => { + window.removeEventListener(AMR_LOGIN_STATUS_EVENT, onStatusChange); + }; + }, [refreshAmrStatus, startAmrPolling, stopAmrPolling]); + const installedAgents = useMemo( () => agents.filter((a) => a.available), [agents], @@ -89,13 +286,66 @@ export function InlineModelSwitcher({ () => agents.find((a) => a.id === config.agentId) ?? null, [agents, config.agentId], ); + const amrInstalled = installedAgents.some((a) => a.id === 'amr'); + const shouldOfferAmrReminder = + config.mode === 'daemon' && config.agentId !== 'amr' && amrInstalled; + const showAmrReminder = shouldOfferAmrReminder && !amrReminderSeen; const currentChoice = (config.agentId && config.agentModels?.[config.agentId]) || {}; + const normalizedCurrentChoice = normalizeAgentModelChoice( + currentAgent, + currentChoice, + ); + const currentAgentId = currentAgent?.id ?? null; + const normalizedCurrentModelId = normalizedCurrentChoice?.model ?? null; + const normalizedCurrentReasoning = normalizedCurrentChoice?.reasoning; + const currentAgentModelIds = currentAgent?.models?.map((m) => m.id) ?? []; + const configuredModelId = + typeof currentChoice.model === 'string' && currentChoice.model + ? currentChoice.model + : null; const currentModelId = - currentChoice.model ?? currentAgent?.models?.[0]?.id ?? null; + currentAgent?.id === 'amr' && + configuredModelId && + !currentAgentModelIds.includes(configuredModelId) + ? currentAgent?.models?.[0]?.id ?? null + : configuredModelId ?? currentAgent?.models?.[0]?.id ?? null; + + useEffect(() => { + if (!currentAgentId || !normalizedCurrentModelId) return; + onAgentModelChange(currentAgentId, { + model: normalizedCurrentModelId, + reasoning: normalizedCurrentReasoning, + }); + }, [ + currentAgentId, + normalizedCurrentModelId, + normalizedCurrentReasoning, + onAgentModelChange, + ]); + const currentModelLabel = currentAgent?.models?.find((m) => m.id === currentModelId)?.label ?? null; + const amrLoggedIn = amrStatus?.loggedIn === true; + const amrActionLabel = amrLoginPending + ? t('settings.amrSigningIn') + : amrLoggedIn + ? t('settings.amrSignedIn') + : t('settings.amrSignIn'); + const amrPendingHoverLabel = t('settings.amrCancelSignIn'); + const amrInlineStatus = amrLoginError + ? t('settings.amrLoginErrorCompact') + : amrLoggedIn + ? t('settings.amrSignedIn') + : amrLoginPending + ? t('settings.amrSigningIn') + : t('settings.amrSignIn'); + const amrStatusIconName = amrLoggedIn + ? 'check' + : amrLoginPending + ? 'spinner' + : null; const apiProtocol = config.apiProtocol ?? 'anthropic'; const providerForProtocol = useMemo( @@ -119,7 +369,9 @@ export function InlineModelSwitcher({ : t('inlineSwitcher.chipByok'); const chipPrimary = config.mode === 'daemon' - ? currentAgent?.name ?? t('inlineSwitcher.noAgent') + ? currentAgent + ? displayAgentChipName(currentAgent) + : t('inlineSwitcher.noAgent') : apiProtocolLabel(apiProtocol); const chipModel = config.mode === 'daemon' @@ -128,6 +380,24 @@ export function InlineModelSwitcher({ : t('inlineSwitcher.modelDefault') : config.model.trim() || t('inlineSwitcher.modelDefault'); + const handleChipClick = useCallback(() => { + const nextOpen = !open; + if (nextOpen && showAmrReminder) { + setShowAmrReminderInPopover(true); + setAmrReminderSeen(true); + markAmrReminderSeen(); + } else if (!nextOpen) { + setShowAmrReminderInPopover(false); + } + setOpen(nextOpen); + }, [open, showAmrReminder]); + + useEffect(() => { + if (!open || config.mode !== 'daemon' || config.agentId === 'amr') { + setShowAmrReminderInPopover(false); + } + }, [config.agentId, config.mode, open]); + return (
+ +
); })} @@ -287,7 +629,8 @@ export function InlineModelSwitcher({ } > {renderModelOptions(currentAgent.models)} - {currentModelId && + {currentAgent.id !== 'amr' && + currentModelId && !currentAgent.models.some((m) => m.id === currentModelId) ? (