From 94cbb314c5715924a48902d8160bfbd18162bd82 Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:26:00 +0000 Subject: [PATCH 1/6] fix: give the ACP startup deadline a single owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client running its own deadline over `machine/acp-capabilities-refresh` disagreed with the machine about who owns the truth. The Electron local transport used a 120s socket timeout while the machine could still be inside a 300s cold `npx` init followed by a 120s `session/new`, so the client reported its own timeout for a request the machine was still working on and would have answered — with its real failure reason — minutes later. The machine owns the deadline. `packages/shared/src/acp-startup-budget.ts` is now the one binding for its worst case, and both client paths (Electron local control and the Machine RPC plane) derive a backstop that stays strictly above it, so reaching that backstop means the daemon never replied at all. The CLI's own `initialize` / `session/new` defaults and the cold-`npx` ceiling read from the same module, so the two ends cannot drift into different worst cases. Runtime download is deliberately outside the budget: it streams progress frames, which continuously reset an inactivity-based transport timeout, so a slow download cannot expire the request. Model: claude-opus-5 --- AGENTS.md | 5 ++ apps/cli/src/agent/acp-npx-startup-policy.ts | 7 ++- apps/cli/src/agent/agent-client.ts | 12 ++++- .../electron/src/main/services/cli-service.ts | 8 +++- .../src/providers/create-workspace-runtime.ts | 6 ++- packages/shared/package.json | 4 ++ packages/shared/src/acp-startup-budget.ts | 48 +++++++++++++++++++ packages/shared/src/index.ts | 1 + .../shared/tests/acp-startup-budget.test.ts | 27 +++++++++++ 9 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 packages/shared/src/acp-startup-budget.ts create mode 100644 packages/shared/tests/acp-startup-budget.test.ts diff --git a/AGENTS.md b/AGENTS.md index 57c6b4fbe..c6566d888 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,11 @@ and Web/mobile app sources. `MachineMeta.protocolCapabilities`; never infer support from the CLI release version. Missing capabilities mean legacy/unsupported. Advertised set and version checks share one binding in `packages/shared/src/machine-protocol-capabilities.ts` so a key never travels without its version. +- The machine owns the deadline for an ACP startup round-trip and answers with its own + failure reason. A client timeout over the same request is a backstop for a daemon that + died without replying, so it derives from + `packages/shared/src/acp-startup-budget.ts` and stays strictly above the machine's worst + case; never set a second, smaller client deadline that expires work the machine is still doing. - Managed runtime downloads default to the public R2-backed channel owned by `packages/platform/src/runtime-artifacts.ts`; local and cloud assembly must use that same constant. `LODY_RUNTIME_BASE_URL` is only an explicit mirror override. diff --git a/apps/cli/src/agent/acp-npx-startup-policy.ts b/apps/cli/src/agent/acp-npx-startup-policy.ts index f2595d3b6..2cc527e84 100644 --- a/apps/cli/src/agent/acp-npx-startup-policy.ts +++ b/apps/cli/src/agent/acp-npx-startup-policy.ts @@ -1,3 +1,4 @@ +import { ACP_COLD_NPX_INIT_TIMEOUT_MS } from '@lody/shared'; import type { AcpStartupTimeoutOptions } from './agent-client'; import { AcpTimeoutError } from './agent-client'; import type { Logger } from '@/utils/logger'; @@ -14,7 +15,11 @@ import { type NpxCacheIo, } from './npx-cache'; -export const COLD_NPX_INIT_TIMEOUT_MS = 300_000; +/** + * Re-exported so the client-side backstop in `@lody/shared/acp-startup-budget` + * and this startup path cannot drift into two different worst cases. + */ +export const COLD_NPX_INIT_TIMEOUT_MS = ACP_COLD_NPX_INIT_TIMEOUT_MS; export type NpxStartupAttemptInput = { attempt: number; diff --git a/apps/cli/src/agent/agent-client.ts b/apps/cli/src/agent/agent-client.ts index 8aa975aa4..8611912ed 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -36,6 +36,8 @@ import { buildAskUserQuestionElicitationResponse, formatMcpResolutionProblem, getServerNow, + ACP_INIT_TIMEOUT_MS as DEFAULT_ACP_INIT_TIMEOUT_MS, + ACP_NEW_SESSION_TIMEOUT_MS as DEFAULT_ACP_NEW_SESSION_TIMEOUT_MS, } from '@lody/shared'; import { getLocalControlSocketPath } from '@lody/shared/node/local-ipc'; import { getLodyMcpHttpEndpoint } from '@/mcp/lody-mcp-http-server'; @@ -1718,7 +1720,10 @@ export class AgentClient implements acp.Client { // connection.initialize() internally spawns the CLI process and waits for it to respond. // Missing dependencies or local runtime issues can hang this operation indefinitely. // Apply a hard timeout so startup fails fast. - const ACP_INIT_TIMEOUT_MS = Math.max(0, timeoutOptions.initTimeoutMs ?? 120_000); // 2 minutes default + const ACP_INIT_TIMEOUT_MS = Math.max( + 0, + timeoutOptions.initTimeoutMs ?? DEFAULT_ACP_INIT_TIMEOUT_MS + ); let initResponse: acp.InitializeResponse; try { @@ -2067,7 +2072,10 @@ export class AgentClient implements acp.Client { // 2. Start the internal query system which spawns another subprocess // 3. Call query.supportedModels() and query.supportedCommands() // Any of these can hang due to runtime/environment issues. Apply a hard timeout. - const ACP_NEW_SESSION_TIMEOUT_MS = Math.max(0, timeoutOptions.newSessionTimeoutMs ?? 120_000); // 2 minutes default + const ACP_NEW_SESSION_TIMEOUT_MS = Math.max( + 0, + timeoutOptions.newSessionTimeoutMs ?? DEFAULT_ACP_NEW_SESSION_TIMEOUT_MS + ); try { sessionResponse = await withTimeout( diff --git a/apps/electron/src/main/services/cli-service.ts b/apps/electron/src/main/services/cli-service.ts index 2e12242f8..a3d37c349 100644 --- a/apps/electron/src/main/services/cli-service.ts +++ b/apps/electron/src/main/services/cli-service.ts @@ -33,6 +33,7 @@ import { makeLocalProbeClientAuto } from '@lody/shared/node/local-ipc' import { getLodyDataDir } from '@lody/shared/node/installation-profile' +import { ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS } from '@lody/shared/acp-startup-budget' import type { LocalProjectControlRequest, LocalProjectControlResponse, @@ -72,7 +73,12 @@ const CLI_ELECTRON_SESSION_TOKEN_ENV = 'LODY_ELECTRON_SESSION_TOKEN' // See apps/cli/src/commands/start.ts:ELECTRON_SESSION_USER_ID_ENV for rationale. const CLI_ELECTRON_SESSION_USER_ID_ENV = 'LODY_ELECTRON_SESSION_USER_ID' const LOCAL_SESSION_CONTROL_TIMEOUT_MS = 10_000 -const LOCAL_SESSION_CONTROL_ACP_REFRESH_TIMEOUT_MS = 120_000 +// The machine owns this deadline and answers with its own failure reason. This +// is only a backstop for a daemon that died without replying, so it is derived +// from the machine's worst case rather than set to a second, smaller number: +// a 120s socket timeout here expired requests the CLI was still working on +// (a cold `npx` init alone may run 300s) and reported them as our timeout. +const LOCAL_SESSION_CONTROL_ACP_REFRESH_TIMEOUT_MS = ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS // Downloading + unpacking a registry agent binary can take minutes on a slow // link, so the local-control request must outlive the default before falling // back to Streams RPC. diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 216da9e33..66c8fd364 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -64,6 +64,7 @@ import { type LodyPresenceStateMap, type LoroStreamsTokenProviderEvent, type SyncReason, + ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS, } from '@lody/shared'; import { LocalLoroTransportAdapter } from '@lody/shared/local-loro-transport'; import type { TaskId, WorkspaceId } from '@lody/shared'; @@ -1828,7 +1829,10 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise { + it('covers the slowest initialize followed by session/new', () => { + expect(ACP_COLD_NPX_INIT_TIMEOUT_MS).toBeGreaterThanOrEqual(ACP_INIT_TIMEOUT_MS); + expect(ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS).toBe( + ACP_COLD_NPX_INIT_TIMEOUT_MS + ACP_NEW_SESSION_TIMEOUT_MS + ); + }); + + it('keeps the client backstop strictly above the machine budget', () => { + // A client deadline at or below the machine budget expires requests the + // machine is still working on, and reports a transport timeout instead of + // the machine's own failure reason. + expect(ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS).toBeGreaterThan( + ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS + ); + }); +}); From 65511d8ec43629b48b660ffdec25e5b55cef916e Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:26:12 +0000 Subject: [PATCH 2/6] feat(onboarding): warm runtimes concurrently and show waits honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things made first-run setup feel like a black box behind a Test button. Prefetch ran one runtime at a time, so the wait a user actually feels — wall clock — was the sum of three downloads instead of the longest one. They are independent artifacts keyed by agent type and installs are already deduped per machine and agent, so they now all start together; selecting a provider only moves it to the front of the launch order and never restarts work in flight. This stays off the renderer's critical path because progress fans out only to live listeners and otherwise just records a snapshot, so a concurrent download during the intro ceremony cannot become a render storm. A runtime progress status that already failed (`error`, `unsupported-platform`, `incompatible-host`) mapped back to `checking-runtime`, presenting a known failure as work still in progress. It resolves to a distinct `runtime-failed` phase wearing the failure tone; the final response still owns the durable reason. The waiting itself had no vocabulary. `AgentReadinessMark` gives it one, using assets that already ship: saturation carries readiness, so the logo wall reads as an inventory filling in as each warmed runtime lands rather than as a queue of pending work, and one ring carries the wait — filling when there is a real denominator (a download), orbiting when there is not (the ACP handshake), so a user learns the shape once and recognises both. A stage with no denominator reports elapsed seconds instead of inventing a percentage, because a wait you can measure is bounded and an open-ended one has no floor. Animation is CSS-only on `transform`, with a reduced-motion opt-out. Model: claude-opus-5 --- locales/en.json | 3 + locales/zh_CN.json | 3 + .../src/components/onboarding/AGENTS.md | 3 +- .../onboarding/provider-test-state.ts | 87 +++++++- .../onboarding/screens/providers-screen.tsx | 189 +++++++++++++++--- .../use-builtin-runtime-readiness.ts | 39 ++++ ...use-onboarding-builtin-runtime-prefetch.ts | 74 ++++--- .../shared/agent-readiness-mark.tsx | 137 +++++++++++++ .../stories/AgentReadinessMark.stories.tsx | 101 ++++++++++ packages/components/src/tailwind/index.css | 21 ++ ...nboarding-builtin-runtime-prefetch.test.ts | 122 +++++++---- .../tests/provider-test-state.test.ts | 74 +++++++ 12 files changed, 748 insertions(+), 105 deletions(-) create mode 100644 packages/components/src/components/onboarding/use-builtin-runtime-readiness.ts create mode 100644 packages/components/src/components/shared/agent-readiness-mark.tsx create mode 100644 packages/components/src/stories/AgentReadinessMark.stories.tsx diff --git a/locales/en.json b/locales/en.json index d239dbfc1..405fff27c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -991,6 +991,7 @@ "onboarding.projects.skip": "Skip for now", "onboarding.projects.waitingLocalAgent": "Waiting for the local agent to connect…", "onboarding.projects.title": "Pick a project to start with", + "onboarding.providers.activityRuntimeFailed": "Runtime failed", "onboarding.providers.addAnother": "Add another Agent", "onboarding.providers.addFirst": "Add your first Agent", "onboarding.providers.activityChecking": "Checking", @@ -1000,6 +1001,7 @@ "onboarding.providers.activityStarting": "Starting", "onboarding.providers.activityVerifying": "Verifying", "onboarding.providers.description": "Add an Agent now. Lody will continue setup and let you know if anything needs your attention.", + "onboarding.providers.failedAction": "Failed", "onboarding.providers.failureReasonA11y": "Failed: {{reason}}", "onboarding.providers.failureReasonTitle": "Why it failed", "onboarding.providers.localAgentUnreachable": "Could not reach the local agent. Please restart Lody and try again.", @@ -1025,6 +1027,7 @@ "onboarding.preview.agentVerifying": "Verifying the agent runtime…", "onboarding.preview.conversationStarting": "Starting your first task…", "onboarding.preview.projectImporting": "Adding project…", + "onboarding.providers.workingSeconds": "{{seconds}}s", "onboarding.shell.stepCounter": "Step {{current}} of {{total}}", "onboarding.summary.description": "You can add Agents and projects later from Settings.", "onboarding.summary.agent": "Agent", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 8c0e272a0..e30bfd2e0 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -991,6 +991,7 @@ "onboarding.projects.skip": "暂时跳过", "onboarding.projects.waitingLocalAgent": "正在等待本机 Agent 连接…", "onboarding.projects.title": "选择一个项目开始", + "onboarding.providers.activityRuntimeFailed": "运行时失败", "onboarding.providers.addAnother": "再添加一个 Agent", "onboarding.providers.addFirst": "添加第一个 Agent", "onboarding.providers.activityChecking": "检查中", @@ -1000,6 +1001,7 @@ "onboarding.providers.activityStarting": "启动中", "onboarding.providers.activityVerifying": "校验中", "onboarding.providers.description": "先添加一个 Agent。Lody 会继续完成配置,并在需要你处理时提醒你。", + "onboarding.providers.failedAction": "失败", "onboarding.providers.failureReasonA11y": "失败:{{reason}}", "onboarding.providers.failureReasonTitle": "失败原因", "onboarding.providers.localAgentUnreachable": "无法连接本机 Agent。请重启 Lody 后再试。", @@ -1025,6 +1027,7 @@ "onboarding.preview.agentVerifying": "正在验证 Agent runtime…", "onboarding.preview.conversationStarting": "正在启动你的第一个任务…", "onboarding.preview.projectImporting": "正在添加项目…", + "onboarding.providers.workingSeconds": "{{seconds}} 秒", "onboarding.shell.stepCounter": "第 {{current}} / {{total}} 步", "onboarding.summary.description": "之后可以在设置中添加 Agent 和项目。", "onboarding.summary.agent": "Agent", diff --git a/packages/components/src/components/onboarding/AGENTS.md b/packages/components/src/components/onboarding/AGENTS.md index 60425074b..d98175838 100644 --- a/packages/components/src/components/onboarding/AGENTS.md +++ b/packages/components/src/components/onboarding/AGENTS.md @@ -7,11 +7,12 @@ - User-facing onboarding copy calls the execution choice an Agent. Reserve Provider for Settings configuration management and internal `AgentConfig`/`ProviderSetup` state. - Completion stays in the existing router and navigates to the created session when one exists. Reload recovery must target the normal product root after completion. - Desktop onboarding owns the app theme for its whole route lifetime: enter and reload in `light`, and restore the persisted source to `system` only after completion succeeds or the route unmounts. +- Managed built-in runtimes prefetch CONCURRENTLY, starting when onboarding mounts and running through the intro ceremony. They are independent artifacts keyed by agent type and installs are already deduped per machine and agent, so serial prefetch only made the felt wait the sum of every download instead of the longest one. A selected provider moves to the front of the launch order; it never restarts or waits behind work already in flight. This stays off the renderer's critical path because `handleMachineAcpBinaryProgress` fans out only to live listeners and otherwise just records a snapshot: background prefetch must not subscribe to per-percent progress, so a concurrent download cannot turn into a render storm over the ceremony animation. - `ceremony/intro-sequence.tsx` owns the four-beat illustrated intro. Keep its approved assets and direction in `intro-illustration-direction.md`; setup screens must not replace it with a generic welcome card. - Setup screens use the real `TourStill` product composition. Its Browser beat includes the production Visual Annotation surfaces; do not replace the tour with a hand-built mock. - `TourApp` reuses production components against fixture state, so it must remain inside `TourCloudBoundary`. The boundary owns fixture identity, workspace, authentication, and cloud operations; no tour child may observe or call the outer app's cloud adapter. - The tour's runtime is a stand-in on BOTH planes: `TourCloudBoundary` for cloud operations and `createTourRepo` for `runtime.repo`. The reused components read as well as write — the composer opens the workspace catalog and machine Flock documents — so every tour document must open, read empty, and report its first remote sync as done, and writes on either plane must reject rather than silently succeed. Supply the missing plane; do not fork a repo-free copy of a product component to avoid it. -- Provider rows keep the last durable verification result separate from request-scoped activity. Runtime progress must come from the refresh request that owns the config, not the machine-and-agent global snapshot, and late results must not commit after edit, delete, replacement, or unmount. Active AgentConfig tests and durable ProviderSetup rows share the compact progress-button treatment; determinate progress fills the button, and indeterminate work uses its neutral label. Do not add a second activity row. A failed result keeps its latest reason inspectable from the badge until success or a config mutation clears it. +- Provider rows keep the last durable verification result separate from request-scoped activity. Runtime progress must come from the refresh request that owns the config, not the machine-and-agent global snapshot, and late results must not commit after edit, delete, replacement, or unmount. Active AgentConfig tests and durable ProviderSetup rows share the compact progress-button treatment; determinate progress fills the button, and indeterminate work uses its neutral label. Do not add a second activity row. A failed result keeps its latest reason inspectable from the badge until success or a config mutation clears it. A runtime progress status that already reported failure (`error`, `unsupported-platform`, `incompatible-host`) resolves to the `runtime-failed` phase and wears the failure tone, never back to `checking-runtime`: the final response still owns the durable reason, but in-flight activity must not present a known failure as work still in progress. - The first-task primary action never strands onboarding behind run prerequisites or Session persistence. It requests product navigation immediately; when that navigation succeeds and a runnable prompt exists, Session creation and dispatch continue only as best-effort background work and never navigate or delay entry into the product. Failed navigation creates no Session and remains retryable. An empty task becomes Enter Lody rather than a disabled final action. - Workspace is required product context when the platform exposes multi-workspace onboarding, so it has no Skip or Enter Lody action. A failed workspace-list read keeps the investigation detail and a real platform-backed Retry action. Mutations use bounded waits: after a stale write the UI releases its attempt lock so the user may retry or go back, while late results never navigate or clear a newer attempt. A slow slug availability query is still pending, not failed: explain the slow network, keep Create disabled until the server answers, and let the user restart the check without treating elapsed time as validation. Query errors stay inside the slug field with their detail and Retry. A slug-less existing workspace is repaired inline because its Settings route is unreachable until the slug exists. - Every onboarding error path writes its underlying error or failure detail to `console.error` even when the UI also shows an inline message or toast. Recoverable user actions must become retryable again after failure; never leave an error with only explanatory copy when the same operation can be attempted safely. diff --git a/packages/components/src/components/onboarding/provider-test-state.ts b/packages/components/src/components/onboarding/provider-test-state.ts index 1d799e752..c45a5e7bf 100644 --- a/packages/components/src/components/onboarding/provider-test-state.ts +++ b/packages/components/src/components/onboarding/provider-test-state.ts @@ -1,16 +1,25 @@ import type { AgentConfigId, MachineAcpBinaryProgressMessage } from '@lody/shared'; +import type { AgentReadiness } from '@/components/shared/agent-readiness-mark'; + export type ProviderTestActivityPhase = | 'checking-runtime' | 'downloading-runtime' | 'verifying-runtime' | 'extracting-runtime' | 'installing-runtime' - | 'probing-provider'; + | 'probing-provider' + | 'runtime-failed'; export type ProviderTestActivity = { phase: ProviderTestActivityPhase; percent?: number; + /** + * When the owning request started, so a stage with no denominator can still + * show elapsed time. A wait you can measure is bounded; an unbounded one is + * the thing that has no floor. + */ + startedAtMs?: number; }; export function providerTestActivityFromProgress( @@ -38,15 +47,85 @@ export function providerTestActivityFromProgress( case 'unsupported-platform': case 'incompatible-host': case 'error': - // The final refresh response owns the durable error. Until it arrives, - // keep the activity honest without briefly presenting a second result. - return { phase: 'checking-runtime' }; + // The final refresh response still owns the durable error and its reason; + // this phase only keeps the in-flight activity honest until it arrives. + // Reporting a runtime that already failed as 'checking-runtime' is how a + // known failure gets presented as work still in progress. + return { phase: 'runtime-failed' }; + } + + const unreachableStatus: never = progress.status; + throw new Error(`Unknown provider runtime progress status: ${String(unreachableStatus)}`); +} + +export type AgentRuntimeReadiness = { + readiness: AgentReadiness; + /** Only ever set while downloading, the one stage with a real denominator. */ + percent: number | null; +}; + +/** + * Turns the machine's ephemeral runtime progress into the readiness a mark can + * wear. Background prefetch fills those snapshots before the user reaches this + * step, so the logo wall reads as an inventory filling in rather than as a + * queue of pending work. + * + * A failed runtime reads as `cold`, not as a failure: the mark carries no + * failure vocabulary, and the row's own badge owns the reason. + */ +export function agentRuntimeReadinessFromProgress( + progress: MachineAcpBinaryProgressMessage | null | undefined +): AgentRuntimeReadiness { + if (!progress) return { readiness: 'cold', percent: null }; + switch (progress.status) { + case 'installed': + return { readiness: 'ready', percent: null }; + case 'downloading': + return { + readiness: 'arriving', + percent: + typeof progress.percent === 'number' + ? Math.min(100, Math.max(0, progress.percent)) + : null, + }; + case 'checking': + case 'not-installed': + case 'verifying': + case 'extracting': + case 'publishing': + return { readiness: 'arriving', percent: null }; + case 'unsupported-platform': + case 'incompatible-host': + case 'error': + return { readiness: 'cold', percent: null }; } const unreachableStatus: never = progress.status; throw new Error(`Unknown provider runtime progress status: ${String(unreachableStatus)}`); } +/** + * Readiness for a row whose test/setup request is in flight. The request owns + * the mark while it runs, so a determinate download fills the ring and every + * denominator-free stage — including the ACP handshake — orbits instead. + */ +export function agentRuntimeReadinessFromActivity( + activity: ProviderTestActivity | undefined +): AgentRuntimeReadiness | null { + if (!activity) return null; + if (activity.phase === 'runtime-failed') return { readiness: 'cold', percent: null }; + if (activity.phase === 'downloading-runtime') { + return { + readiness: 'arriving', + percent: + typeof activity.percent === 'number' + ? Math.min(100, Math.max(0, activity.percent)) + : null, + }; + } + return { readiness: 'arriving', percent: null }; +} + export type ProviderTestRun = { id: number; signal: AbortSignal; diff --git a/packages/components/src/components/onboarding/screens/providers-screen.tsx b/packages/components/src/components/onboarding/screens/providers-screen.tsx index 09c4b1439..dc20ead51 100644 --- a/packages/components/src/components/onboarding/screens/providers-screen.tsx +++ b/packages/components/src/components/onboarding/screens/providers-screen.tsx @@ -6,6 +6,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Loader2, Plus, Trash2, XCircle } import { REGISTRY_ACP_AGENTS, getBuiltinAgentByAgentType, + isManagedBuiltinAgentType, type AgentBrandId, type BuiltinAgentType, type ManagedBuiltinAgentType, @@ -51,6 +52,7 @@ import { resyncMachineFlockRows } from '@/hooks/use-machine-flock-rows'; import { useMachineAcpBinaryActions } from '@/hooks/use-machine-acp-binary-actions'; import { useProviderSetupRuntimeProgress } from '@/hooks/use-provider-setup-runtime-progress'; import { AgentIcon } from '@/components/icons/agent-icon'; +import { AgentReadinessMark } from '@/components/shared/agent-readiness-mark'; import { REGISTRY_AGENT_ICON_SVGS } from '@/components/icons/registry-agent-icons'; import { AgentConfigDialog, @@ -73,10 +75,13 @@ import { type OnboardingProviderStatus, } from '../provider-status'; import { + agentRuntimeReadinessFromActivity, createProviderTestRunRegistry, providerTestActivityFromProgress, + type AgentRuntimeReadiness, type ProviderTestActivity, } from '../provider-test-state'; +import { useBuiltinRuntimeReadiness } from '../use-builtin-runtime-readiness'; import { useOnboardingAnalytics } from '../onboarding-analytics'; export type ProviderTestStatus = OnboardingProviderStatus | 'needs-auth'; @@ -183,6 +188,12 @@ export interface ProvidersScreenViewProps { testActivities?: Record; /** Latest failed probe detail, kept available after its toast disappears. */ failureReasons?: Record; + /** + * Readiness of the managed built-in runtimes the background prefetch warms. + * Passed in rather than read from a runtime atom so this half stays + * presentational and story-renderable. + */ + runtimeReadiness?: Partial>; selectedProviderId?: string | null; /** True when the local machine record has not yet arrived. */ noLocalMachine: boolean; @@ -215,6 +226,7 @@ export function ProvidersScreenView({ testStatuses, testActivities = {}, failureReasons = {}, + runtimeReadiness = {}, selectedProviderId, noLocalMachine, localMachineId = null, @@ -332,8 +344,14 @@ export function ProvidersScreenView({ {configs.map((config) => { const status: ProviderTestStatus = testStatuses[config.id] ?? 'untested'; const activity = testActivities[config.id]; - const activityPercent = getProviderTestActivityPercent(activity); const selected = config.id === resolvedSelectedProviderId; + // 'needs-auth' stays ready on purpose: that agent arrived, it + // is waiting on the user, and the row's panel says so. + const rowReadiness: AgentRuntimeReadiness = + agentRuntimeReadinessFromActivity(activity) ?? + (status === 'failed' + ? { readiness: 'cold', percent: null } + : { readiness: 'ready', percent: null }); return ( (onSelect ? onSelect(config) : onEdit(config))} > -
- -
+ {/* The mark carries the work, so the row needs no second + activity element. A published config reads as ready + unless a request is running on it or it failed: + dimming an agent the user already has, because we + have not probed it, is the anxiety this replaces. */} +
{config.name}
@@ -397,14 +420,7 @@ export function ProvidersScreenView({ {t('common.edit', 'Edit')} {activity ? ( - + ) : status !== 'needs-auth' ? (
); } +/** + * The glyph inside a showcase chip. + * + * Only the managed built-in runtimes the background prefetch warms get the + * readiness treatment, and they light up from monochrome to full brand colour + * as each one lands. The rest keep the wall's resting monochrome look, because + * nothing is being prepared for them and a dimmed mark would imply otherwise. + */ +function ShowcaseChipMark({ + agent, + runtimeReadiness, +}: { + agent: ShowcaseAgent; + runtimeReadiness: Partial>; +}) { + const warmed = + agent.pick.kind === 'builtin' && isManagedBuiltinAgentType(agent.pick.agentType) + ? runtimeReadiness[agent.pick.agentType] + : undefined; + + if (warmed) { + return ( + + ); + } + + return ( + + + + ); +} + /** * "Logo wall" of supported ACP agents. Communicates that Lody runs far more * than the two built-ins; clicking a brand opens the create dialog pre-selected @@ -491,9 +557,11 @@ export function ProvidersScreenView({ function AgentShowcase({ disabled, onPick, + runtimeReadiness, }: { disabled: boolean; onPick: (pick: ShowcasePick) => void; + runtimeReadiness: Partial>; }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -527,14 +595,7 @@ function AgentShowcase({ 'disabled:pointer-events-none disabled:opacity-50' )} > - - - + {agent.label} ))} @@ -623,6 +684,7 @@ export function ProvidersScreen({ [allSetups, localMachineId] ); useProviderSetupRuntimeProgress(runtime, workspaceId, localSetups); + const runtimeReadiness = useBuiltinRuntimeReadiness(localMachineId); const [dialogMode, setDialogMode] = useState(null); const dialogOpen = dialogMode !== null; @@ -852,7 +914,7 @@ export function ProvidersScreen({ }); setTestActivities((prev) => ({ ...prev, - [config.id]: { phase: 'checking-runtime' }, + [config.id]: { phase: 'checking-runtime', startedAtMs: Date.now() }, })); void (async () => { try { @@ -864,7 +926,14 @@ export function ProvidersScreen({ if (!testRunsRef.current.isCurrent(config.id, run)) return; setTestActivities((prev) => ({ ...prev, - [config.id]: providerTestActivityFromProgress(progress), + [config.id]: { + ...providerTestActivityFromProgress(progress), + // Elapsed time belongs to the request, not to the stage it + // happens to be in, so it survives every phase change. + ...(prev[config.id]?.startedAtMs !== undefined + ? { startedAtMs: prev[config.id]?.startedAtMs } + : {}), + }, })); }, }); @@ -1107,6 +1176,7 @@ export function ProvidersScreen({ testStatuses={testStatuses} testActivities={testActivities} failureReasons={failureReasons} + runtimeReadiness={runtimeReadiness} selectedProviderId={selectedProviderId} noLocalMachine={!localMachine} localMachineId={localMachineId} @@ -1215,6 +1285,55 @@ export function ProvidersScreen({ ); } +/** Seconds since `startedAtMs`, ticking only while one is supplied. */ +function useElapsedSeconds(startedAtMs: number | null): number { + const [elapsedSeconds, setElapsedSeconds] = useState(0); + useEffect(() => { + if (startedAtMs === null) { + setElapsedSeconds(0); + return undefined; + } + const tick = (): void => + setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000))); + tick(); + const intervalId = window.setInterval(tick, 1000); + return () => window.clearInterval(intervalId); + }, [startedAtMs]); + return elapsedSeconds; +} + +/** + * The in-flight action for a row. + * + * A download has a denominator, so the button fills and reads as a percentage. + * Every other stage — the ACP handshake above all — has none, and inventing one + * would be a lie; it reports elapsed time instead, which is what turns an + * open-ended wait into a wait the user can measure. The badge beside it names + * the stage, so the two together read as "Starting · 14s". + */ +function ProviderActivityAction({ activity }: { activity: ProviderTestActivity }) { + const { t } = useTranslation(); + const percent = getProviderTestActivityPercent(activity); + const runtimeFailed = activity.phase === 'runtime-failed'; + const elapsedSeconds = useElapsedSeconds( + percent === null && !runtimeFailed ? (activity.startedAtMs ?? null) : null + ); + const label = (() => { + if (percent !== null) return `${percent}%`; + // Never label an already-failed runtime as ongoing work while the durable + // reason is still in flight. + if (runtimeFailed) return t('onboarding.providers.failedAction', 'Failed'); + if (elapsedSeconds > 0) { + return t('onboarding.providers.workingSeconds', '{{seconds}}s', { + seconds: elapsedSeconds, + }); + } + return t('onboarding.providers.workingAction', 'Working'); + })(); + + return ; +} + function getProviderTestActivityPercent(activity?: ProviderTestActivity): number | null { return activity?.phase === 'downloading-runtime' && typeof activity.percent === 'number' ? Math.min(100, Math.max(0, Math.round(activity.percent))) @@ -1246,15 +1365,25 @@ function ProviderStatusBadge({ return t('onboarding.providers.activityInstalling', 'Installing'); case 'probing-provider': return t('onboarding.providers.activityStarting', 'Starting'); + case 'runtime-failed': + return t('onboarding.providers.activityRuntimeFailed', 'Runtime failed'); } const unreachablePhase: never = activity.phase; throw new Error(`Unknown provider test activity phase: ${String(unreachablePhase)}`); })(); + // A runtime that already reported a failure must not keep wearing the + // in-progress tone; the final response still owns the durable reason. + const runtimeFailed = activity.phase === 'runtime-failed'; return ( {label} diff --git a/packages/components/src/components/onboarding/use-builtin-runtime-readiness.ts b/packages/components/src/components/onboarding/use-builtin-runtime-readiness.ts new file mode 100644 index 000000000..4745613f6 --- /dev/null +++ b/packages/components/src/components/onboarding/use-builtin-runtime-readiness.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import type { ManagedBuiltinAgentType, MachineId } from '@lody/shared'; + +import { activeWorkspaceRuntimeAtom } from '@/atoms/runtime'; +import { useMachineAcpBinaryProgress } from '@/hooks/use-machine-acp-binary-progress'; +import { BUILTIN_BACKGROUND_PREFETCH_AGENT_TYPES } from './use-onboarding-builtin-runtime-prefetch'; +import { agentRuntimeReadinessFromProgress, type AgentRuntimeReadiness } from './provider-test-state'; + +export type BuiltinRuntimeReadinessMap = Partial< + Record +>; + +/** + * Readiness of the runtimes the background prefetch warms, for the surfaces that + * light a mark up as each one lands. + * + * Reading it here rather than during the ceremony is deliberate: the runtime + * records a progress snapshot whether or not anyone listens, so subscribing on + * this screen picks up whatever already happened without a per-percent render + * ever landing on the intro animation. The hook list is fixed-length because + * the warmed set is a module constant. + */ +export function useBuiltinRuntimeReadiness(machineId: MachineId | null): BuiltinRuntimeReadinessMap { + const runtime = useAtomValue(activeWorkspaceRuntimeAtom); + const [kimi, codex, claude] = BUILTIN_BACKGROUND_PREFETCH_AGENT_TYPES; + const kimiProgress = useMachineAcpBinaryProgress(runtime, machineId, kimi); + const codexProgress = useMachineAcpBinaryProgress(runtime, machineId, codex); + const claudeProgress = useMachineAcpBinaryProgress(runtime, machineId, claude); + + return useMemo( + () => ({ + [kimi]: agentRuntimeReadinessFromProgress(kimiProgress), + [codex]: agentRuntimeReadinessFromProgress(codexProgress), + [claude]: agentRuntimeReadinessFromProgress(claudeProgress), + }), + [claude, claudeProgress, codex, codexProgress, kimi, kimiProgress] + ); +} diff --git a/packages/components/src/components/onboarding/use-onboarding-builtin-runtime-prefetch.ts b/packages/components/src/components/onboarding/use-onboarding-builtin-runtime-prefetch.ts index c9200dfba..ea1047919 100644 --- a/packages/components/src/components/onboarding/use-onboarding-builtin-runtime-prefetch.ts +++ b/packages/components/src/components/onboarding/use-onboarding-builtin-runtime-prefetch.ts @@ -6,7 +6,12 @@ import { currentWorkspaceIdAtom } from '@/atoms/workspace-context'; import { localCliStartingAtom, localMachineIdAtom } from '@/atoms/local-probe'; import { useMachineAcpBinaryActions } from '@/hooks/use-machine-acp-binary-actions'; -const BUILTIN_BACKGROUND_PREFETCH_AGENT_TYPES = [ +/** + * The managed runtimes warmed in the background. Exported because the provider + * step reads readiness for exactly this set: a mark may only light up for an + * agent something is actually preparing. + */ +export const BUILTIN_BACKGROUND_PREFETCH_AGENT_TYPES = [ 'kimi', 'codex', 'claude', @@ -29,8 +34,7 @@ type PrefetchErrorHandler = (agentType: ManagedBuiltinAgentType, error: unknown) type PrefetchScopeState = { readonly completed: Set; - pending: ManagedBuiltinAgentType[]; - running: ManagedBuiltinAgentType | null; + readonly running: Set; owner: symbol | null; task: PrefetchTask | null; onError: PrefetchErrorHandler | null; @@ -47,8 +51,7 @@ class OnboardingBuiltinRuntimePrefetchScheduler { ): { dispose: () => void } { const state = this.scopes.get(scopeKey) ?? { completed: new Set(), - pending: [], - running: null, + running: new Set(), owner: null, task: null, onError: null, @@ -58,10 +61,7 @@ class OnboardingBuiltinRuntimePrefetchScheduler { state.owner = owner; state.task = task; state.onError = onError ?? null; - state.pending = order.filter( - (agentType) => agentType !== state.running && !state.completed.has(agentType) - ); - this.runNext(scopeKey, state); + this.launchPending(scopeKey, state, order); return { dispose: () => { @@ -69,7 +69,6 @@ class OnboardingBuiltinRuntimePrefetchScheduler { state.owner = null; state.task = null; state.onError = null; - state.pending = []; }, }; } @@ -78,26 +77,35 @@ class OnboardingBuiltinRuntimePrefetchScheduler { this.scopes.clear(); } - private runNext(scopeKey: string, state: PrefetchScopeState): void { - if (state.running || !state.owner || !state.task) return; - const agentType = state.pending.shift(); - if (!agentType) return; + /** + * Every runtime that is not already running or finished starts now. `order` + * still decides which request reaches the wire first, so a selected provider + * gets the connection ahead of the rest without waiting behind it. + */ + private launchPending( + scopeKey: string, + state: PrefetchScopeState, + order: readonly ManagedBuiltinAgentType[] + ): void { + if (!state.owner || !state.task) return; const task = state.task; const onError = state.onError; - state.running = agentType; - void Promise.resolve() - .then(() => task(agentType)) - .then(() => { - state.completed.add(agentType); - }) - .catch((error) => { - onError?.(agentType, error); - }) - .finally(() => { - if (this.scopes.get(scopeKey) !== state) return; - state.running = null; - this.runNext(scopeKey, state); - }); + for (const agentType of order) { + if (state.running.has(agentType) || state.completed.has(agentType)) continue; + state.running.add(agentType); + void Promise.resolve() + .then(() => task(agentType)) + .then(() => { + state.completed.add(agentType); + }) + .catch((error) => { + onError?.(agentType, error); + }) + .finally(() => { + if (this.scopes.get(scopeKey) !== state) return; + state.running.delete(agentType); + }); + } } } @@ -113,9 +121,13 @@ export const __onboardingBuiltinRuntimePrefetchForTests = { /** * Onboarding should not wait for a user to reach or interact with the provider - * step before managed built-in runtimes begin downloading. Background work is - * serial across effect restarts. Selecting a provider reorders work that has - * not started; the current download is allowed to finish first. + * step before managed built-in runtimes begin downloading. The runtimes are + * downloaded CONCURRENTLY: the wait a user actually feels is wall-clock, and + * running them one at a time made it the sum of three downloads instead of the + * longest one. They are independent artifacts keyed by agent type, and + * `useMachineAcpBinaryActions` already dedupes installs per machine and agent, + * so nothing is duplicated by starting them together. Selecting a provider only + * moves it to the front of the launch order; work already in flight continues. */ export function useOnboardingBuiltinRuntimePrefetch( preferredAgentType: ManagedBuiltinAgentType | null diff --git a/packages/components/src/components/shared/agent-readiness-mark.tsx b/packages/components/src/components/shared/agent-readiness-mark.tsx new file mode 100644 index 000000000..11a622a34 --- /dev/null +++ b/packages/components/src/components/shared/agent-readiness-mark.tsx @@ -0,0 +1,137 @@ +import type { AgentBrandId, AgentConfigCliType } from '@lody/shared'; + +import { AgentIcon } from '@/components/icons/agent-icon'; +import { cn } from '@/lib/utils'; + +/** + * How far along an agent is toward being usable, expressed on the agent's own + * mark rather than as a separate status word beside it. + * + * - `cold` — nothing has been prepared yet: monochrome and dim. + * - `arriving` — being prepared: still monochrome, and the mark carries a ring. + * - `ready` — full brand contrast, no ring. A ready agent says nothing at all, + * because a badge confirming success only advertises that failure exists. + */ +export type AgentReadiness = 'cold' | 'arriving' | 'ready'; + +const RING_RADIUS = 45; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; +/** Visible sweep of the indeterminate arc, as a fraction of the ring. */ +const ORBIT_ARC_FRACTION = 0.22; + +const SIZES = { + sm: { box: 'h-7 w-7', icon: 'h-3 w-3', stroke: 8 }, + md: { box: 'h-10 w-10', icon: 'h-4.5 w-4.5', stroke: 7 }, + lg: { box: 'h-14 w-14', icon: 'h-6 w-6', stroke: 6 }, +} as const; + +export type AgentReadinessMarkProps = { + cliType: AgentConfigCliType; + agentType: string; + brandId?: AgentBrandId; + env?: Record; + readiness: AgentReadiness; + /** + * Determinate progress, 0-100. Supplied only while `arriving`, and only when + * the work actually has a denominator — a download does, an ACP handshake + * does not. `null` keeps the ring on its indeterminate orbit instead of + * inventing a number. + */ + percent?: number | null; + size?: keyof typeof SIZES; + className?: string; + /** Describes the mark for assistive tech; the visual carries no text. */ + ariaLabel?: string; +}; + +export function AgentReadinessMark({ + cliType, + agentType, + brandId, + env, + readiness, + percent = null, + size = 'md', + className, + ariaLabel, +}: AgentReadinessMarkProps) { + const { box, icon, stroke } = SIZES[size]; + const determinatePercent = + typeof percent === 'number' && Number.isFinite(percent) + ? Math.min(100, Math.max(0, percent)) + : null; + + return ( + + {readiness === 'arriving' ? ( + // -rotate-90 puts 0% at twelve o'clock; the orbit rotation composes on + // the inner group so both transforms stay independent. + + ) : null} + + + ); +} diff --git a/packages/components/src/stories/AgentReadinessMark.stories.tsx b/packages/components/src/stories/AgentReadinessMark.stories.tsx new file mode 100644 index 000000000..cdf2551be --- /dev/null +++ b/packages/components/src/stories/AgentReadinessMark.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { AgentReadinessMark } from '@/components/shared/agent-readiness-mark'; + +const meta = { + title: 'Shared/AgentReadinessMark', + component: AgentReadinessMark, + parameters: { layout: 'centered' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +/** + * The whole vocabulary in one frame: saturation carries readiness, and the ring + * carries the wait — filling when there is a denominator, orbiting when there + * is not. + */ +export const Vocabulary: Story = { + args: { cliType: 'builtin', agentType: 'codex', readiness: 'ready' }, + render: () => ( +
+ + + + + + + + + + + + + + + +
+ ), +}; + +/** The logo wall lighting up: an inventory filling in, not a queue of waits. */ +export const InventoryFillingIn: Story = { + args: { cliType: 'builtin', agentType: 'kimi', readiness: 'ready' }, + render: () => ( +
+ + + +
+ ), +}; + +export const Sizes: Story = { + args: { cliType: 'builtin', agentType: 'claude', readiness: 'arriving', percent: 40 }, + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/components/src/tailwind/index.css b/packages/components/src/tailwind/index.css index a32f60aa9..b89f352b7 100644 --- a/packages/components/src/tailwind/index.css +++ b/packages/components/src/tailwind/index.css @@ -1891,6 +1891,27 @@ pre, } } +/* An agent runtime whose wait has no denominator (the ACP handshake) gets the + same ring as a download, on an orbit instead of a fill. Transform-only and + CSS-driven, so React stays idle while it spins. */ +@keyframes agent-readiness-orbit { + to { + transform: rotate(360deg); + } +} + +.agent-readiness-orbit { + animation: agent-readiness-orbit 1.4s linear infinite; + transform-origin: 50% 50%; + will-change: transform; +} + +@media (prefers-reduced-motion: reduce) { + .agent-readiness-orbit { + animation: none; + } +} + /* Agent activity stays on compositor-friendly transform/opacity animations. The highlighted label is a pseudo-element so React remains idle while the agent is working. */ diff --git a/packages/components/tests/onboarding-builtin-runtime-prefetch.test.ts b/packages/components/tests/onboarding-builtin-runtime-prefetch.test.ts index b98b31d65..a534d4426 100644 --- a/packages/components/tests/onboarding-builtin-runtime-prefetch.test.ts +++ b/packages/components/tests/onboarding-builtin-runtime-prefetch.test.ts @@ -3,6 +3,44 @@ import type { ManagedBuiltinAgentType } from '@lody/shared'; import { __onboardingBuiltinRuntimePrefetchForTests as prefetch } from '../src/components/onboarding/use-onboarding-builtin-runtime-prefetch'; +type Deferred = { promise: Promise; resolve: () => void }; + +function createDeferred(): Deferred { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +/** + * The scheduler records completion in `.then`/`.finally` hops, so the test has + * to let those microtasks run. This is a fixed number of microtask turns, not a + * timer or a wall-clock wait, so it cannot race. + */ +async function drainMicrotasks(): Promise { + for (let turn = 0; turn < 8; turn += 1) await Promise.resolve(); +} + +/** Records start order and reports each start through its own explicit signal. */ +function createRecorder(hold?: Deferred) { + const starts: ManagedBuiltinAgentType[] = []; + const started = new Map(); + const signalFor = (agentType: ManagedBuiltinAgentType): Deferred => { + const existing = started.get(agentType); + if (existing) return existing; + const deferred = createDeferred(); + started.set(agentType, deferred); + return deferred; + }; + const run = async (agentType: ManagedBuiltinAgentType): Promise => { + starts.push(agentType); + signalFor(agentType).resolve(); + if (hold) await hold.promise; + }; + return { starts, run, waitForStart: (agentType: ManagedBuiltinAgentType) => signalFor(agentType).promise }; +} + describe('onboarding builtin runtime prefetch scheduling', () => { beforeEach(() => { prefetch.reset(); @@ -14,46 +52,52 @@ describe('onboarding builtin runtime prefetch scheduling', () => { expect(prefetch.resolvePrefetchOrder('claude')).toEqual(['claude', 'kimi', 'codex']); }); - it('finishes the running task before starting the newly preferred runtime', async () => { - const createDeferred = () => { - let resolve!: () => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; - }; - const kimiStarted = createDeferred(); - const kimiFinished = createDeferred(); - const codexStarted = createDeferred(); - const starts: ManagedBuiltinAgentType[] = []; - const runTask = async (agentType: ManagedBuiltinAgentType): Promise => { - starts.push(agentType); - if (agentType === 'kimi') { - kimiStarted.resolve(); - await kimiFinished.promise; - } - if (agentType === 'codex') { - codexStarted.resolve(); - } - }; - - const initial = prefetch.schedule( - 'workspace:machine', - prefetch.resolvePrefetchOrder(null), - runTask - ); - await kimiStarted.promise; + it('starts every runtime concurrently instead of queueing behind the first', async () => { + const hold = createDeferred(); + const recorder = createRecorder(hold); + + prefetch.schedule('workspace:machine', prefetch.resolvePrefetchOrder(null), recorder.run); + + // No task has settled, so a serial scheduler would never start the other + // two and this await would never resolve. + await Promise.all([ + recorder.waitForStart('kimi'), + recorder.waitForStart('codex'), + recorder.waitForStart('claude'), + ]); + expect(recorder.starts).toEqual(['kimi', 'codex', 'claude']); + + hold.resolve(); + }); + + it('launches the preferred runtime first without restarting work in flight', async () => { + const hold = createDeferred(); + const recorder = createRecorder(hold); + + const initial = prefetch.schedule('workspace:machine', ['kimi'], recorder.run); + await recorder.waitForStart('kimi'); initial.dispose(); - prefetch.schedule( - 'workspace:machine', - prefetch.resolvePrefetchOrder('codex'), - runTask - ); - expect(starts).toEqual(['kimi']); - - kimiFinished.resolve(); - await codexStarted.promise; - expect(starts.slice(0, 2)).toEqual(['kimi', 'codex']); + prefetch.schedule('workspace:machine', prefetch.resolvePrefetchOrder('codex'), recorder.run); + await Promise.all([recorder.waitForStart('codex'), recorder.waitForStart('claude')]); + + // 'codex' leads the new order, and the in-flight 'kimi' is not started twice. + expect(recorder.starts).toEqual(['kimi', 'codex', 'claude']); + + hold.resolve(); + }); + + it('does not restart a runtime that already finished', async () => { + const recorder = createRecorder(); + + const initial = prefetch.schedule('workspace:machine', ['kimi'], recorder.run); + await recorder.waitForStart('kimi'); + await drainMicrotasks(); + + initial.dispose(); + prefetch.schedule('workspace:machine', prefetch.resolvePrefetchOrder(null), recorder.run); + await Promise.all([recorder.waitForStart('codex'), recorder.waitForStart('claude')]); + + expect(recorder.starts).toEqual(['kimi', 'codex', 'claude']); }); }); diff --git a/packages/components/tests/provider-test-state.test.ts b/packages/components/tests/provider-test-state.test.ts index 9613a48fe..cd0131376 100644 --- a/packages/components/tests/provider-test-state.test.ts +++ b/packages/components/tests/provider-test-state.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import type { AgentConfigId, MachineAcpBinaryProgressMessage, MachineId } from '@lody/shared'; import { + agentRuntimeReadinessFromActivity, + agentRuntimeReadinessFromProgress, createProviderTestRunRegistry, providerTestActivityFromProgress, } from '../src/components/onboarding/provider-test-state'; @@ -41,6 +43,14 @@ describe('providerTestActivityFromProgress', () => { phase: 'probing-provider', }); }); + + it('does not present a runtime that already failed as still checking', () => { + for (const status of ['error', 'unsupported-platform', 'incompatible-host'] as const) { + expect(providerTestActivityFromProgress(progress(status))).toEqual({ + phase: 'runtime-failed', + }); + } + }); }); describe('createProviderTestRunRegistry', () => { @@ -66,3 +76,67 @@ describe('createProviderTestRunRegistry', () => { expect(registry.finish(configId, completed)).toBe(false); }); }); + +describe('agentRuntimeReadinessFromProgress', () => { + it('lights the mark up only once the runtime has landed', () => { + expect(agentRuntimeReadinessFromProgress(progress('installed'))).toEqual({ + readiness: 'ready', + percent: null, + }); + expect(agentRuntimeReadinessFromProgress(null)).toEqual({ + readiness: 'cold', + percent: null, + }); + }); + + it('only carries a denominator while downloading', () => { + expect(agentRuntimeReadinessFromProgress(progress('downloading', 62.5))).toEqual({ + readiness: 'arriving', + percent: 62.5, + }); + expect(agentRuntimeReadinessFromProgress(progress('downloading'))).toEqual({ + readiness: 'arriving', + percent: null, + }); + for (const status of ['checking', 'verifying', 'extracting', 'publishing'] as const) { + expect(agentRuntimeReadinessFromProgress(progress(status))).toEqual({ + readiness: 'arriving', + percent: null, + }); + } + }); + + it('reads a failed runtime as cold, leaving the reason to the row badge', () => { + for (const status of ['error', 'unsupported-platform', 'incompatible-host'] as const) { + expect(agentRuntimeReadinessFromProgress(progress(status))).toEqual({ + readiness: 'cold', + percent: null, + }); + } + }); +}); + +describe('agentRuntimeReadinessFromActivity', () => { + it('leaves the mark to its durable state when nothing is in flight', () => { + expect(agentRuntimeReadinessFromActivity(undefined)).toBeNull(); + }); + + it('fills for a download and orbits for every denominator-free stage', () => { + expect( + agentRuntimeReadinessFromActivity({ phase: 'downloading-runtime', percent: 140 }) + ).toEqual({ readiness: 'arriving', percent: 100 }); + // The ACP handshake is the case that matters: no percentage exists, so none + // may be invented. + expect(agentRuntimeReadinessFromActivity({ phase: 'probing-provider' })).toEqual({ + readiness: 'arriving', + percent: null, + }); + }); + + it('stops presenting an already-failed runtime as arriving', () => { + expect(agentRuntimeReadinessFromActivity({ phase: 'runtime-failed' })).toEqual({ + readiness: 'cold', + percent: null, + }); + }); +}); From 310fa2d7761490569e3f149e8ec5010f0c6ef2ff Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:40:44 +0000 Subject: [PATCH 3/6] fix(onboarding): explain failures and animate stalled startup Replace raw provider setup failure enums with localized, actionable recovery copy on the onboarding summary. Give a bypassed startup step a reduced-motion-aware spring probe that reaches toward the next stage and rebounds while the CLI remains delayed. Add Storybook and regression coverage for both states. Model: gpt-5 --- locales/en.json | 4 +- locales/zh_CN.json | 4 +- .../src/components/onboarding/AGENTS.md | 1 + .../onboarding/onboarding-loading.tsx | 29 +++++++++++-- .../onboarding/screens/summary-screen.tsx | 41 +++++++++++++++---- .../OnboardingCompletionJourney.stories.tsx | 14 +++++++ .../components/tests/onboarding-flow.test.tsx | 14 ++++++- 7 files changed, 92 insertions(+), 15 deletions(-) diff --git a/locales/en.json b/locales/en.json index 405fff27c..770f13672 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1037,7 +1037,9 @@ "onboarding.summary.agentRetryHint": "Agent setup can be retried here.", "onboarding.summary.failedDescription": "Your Agent could not finish setup. Retry here or enter Lody and finish later.", "onboarding.summary.failedTitle": "Agent setup needs attention", - "onboarding.summary.failureCode": "Failure code: {{code}}", + "onboarding.summary.failure.runtimeInstallFailed": "Lody could not download the Agent runtime. Check your connection and try again.", + "onboarding.summary.failure.runtimeUnavailable": "This Agent is not available on the selected machine. Update Lody or choose another machine in Settings.", + "onboarding.summary.failure.verificationFailed": "Lody could not verify this Agent. Check its sign-in or credentials and try again.", "onboarding.summary.open": "Open Lody", "onboarding.summary.notConfigured": "Not configured", "onboarding.summary.notSelected": "Not selected", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index e30bfd2e0..a2433c1ad 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1037,7 +1037,9 @@ "onboarding.summary.agentRetryHint": "你可以在这里重试 Agent 配置。", "onboarding.summary.failedDescription": "Agent 未能完成配置。你可以在这里重试,也可以先进入 Lody 稍后处理。", "onboarding.summary.failedTitle": "Agent 配置需要处理", - "onboarding.summary.failureCode": "失败代码:{{code}}", + "onboarding.summary.failure.runtimeInstallFailed": "Lody 无法下载 Agent 运行环境。请检查网络连接后重试。", + "onboarding.summary.failure.runtimeUnavailable": "所选机器无法使用这个 Agent。请更新 Lody,或前往设置选择其他机器。", + "onboarding.summary.failure.verificationFailed": "Lody 无法验证这个 Agent。请检查登录状态或凭据后重试。", "onboarding.summary.open": "打开 Lody", "onboarding.summary.notConfigured": "尚未配置", "onboarding.summary.notSelected": "尚未选择", diff --git a/packages/components/src/components/onboarding/AGENTS.md b/packages/components/src/components/onboarding/AGENTS.md index d98175838..56f09a040 100644 --- a/packages/components/src/components/onboarding/AGENTS.md +++ b/packages/components/src/components/onboarding/AGENTS.md @@ -19,3 +19,4 @@ - Desktop onboarding analytics use the shared flow/step/operation/completion event contract and one anonymous session-scoped `flow_id`. Capture is hard-gated by the platform `telemetry` capability, so the OSS local composition emits nothing. Properties may contain fixed state, action, failure-code, attempt, count, and duration values; never send names, slugs, repository paths, prompts, raw error messages, or other user-authored text. - First Task keeps an exact published `AgentConfig` selection and may switch only among published configs on the selected project's machine. It never falls back to another config when that selection disappears. Skipping First Task completes onboarding without creating a Session, first turn, or dispatch request. - Skipping provider setup completes onboarding as an exploration path. Its summary must not claim the product is ready to run; it sends the user into Lody and points later agent setup to Settings. This is distinct from the pending-setup summary, which uses a compact status table and says setup is still progressing without guessing whether the current work is download, installation, verification, authentication, or machine availability. The summary reads the live ProviderSetupTask: a durable `failed` status shows as setup failed (never as still progressing), and a deleted task shows as not configured — unless the machine has already replaced it with a published AgentConfig under the same id, which reads as ready. +- Provider setup failure codes are internal diagnostics. Onboarding maps them to specific, actionable user copy and never prints the raw enum in the UI. diff --git a/packages/components/src/components/onboarding/onboarding-loading.tsx b/packages/components/src/components/onboarding/onboarding-loading.tsx index b020d86bd..d23ef3196 100644 --- a/packages/components/src/components/onboarding/onboarding-loading.tsx +++ b/packages/components/src/components/onboarding/onboarding-loading.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import { motion } from 'framer-motion'; +import { motion, useReducedMotion } from 'framer-motion'; import type { CliRuntimeStartupStage, ElectronCliPhase, ElectronCliState } from '@lody/shared'; import { cn } from '@/lib/utils'; import { getIpcServices, onIpcEvent, sendIpc } from '@/lib/electron-ipc-client'; @@ -29,6 +29,7 @@ export function OnboardingLoadingView({ bypassed = false, }: OnboardingLoadingViewProps) { const { t } = useTranslation(); + const shouldReduceMotion = useReducedMotion(); const reachedIndex = useMemo(() => { if (phase === 'running') return STARTUP_STAGES.length - 1; @@ -94,6 +95,7 @@ export function OnboardingLoadingView({ {STARTUP_STAGES.map((s, i) => { const reached = i <= reachedIndex; const active = i === reachedIndex && phase !== 'running'; + const stalledProbe = bypassed && active && i < STARTUP_STAGES.length - 1; return (
  • - {stageLabel(s)} diff --git a/packages/components/src/components/onboarding/screens/summary-screen.tsx b/packages/components/src/components/onboarding/screens/summary-screen.tsx index 7cf9b6eca..2bc531287 100644 --- a/packages/components/src/components/onboarding/screens/summary-screen.tsx +++ b/packages/components/src/components/onboarding/screens/summary-screen.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Check, Clock3, Loader2, Minus, RotateCcw, XCircle } from 'lucide-react'; +import type { ProviderSetupFailureCode } from '@lody/shared'; import { Table, TableBody, TableCell, TableRow } from '@/ui/table'; import { Button } from '@/ui/button'; import { OnboardingBackButton, OnboardingNextButton, OnboardingShell } from '../onboarding-shell'; @@ -21,7 +22,7 @@ export function SummaryScreen({ }: { agentState: OnboardingSummaryAgentState; agentName?: string; - agentFailureCode?: string; + agentFailureCode?: ProviderSetupFailureCode; projectName?: string; onBack: () => void; onComplete: () => void; @@ -101,14 +102,11 @@ export function SummaryScreen({ {agentState === 'failed' && onRetryAgent ? (
    -

    {t('onboarding.summary.agentRetryHint', 'Agent setup can be retried here.')}

    - {agentFailureCode ? ( -

    - {t('onboarding.summary.failureCode', 'Failure code: {{code}}', { - code: agentFailureCode, - })} -

    - ) : null} +

    + {agentFailureCode + ? agentFailureMessage(t, agentFailureCode) + : t('onboarding.summary.agentRetryHint', 'Agent setup can be retried here.')} +

    {retryError ? (

    {retryError}

    ) : null} @@ -163,6 +161,31 @@ export function SummaryScreen({ ); } +function agentFailureMessage( + t: ReturnType['t'], + failureCode: ProviderSetupFailureCode +): string { + switch (failureCode) { + case 'runtime-unavailable': + return t( + 'onboarding.summary.failure.runtimeUnavailable', + 'This Agent is not available on the selected machine. Update Lody or choose another machine in Settings.' + ); + case 'runtime-install-failed': + return t( + 'onboarding.summary.failure.runtimeInstallFailed', + 'Lody could not download the Agent runtime. Check your connection and try again.' + ); + case 'verification-failed': + return t( + 'onboarding.summary.failure.verificationFailed', + 'Lody could not verify this Agent. Check its sign-in or credentials and try again.' + ); + default: + return failureCode satisfies never; + } +} + function SummaryRow({ label, value, diff --git a/packages/components/src/stories/OnboardingCompletionJourney.stories.tsx b/packages/components/src/stories/OnboardingCompletionJourney.stories.tsx index 524d65b37..1905dc1aa 100644 --- a/packages/components/src/stories/OnboardingCompletionJourney.stories.tsx +++ b/packages/components/src/stories/OnboardingCompletionJourney.stories.tsx @@ -195,3 +195,17 @@ export const ProviderSkip: Story = {}; export const ProviderPendingSetup: Story = { render: () => , }; + +export const ProviderSetupFailed: Story = { + render: () => ( + undefined} + /> + ), +}; diff --git a/packages/components/tests/onboarding-flow.test.tsx b/packages/components/tests/onboarding-flow.test.tsx index 395b67ff5..12e4c7626 100644 --- a/packages/components/tests/onboarding-flow.test.tsx +++ b/packages/components/tests/onboarding-flow.test.tsx @@ -54,6 +54,7 @@ import { OnboardingOverlay, resolveDesktopOnboardingPhase, } from '../src/components/onboarding/onboarding-overlay'; +import { OnboardingLoadingView } from '../src/components/onboarding/onboarding-loading'; import { getDesktopOnboardingSteps } from '../src/components/onboarding/onboarding-steps'; import { ProjectsScreen, @@ -157,6 +158,14 @@ describe('desktop onboarding flow', () => { expect(mocks.getCliState).not.toHaveBeenCalled(); }); + it('marks the stalled startup step as a spring probe after the bypass', async () => { + await act(async () => { + root?.render(); + }); + + expect(container.querySelector('[data-onboarding-stalled-probe]')).not.toBeNull(); + }); + it('derives steps and repairs stale phases from platform capabilities', () => { expect(getDesktopOnboardingSteps({ cloudAccount: false, multiWorkspace: false })).toEqual([ 'ceremony', @@ -449,7 +458,10 @@ describe('desktop onboarding flow', () => { ); }); - expect(container.textContent).toContain('Failure code: runtime-install-failed'); + expect(container.textContent).toContain( + 'Lody could not download the Agent runtime. Check your connection and try again.' + ); + expect(container.textContent).not.toContain('runtime-install-failed'); await act(async () => { findButton(container, 'Retry').dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); From 0366dcc5eb1e10ec4687ac8a4b5b917231db8f05 Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:11:05 +0000 Subject: [PATCH 4/6] fix: budget the whole ACP startup recovery, not one attempt The client backstop covered a single cold `npx` initialize plus `session/new`, but `runNpxStartupWithRecovery` purges and retries up to three times and each retry gets those full per-attempt timeouts again. A 450s deadline therefore expired during attempt two while the machine was still executing its intended recovery, reporting a client timeout instead of the reason the machine would have given on attempt three. Derive the machine budget from every attempt the policy may make plus the cleanup between them, and make the attempt count a shared binding the policy takes as its default so the two cannot drift. Hide the onboarding elapsed-seconds counter until a wait has run past a threshold. A counter is not neutral: it reassures at 3s and applies pressure at 87s, and measuring a wait is what the user needs once it has already begun to look abnormal, so an ordinary start reads as its stage name alone. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 3 ++ apps/cli/src/agent/acp-npx-startup-policy.ts | 9 ++-- apps/cli/src/agent/npx-cache.test.ts | 50 +++++++++++++++++++ .../src/components/onboarding/AGENTS.md | 1 + .../onboarding/screens/providers-screen.tsx | 20 ++++++-- packages/shared/src/acp-startup-budget.ts | 35 ++++++++++++- .../shared/tests/acp-startup-budget.test.ts | 18 ++++++- 7 files changed, 125 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c6566d888..a498f8f72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,9 @@ and Web/mobile app sources. died without replying, so it derives from `packages/shared/src/acp-startup-budget.ts` and stays strictly above the machine's worst case; never set a second, smaller client deadline that expires work the machine is still doing. + That worst case is the whole recovery policy, not one attempt: a cold `npx` initialize + timeout makes the machine purge and retry, and every retry gets the full per-attempt + timeouts again, so the attempt count lives in the same shared binding as the timeouts. - Managed runtime downloads default to the public R2-backed channel owned by `packages/platform/src/runtime-artifacts.ts`; local and cloud assembly must use that same constant. `LODY_RUNTIME_BASE_URL` is only an explicit mirror override. diff --git a/apps/cli/src/agent/acp-npx-startup-policy.ts b/apps/cli/src/agent/acp-npx-startup-policy.ts index 2cc527e84..f1dd33e11 100644 --- a/apps/cli/src/agent/acp-npx-startup-policy.ts +++ b/apps/cli/src/agent/acp-npx-startup-policy.ts @@ -1,4 +1,4 @@ -import { ACP_COLD_NPX_INIT_TIMEOUT_MS } from '@lody/shared'; +import { ACP_COLD_NPX_INIT_TIMEOUT_MS, ACP_NPX_STARTUP_MAX_ATTEMPTS } from '@lody/shared'; import type { AcpStartupTimeoutOptions } from './agent-client'; import { AcpTimeoutError } from './agent-client'; import type { Logger } from '@/utils/logger'; @@ -17,9 +17,12 @@ import { /** * Re-exported so the client-side backstop in `@lody/shared/acp-startup-budget` - * and this startup path cannot drift into two different worst cases. + * and this startup path cannot drift into two different worst cases. The + * backstop has to cover every attempt this policy may make, not just one, so + * the attempt count is part of the same shared binding as the timeout. */ export const COLD_NPX_INIT_TIMEOUT_MS = ACP_COLD_NPX_INIT_TIMEOUT_MS; +export const DEFAULT_NPX_STARTUP_MAX_ATTEMPTS = ACP_NPX_STARTUP_MAX_ATTEMPTS; export type NpxStartupAttemptInput = { attempt: number; @@ -104,7 +107,7 @@ export async function runNpxStartupWithRecovery( }); } - const maxAttempts = options.maxAttempts ?? 3; + const maxAttempts = options.maxAttempts ?? DEFAULT_NPX_STARTUP_MAX_ATTEMPTS; const coldInitTimeoutMs = options.coldInitTimeoutMs ?? COLD_NPX_INIT_TIMEOUT_MS; const npxCacheRoot = getConfiguredNpxCacheRoot(options.env); const roots = options.npxCacheRoots ?? (npxCacheRoot ? [npxCacheRoot] : undefined); diff --git a/apps/cli/src/agent/npx-cache.test.ts b/apps/cli/src/agent/npx-cache.test.ts index fcb00c38d..f58c14164 100644 --- a/apps/cli/src/agent/npx-cache.test.ts +++ b/apps/cli/src/agent/npx-cache.test.ts @@ -2,9 +2,16 @@ import { homedir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS, + ACP_INIT_TIMEOUT_MS, + ACP_NEW_SESSION_TIMEOUT_MS, + ACP_NPX_STARTUP_MAX_ATTEMPTS, +} from '@lody/shared'; import { AcpTimeoutError } from './agent-client'; import { COLD_NPX_INIT_TIMEOUT_MS, + DEFAULT_NPX_STARTUP_MAX_ATTEMPTS, runNpxStartupWithRecovery, type NpxStartupAttemptInput, } from './acp-npx-startup-policy'; @@ -509,6 +516,49 @@ describe('runNpxStartupWithRecovery', () => { expect(new Set(io.removed)).toEqual(new Set([npxRoot, cacache])); }); + it('keeps a whole cold-timeout retry run inside the client backstop', async () => { + // The client budget is derived from this loop, so the loop is what has to + // prove the budget: every attempt it is allowed to make, at the timeouts it + // hands each attempt, must still fit under the backstop. Otherwise the + // client reports a timeout while the machine is mid-recovery. + const attempts: NpxStartupAttemptInput[] = []; + const cache = getLodyNpmCacheDir(); + const npxRoot = join(cache, '_npx'); + const io = makeIo({ dirs: { [npxRoot]: [], [join(cache, '_cacache')]: [] } }); + + await expect( + runNpxStartupWithRecovery({ + command: 'npx', + args: npxArgs(), + env: { npm_config_cache: cache }, + logger, + logPrefix: '[test]', + npxCacheIo: io, + npxCacheRoots: [npxRoot], + getStderrTail: () => '', + attempt: async (input) => { + attempts.push(input); + throw new AcpTimeoutError( + 'connection.initialize', + input.startupTimeouts?.initTimeoutMs ?? ACP_INIT_TIMEOUT_MS, + `session-${input.attempt}` + ); + }, + }) + ).rejects.toBeInstanceOf(AcpTimeoutError); + + expect(DEFAULT_NPX_STARTUP_MAX_ATTEMPTS).toBe(ACP_NPX_STARTUP_MAX_ATTEMPTS); + expect(attempts).toHaveLength(ACP_NPX_STARTUP_MAX_ATTEMPTS); + const worstCaseMs = attempts.reduce( + (total, input) => + total + + (input.startupTimeouts?.initTimeoutMs ?? ACP_INIT_TIMEOUT_MS) + + (input.startupTimeouts?.newSessionTimeoutMs ?? ACP_NEW_SESSION_TIMEOUT_MS), + 0 + ); + expect(worstCaseMs).toBeLessThan(ACP_CAPABILITIES_REFRESH_CLIENT_BACKSTOP_MS); + }); + it('refreshes stale npm metadata online once for a missing exact version', async () => { const attempts: NpxStartupAttemptInput[] = []; const io = makeIo({}); diff --git a/packages/components/src/components/onboarding/AGENTS.md b/packages/components/src/components/onboarding/AGENTS.md index 56f09a040..071c92db3 100644 --- a/packages/components/src/components/onboarding/AGENTS.md +++ b/packages/components/src/components/onboarding/AGENTS.md @@ -13,6 +13,7 @@ - `TourApp` reuses production components against fixture state, so it must remain inside `TourCloudBoundary`. The boundary owns fixture identity, workspace, authentication, and cloud operations; no tour child may observe or call the outer app's cloud adapter. - The tour's runtime is a stand-in on BOTH planes: `TourCloudBoundary` for cloud operations and `createTourRepo` for `runtime.repo`. The reused components read as well as write — the composer opens the workspace catalog and machine Flock documents — so every tour document must open, read empty, and report its first remote sync as done, and writes on either plane must reject rather than silently succeed. Supply the missing plane; do not fork a repo-free copy of a product component to avoid it. - Provider rows keep the last durable verification result separate from request-scoped activity. Runtime progress must come from the refresh request that owns the config, not the machine-and-agent global snapshot, and late results must not commit after edit, delete, replacement, or unmount. Active AgentConfig tests and durable ProviderSetup rows share the compact progress-button treatment; determinate progress fills the button, and indeterminate work uses its neutral label. Do not add a second activity row. A failed result keeps its latest reason inspectable from the badge until success or a config mutation clears it. A runtime progress status that already reported failure (`error`, `unsupported-platform`, `incompatible-host`) resolves to the `runtime-failed` phase and wears the failure tone, never back to `checking-runtime`: the final response still owns the durable reason, but in-flight activity must not present a known failure as work still in progress. +- An indeterminate wait shows elapsed seconds only after a threshold, not from zero. A counter is not neutral: it reassures at 3s and applies pressure at 87s, and measuring a wait is what the user needs once it has already begun to look abnormal. An ordinary start reads as its stage name alone. - The first-task primary action never strands onboarding behind run prerequisites or Session persistence. It requests product navigation immediately; when that navigation succeeds and a runnable prompt exists, Session creation and dispatch continue only as best-effort background work and never navigate or delay entry into the product. Failed navigation creates no Session and remains retryable. An empty task becomes Enter Lody rather than a disabled final action. - Workspace is required product context when the platform exposes multi-workspace onboarding, so it has no Skip or Enter Lody action. A failed workspace-list read keeps the investigation detail and a real platform-backed Retry action. Mutations use bounded waits: after a stale write the UI releases its attempt lock so the user may retry or go back, while late results never navigate or clear a newer attempt. A slow slug availability query is still pending, not failed: explain the slow network, keep Create disabled until the server answers, and let the user restart the check without treating elapsed time as validation. Query errors stay inside the slug field with their detail and Retry. A slug-less existing workspace is repaired inline because its Settings route is unreachable until the slug exists. - Every onboarding error path writes its underlying error or failure detail to `console.error` even when the UI also shows an inline message or toast. Recoverable user actions must become retryable again after failure; never leave an error with only explanatory copy when the same operation can be attempted safely. diff --git a/packages/components/src/components/onboarding/screens/providers-screen.tsx b/packages/components/src/components/onboarding/screens/providers-screen.tsx index dc20ead51..3ba6b798b 100644 --- a/packages/components/src/components/onboarding/screens/providers-screen.tsx +++ b/packages/components/src/components/onboarding/screens/providers-screen.tsx @@ -1302,14 +1302,26 @@ function useElapsedSeconds(startedAtMs: number | null): number { return elapsedSeconds; } +/** + * How long a wait has to run before it is worth putting a number on it. + * + * A counter is not free. "Starting · 3s" reassures; "Starting · 87s" applies + * pressure, and a normal handshake is over long before anyone wants to measure + * it. Measuring is what you need once a wait has already begun to look + * abnormal, so the seconds stay hidden until then and an ordinary start reads + * as plain "Starting". + */ +const ELAPSED_SECONDS_VISIBLE_AFTER_SECONDS = 10; + /** * The in-flight action for a row. * * A download has a denominator, so the button fills and reads as a percentage. * Every other stage — the ACP handshake above all — has none, and inventing one - * would be a lie; it reports elapsed time instead, which is what turns an - * open-ended wait into a wait the user can measure. The badge beside it names - * the stage, so the two together read as "Starting · 14s". + * would be a lie. Past {@link ELAPSED_SECONDS_VISIBLE_AFTER_SECONDS} it reports + * elapsed time instead, which is what turns an open-ended wait into a wait the + * user can measure. The badge beside it names the stage, so the two together + * read as "Starting · 14s". */ function ProviderActivityAction({ activity }: { activity: ProviderTestActivity }) { const { t } = useTranslation(); @@ -1323,7 +1335,7 @@ function ProviderActivityAction({ activity }: { activity: ProviderTestActivity } // Never label an already-failed runtime as ongoing work while the durable // reason is still in flight. if (runtimeFailed) return t('onboarding.providers.failedAction', 'Failed'); - if (elapsedSeconds > 0) { + if (elapsedSeconds >= ELAPSED_SECONDS_VISIBLE_AFTER_SECONDS) { return t('onboarding.providers.workingSeconds', '{{seconds}}s', { seconds: elapsedSeconds, }); diff --git a/packages/shared/src/acp-startup-budget.ts b/packages/shared/src/acp-startup-budget.ts index ec7166d0e..12cbb37ab 100644 --- a/packages/shared/src/acp-startup-budget.ts +++ b/packages/shared/src/acp-startup-budget.ts @@ -26,16 +26,47 @@ export const ACP_COLD_NPX_INIT_TIMEOUT_MS = 300_000; export const ACP_NEW_SESSION_TIMEOUT_MS = 120_000; /** - * Worst-case machine time for a capability refresh: the slowest `initialize` + * Attempts `runNpxStartupWithRecovery` may spend on one startup request. + * + * A cold `initialize` timeout, npm cache corruption, stale package metadata, or + * a broken install each purge and retry, and every retry starts a fresh + * process that gets the full per-attempt timeouts again. + */ +export const ACP_NPX_STARTUP_MAX_ATTEMPTS = 3; + +/** + * Worst-case machine time for ONE startup attempt: the slowest `initialize` * followed by `session/new`. * * Runtime download is deliberately excluded. It streams progress frames, so an * inactivity-based transport timeout is continuously reset while it runs and a * slow download cannot expire the request. */ -export const ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS = +export const ACP_STARTUP_ATTEMPT_MACHINE_BUDGET_MS = ACP_COLD_NPX_INIT_TIMEOUT_MS + ACP_NEW_SESSION_TIMEOUT_MS; +/** + * Terminating the failed child (a 3s SIGTERM grace) and purging the npx/npm + * cache directories before the next attempt. Not covered by either ACP timeout, + * and the retry loop emits no progress frame across it, so it is silence the + * client budget has to absorb like any other. + */ +const ACP_STARTUP_ATTEMPT_CLEANUP_MS = 10_000; + +/** + * Worst-case machine time for a capability refresh: every attempt the recovery + * policy is allowed to make, plus the cleanup between them. + * + * Budgeting a single attempt is what makes a client backstop lie. The machine + * treats a cold `initialize` timeout as a reason to purge and try again, so a + * one-attempt budget expires while the machine is still executing its intended + * recovery — and the user is told "timed out" instead of the reason the machine + * would have reported on attempt three. + */ +export const ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS = + ACP_NPX_STARTUP_MAX_ATTEMPTS * ACP_STARTUP_ATTEMPT_MACHINE_BUDGET_MS + + (ACP_NPX_STARTUP_MAX_ATTEMPTS - 1) * ACP_STARTUP_ATTEMPT_CLEANUP_MS; + /** Headroom for process spawn, teardown, and transport overhead. */ const CLIENT_BACKSTOP_MARGIN_MS = 30_000; diff --git a/packages/shared/tests/acp-startup-budget.test.ts b/packages/shared/tests/acp-startup-budget.test.ts index db8191beb..e746ee841 100644 --- a/packages/shared/tests/acp-startup-budget.test.ts +++ b/packages/shared/tests/acp-startup-budget.test.ts @@ -6,16 +6,30 @@ import { ACP_COLD_NPX_INIT_TIMEOUT_MS, ACP_INIT_TIMEOUT_MS, ACP_NEW_SESSION_TIMEOUT_MS, + ACP_NPX_STARTUP_MAX_ATTEMPTS, + ACP_STARTUP_ATTEMPT_MACHINE_BUDGET_MS, } from '../src/acp-startup-budget'; describe('acp startup budget', () => { - it('covers the slowest initialize followed by session/new', () => { + it('covers the slowest initialize followed by session/new in one attempt', () => { expect(ACP_COLD_NPX_INIT_TIMEOUT_MS).toBeGreaterThanOrEqual(ACP_INIT_TIMEOUT_MS); - expect(ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS).toBe( + expect(ACP_STARTUP_ATTEMPT_MACHINE_BUDGET_MS).toBe( ACP_COLD_NPX_INIT_TIMEOUT_MS + ACP_NEW_SESSION_TIMEOUT_MS ); }); + it('covers every attempt the npx recovery policy may make, not just the first', () => { + // A cold `initialize` timeout is a reason for the machine to purge and try + // again, and each retry gets the full per-attempt timeouts. Budgeting one + // attempt lets the client expire during attempt two or three — while the + // machine is still executing the recovery it intended — and report a + // client timeout instead of the machine's own final reason. + expect(ACP_NPX_STARTUP_MAX_ATTEMPTS).toBeGreaterThan(1); + expect(ACP_CAPABILITIES_REFRESH_MACHINE_BUDGET_MS).toBeGreaterThan( + ACP_NPX_STARTUP_MAX_ATTEMPTS * ACP_STARTUP_ATTEMPT_MACHINE_BUDGET_MS + ); + }); + it('keeps the client backstop strictly above the machine budget', () => { // A client deadline at or below the machine budget expires requests the // machine is still working on, and reports a transport timeout instead of From dd95c7b2281386c236464cf1ceb0f2f4de27a775 Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:27:52 +0000 Subject: [PATCH 5/6] feat(onboarding): escalate a slow wait instead of holding its stage name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rising counter under an unchanged stage name is the interface insisting everything is normal while the user watches evidence that it is not. Give the wait a second tier: past the exceptional threshold the row stops calling itself "Starting" and says the wait left the usual range, in the amber this screen already uses for "needs attention, nothing failed". No progress is invented — what changes is what the UI is willing to claim. Both tiers now come from one binding so the badge's tone and the action's number cannot disagree. Leaving the provider step no longer aborts its in-flight probes. The machine drops a capability refresh once its last consumer leaves, so unmount-abort made "continue" also mean "give up on this agent" — at exactly the moment a slow first run makes moving on most attractive. Blocking a stale commit and cancelling the work are now separate: `invalidate` still aborts an obsolete probe, `detachAll` only stops the screen committing a result it can no longer show. That is what makes the escalation copy's promise of background work true. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- locales/en.json | 3 + locales/zh_CN.json | 3 + .../src/components/onboarding/AGENTS.md | 5 +- .../src/components/onboarding/index.ts | 4 + .../onboarding/provider-test-state.ts | 43 +++++++++- .../onboarding/screens/providers-screen.tsx | 82 ++++++++++++++----- .../OnboardingProvidersScreen.stories.tsx | 28 +++++++ .../tests/provider-test-state.test.ts | 45 ++++++++++ 8 files changed, 189 insertions(+), 24 deletions(-) diff --git a/locales/en.json b/locales/en.json index 770f13672..05e88e562 100644 --- a/locales/en.json +++ b/locales/en.json @@ -992,6 +992,7 @@ "onboarding.projects.waitingLocalAgent": "Waiting for the local agent to connect…", "onboarding.projects.title": "Pick a project to start with", "onboarding.providers.activityRuntimeFailed": "Runtime failed", + "onboarding.providers.activitySlow": "Taking longer", "onboarding.providers.addAnother": "Add another Agent", "onboarding.providers.addFirst": "Add your first Agent", "onboarding.providers.activityChecking": "Checking", @@ -1012,6 +1013,8 @@ "onboarding.providers.showLess": "Show less", "onboarding.providers.showMore": "+{{count}} more", "onboarding.providers.skip": "Skip for now", + "onboarding.providers.slowWaitDetail": "{{stage}} is still running. A first run may have to download and unpack the agent. You can continue — Lody keeps working on this in the background.", + "onboarding.providers.slowWaitTitle": "This is taking longer than usual", "onboarding.providers.statusFailed": "Failed", "onboarding.providers.statusNeedsAuth": "Sign in", "onboarding.providers.statusPassed": "Verified", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index a2433c1ad..756ac77b1 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -992,6 +992,7 @@ "onboarding.projects.waitingLocalAgent": "正在等待本机 Agent 连接…", "onboarding.projects.title": "选择一个项目开始", "onboarding.providers.activityRuntimeFailed": "运行时失败", + "onboarding.providers.activitySlow": "耗时偏长", "onboarding.providers.addAnother": "再添加一个 Agent", "onboarding.providers.addFirst": "添加第一个 Agent", "onboarding.providers.activityChecking": "检查中", @@ -1012,6 +1013,8 @@ "onboarding.providers.showLess": "收起", "onboarding.providers.showMore": "更多 +{{count}}", "onboarding.providers.skip": "稍后再配置", + "onboarding.providers.slowWaitDetail": "{{stage}} 仍在进行。首次运行可能需要下载并解包 Agent。你可以继续下一步,Lody 会在后台完成。", + "onboarding.providers.slowWaitTitle": "这次等待比平时更久", "onboarding.providers.statusFailed": "失败", "onboarding.providers.statusNeedsAuth": "需要登录", "onboarding.providers.statusPassed": "已验证", diff --git a/packages/components/src/components/onboarding/AGENTS.md b/packages/components/src/components/onboarding/AGENTS.md index 071c92db3..7b247f344 100644 --- a/packages/components/src/components/onboarding/AGENTS.md +++ b/packages/components/src/components/onboarding/AGENTS.md @@ -12,8 +12,9 @@ - Setup screens use the real `TourStill` product composition. Its Browser beat includes the production Visual Annotation surfaces; do not replace the tour with a hand-built mock. - `TourApp` reuses production components against fixture state, so it must remain inside `TourCloudBoundary`. The boundary owns fixture identity, workspace, authentication, and cloud operations; no tour child may observe or call the outer app's cloud adapter. - The tour's runtime is a stand-in on BOTH planes: `TourCloudBoundary` for cloud operations and `createTourRepo` for `runtime.repo`. The reused components read as well as write — the composer opens the workspace catalog and machine Flock documents — so every tour document must open, read empty, and report its first remote sync as done, and writes on either plane must reject rather than silently succeed. Supply the missing plane; do not fork a repo-free copy of a product component to avoid it. -- Provider rows keep the last durable verification result separate from request-scoped activity. Runtime progress must come from the refresh request that owns the config, not the machine-and-agent global snapshot, and late results must not commit after edit, delete, replacement, or unmount. Active AgentConfig tests and durable ProviderSetup rows share the compact progress-button treatment; determinate progress fills the button, and indeterminate work uses its neutral label. Do not add a second activity row. A failed result keeps its latest reason inspectable from the badge until success or a config mutation clears it. A runtime progress status that already reported failure (`error`, `unsupported-platform`, `incompatible-host`) resolves to the `runtime-failed` phase and wears the failure tone, never back to `checking-runtime`: the final response still owns the durable reason, but in-flight activity must not present a known failure as work still in progress. -- An indeterminate wait shows elapsed seconds only after a threshold, not from zero. A counter is not neutral: it reassures at 3s and applies pressure at 87s, and measuring a wait is what the user needs once it has already begun to look abnormal. An ordinary start reads as its stage name alone. +- Provider rows keep the last durable verification result separate from request-scoped activity. Runtime progress must come from the refresh request that owns the config, not the machine-and-agent global snapshot. Active AgentConfig tests and durable ProviderSetup rows share the compact progress-button treatment; determinate progress fills the button. Do not add a second activity row. A failed result keeps its latest reason inspectable from the badge until success or a config mutation clears it. A runtime progress status that already reported failure (`error`, `unsupported-platform`, `incompatible-host`) resolves to the `runtime-failed` phase and wears the failure tone, never back to `checking-runtime`: the final response still owns the durable reason, but in-flight activity must not present a known failure as work still in progress. +- An indeterminate wait escalates through one binding, `providerWaitEscalation`, so tone and number agree: `normal` names the stage alone (3s reassures, 87s pressures), `measured` adds seconds, `exceptional` stops calling it ordinary. It changes what the UI CLAIMS, never inventing a denominator; downloads never escalate. +- Leaving the step detaches in-flight probes, never aborts: the machine drops a refresh once its last consumer leaves, so unmount-abort made "continue" mean "give up on this agent". A late result must never commit after edit, delete, replace, or unmount, but only the first three cancel: `invalidate` aborts, `detachAll` only blocks the commit — which is what lets copy promise background work. - The first-task primary action never strands onboarding behind run prerequisites or Session persistence. It requests product navigation immediately; when that navigation succeeds and a runnable prompt exists, Session creation and dispatch continue only as best-effort background work and never navigate or delay entry into the product. Failed navigation creates no Session and remains retryable. An empty task becomes Enter Lody rather than a disabled final action. - Workspace is required product context when the platform exposes multi-workspace onboarding, so it has no Skip or Enter Lody action. A failed workspace-list read keeps the investigation detail and a real platform-backed Retry action. Mutations use bounded waits: after a stale write the UI releases its attempt lock so the user may retry or go back, while late results never navigate or clear a newer attempt. A slow slug availability query is still pending, not failed: explain the slow network, keep Create disabled until the server answers, and let the user restart the check without treating elapsed time as validation. Query errors stay inside the slug field with their detail and Retry. A slug-less existing workspace is repaired inline because its Settings route is unreachable until the slug exists. - Every onboarding error path writes its underlying error or failure detail to `console.error` even when the UI also shows an inline message or toast. Recoverable user actions must become retryable again after failure; never leave an error with only explanatory copy when the same operation can be attempted safely. diff --git a/packages/components/src/components/onboarding/index.ts b/packages/components/src/components/onboarding/index.ts index e0a327a1b..c653bf078 100644 --- a/packages/components/src/components/onboarding/index.ts +++ b/packages/components/src/components/onboarding/index.ts @@ -18,6 +18,10 @@ export { } from './screens/providers-screen'; export type { DesktopOnboardingProviderSelection } from '@/atoms/onboarding'; export type { ProviderTestActivity } from './provider-test-state'; +export { + PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS, + PROVIDER_WAIT_MEASURED_AFTER_SECONDS, +} from './provider-test-state'; export { ProjectsScreen, ProjectsScreenView, diff --git a/packages/components/src/components/onboarding/provider-test-state.ts b/packages/components/src/components/onboarding/provider-test-state.ts index c45a5e7bf..3f4be6de4 100644 --- a/packages/components/src/components/onboarding/provider-test-state.ts +++ b/packages/components/src/components/onboarding/provider-test-state.ts @@ -126,6 +126,32 @@ export function agentRuntimeReadinessFromActivity( return { readiness: 'arriving', percent: null }; } +/** + * How a wait presents once it stops being routine. + * + * - `normal` — name the stage and nothing else. A counter under a few seconds + * only teaches the user to watch a number that was never going to matter. + * - `measured` — show elapsed time. The wait has run long enough that the user + * wants it bounded, and a number they can watch is what bounds it. + * - `exceptional` — say so. Past here a stage name over a rising number reads + * as the UI insisting everything is fine; the honest move is to admit the + * wait has left the normal range. Not by inventing progress — by changing + * what the interface is willing to claim. + */ +export type ProviderWaitEscalation = 'normal' | 'measured' | 'exceptional'; + +/** Elapsed seconds at which a wait starts showing its number. */ +export const PROVIDER_WAIT_MEASURED_AFTER_SECONDS = 10; + +/** Elapsed seconds at which a wait stops presenting itself as ordinary. */ +export const PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS = 60; + +export function providerWaitEscalation(elapsedSeconds: number): ProviderWaitEscalation { + if (elapsedSeconds >= PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS) return 'exceptional'; + if (elapsedSeconds >= PROVIDER_WAIT_MEASURED_AFTER_SECONDS) return 'measured'; + return 'normal'; +} + export type ProviderTestRun = { id: number; signal: AbortSignal; @@ -136,6 +162,11 @@ export type ProviderTestRunRegistry = ReturnType () => { - testRunsRef.current.invalidateAll(); + testRunsRef.current.detachAll(); }, [] ); @@ -1303,39 +1309,44 @@ function useElapsedSeconds(startedAtMs: number | null): number { } /** - * How long a wait has to run before it is worth putting a number on it. + * The escalation tier a row's wait has reached, and the seconds behind it. * - * A counter is not free. "Starting · 3s" reassures; "Starting · 87s" applies - * pressure, and a normal handshake is over long before anyone wants to measure - * it. Measuring is what you need once a wait has already begun to look - * abnormal, so the seconds stay hidden until then and an ordinary start reads - * as plain "Starting". + * The badge and the action both read this, so the tone the row takes and the + * number it shows can never disagree about which tier the wait is in. Only a + * denominator-free stage escalates: a download already answers "how much + * longer" with a percentage, and a runtime that has failed is not waiting. */ -const ELAPSED_SECONDS_VISIBLE_AFTER_SECONDS = 10; +function useProviderWaitEscalation(activity: ProviderTestActivity | undefined): { + escalation: ProviderWaitEscalation; + elapsedSeconds: number; +} { + const percent = getProviderTestActivityPercent(activity); + const measurable = activity !== undefined && percent === null && activity.phase !== 'runtime-failed'; + const elapsedSeconds = useElapsedSeconds(measurable ? (activity.startedAtMs ?? null) : null); + return { escalation: providerWaitEscalation(elapsedSeconds), elapsedSeconds }; +} /** * The in-flight action for a row. * * A download has a denominator, so the button fills and reads as a percentage. * Every other stage — the ACP handshake above all — has none, and inventing one - * would be a lie. Past {@link ELAPSED_SECONDS_VISIBLE_AFTER_SECONDS} it reports - * elapsed time instead, which is what turns an open-ended wait into a wait the - * user can measure. The badge beside it names the stage, so the two together - * read as "Starting · 14s". + * would be a lie. Once the wait is `measured` it reports elapsed time instead, + * which is what turns an open-ended wait into a wait the user can measure. The + * badge beside it names the stage, so the two together read as "Starting · 14s" + * — and, once the wait is `exceptional`, as "Taking longer · 74s". */ function ProviderActivityAction({ activity }: { activity: ProviderTestActivity }) { const { t } = useTranslation(); const percent = getProviderTestActivityPercent(activity); const runtimeFailed = activity.phase === 'runtime-failed'; - const elapsedSeconds = useElapsedSeconds( - percent === null && !runtimeFailed ? (activity.startedAtMs ?? null) : null - ); + const { escalation, elapsedSeconds } = useProviderWaitEscalation(activity); const label = (() => { if (percent !== null) return `${percent}%`; // Never label an already-failed runtime as ongoing work while the durable // reason is still in flight. if (runtimeFailed) return t('onboarding.providers.failedAction', 'Failed'); - if (elapsedSeconds >= ELAPSED_SECONDS_VISIBLE_AFTER_SECONDS) { + if (escalation !== 'normal') { return t('onboarding.providers.workingSeconds', '{{seconds}}s', { seconds: elapsedSeconds, }); @@ -1362,8 +1373,9 @@ function ProviderStatusBadge({ failureReason?: string; }) { const { t } = useTranslation(); + const { escalation } = useProviderWaitEscalation(activity); if (activity) { - const label = (() => { + const stageLabel = (() => { switch (activity.phase) { case 'checking-runtime': return t('onboarding.providers.activityChecking', 'Checking'); @@ -1387,19 +1399,49 @@ function ProviderStatusBadge({ // A runtime that already reported a failure must not keep wearing the // in-progress tone; the final response still owns the durable reason. const runtimeFailed = activity.phase === 'runtime-failed'; - return ( + // Second escalation. The row stops naming the stage as if this were a + // normal run and says what is actually true — the wait left the usual + // range — in the amber this screen already uses for "needs your + // attention, but nothing has failed". No new progress is invented, and + // the elapsed counter beside it keeps the wait measurable. + const exceptional = !runtimeFailed && escalation === 'exceptional'; + const slowDetail = t( + 'onboarding.providers.slowWaitDetail', + '{{stage}} is still running. A first run may have to download and unpack the agent. You can continue — Lody keeps working on this in the background.', + { stage: stageLabel } + ); + const badge = ( - {label} + {exceptional ? t('onboarding.providers.activitySlow', 'Taking longer') : stageLabel} ); + if (!exceptional) return badge; + return ( + + + {badge} + +
    + {t('onboarding.providers.slowWaitTitle', 'This is taking longer than usual')} +
    +
    {slowDetail}
    +
    +
    +
    + ); } if (status === 'passed') { return ( diff --git a/packages/components/src/stories/OnboardingProvidersScreen.stories.tsx b/packages/components/src/stories/OnboardingProvidersScreen.stories.tsx index 7bd288685..becc9e74b 100644 --- a/packages/components/src/stories/OnboardingProvidersScreen.stories.tsx +++ b/packages/components/src/stories/OnboardingProvidersScreen.stories.tsx @@ -4,6 +4,8 @@ import { fn } from 'storybook/test'; import type { AgentConfigId, AgentConfigMeta, MachineId } from '@lody/shared'; import { OnboardingBackdrop, + PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS, + PROVIDER_WAIT_MEASURED_AFTER_SECONDS, ProvidersScreenView, type ProviderTestActivity, type ProviderTestStatus, @@ -226,6 +228,32 @@ export const DownloadingRuntime: Story = { }, }; +/** + * The three escalation tiers side by side. A wait that just started names its + * stage; one past the measured threshold adds the seconds it has taken; one + * past the exceptional threshold stops calling itself ordinary and says so, + * without inventing any progress it does not have. + */ +export const WaitEscalation: Story = { + args: { + configs: [claudeConfig, codexConfig, kimiConfig], + testStatuses: {}, + testActivities: { + [claudeConfig.id]: { phase: 'probing-provider', startedAtMs: Date.now() }, + [codexConfig.id]: { + phase: 'probing-provider', + startedAtMs: Date.now() - PROVIDER_WAIT_MEASURED_AFTER_SECONDS * 1000, + }, + [kimiConfig.id]: { + phase: 'probing-provider', + startedAtMs: Date.now() - PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS * 1000, + }, + }, + selectedProviderId: kimiConfig.id, + noLocalMachine: false, + }, +}; + export const RecheckingVerified: Story = { args: { configs: [claudeConfig, codexConfig], diff --git a/packages/components/tests/provider-test-state.test.ts b/packages/components/tests/provider-test-state.test.ts index cd0131376..f8cab94c3 100644 --- a/packages/components/tests/provider-test-state.test.ts +++ b/packages/components/tests/provider-test-state.test.ts @@ -6,6 +6,9 @@ import { agentRuntimeReadinessFromProgress, createProviderTestRunRegistry, providerTestActivityFromProgress, + providerWaitEscalation, + PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS, + PROVIDER_WAIT_MEASURED_AFTER_SECONDS, } from '../src/components/onboarding/provider-test-state'; const configId = 'provider-1' as AgentConfigId; @@ -75,6 +78,21 @@ describe('createProviderTestRunRegistry', () => { expect(registry.finish(configId, completed)).toBe(true); expect(registry.finish(configId, completed)).toBe(false); }); + + it('detaches without aborting, so leaving the step does not cancel the work', () => { + // Leaving the provider step must stop the screen committing a result it can + // no longer show, and nothing more. The machine drops a refresh once its + // last consumer leaves, so aborting here would make "continue" quietly mean + // "abandon this agent" — the opposite of what the UI offers at that moment. + const registry = createProviderTestRunRegistry(); + const detached = registry.start(configId); + + registry.detachAll(); + + expect(detached.signal.aborted).toBe(false); + expect(registry.isCurrent(configId, detached)).toBe(false); + expect(registry.finish(configId, detached)).toBe(false); + }); }); describe('agentRuntimeReadinessFromProgress', () => { @@ -140,3 +158,30 @@ describe('agentRuntimeReadinessFromActivity', () => { }); }); }); + +describe('providerWaitEscalation', () => { + it('says only the stage name while a wait is still ordinary', () => { + expect(providerWaitEscalation(0)).toBe('normal'); + expect(providerWaitEscalation(PROVIDER_WAIT_MEASURED_AFTER_SECONDS - 1)).toBe('normal'); + }); + + it('starts measuring once the wait is long enough to want bounded', () => { + expect(providerWaitEscalation(PROVIDER_WAIT_MEASURED_AFTER_SECONDS)).toBe('measured'); + expect(providerWaitEscalation(PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS - 1)).toBe('measured'); + }); + + it('admits the wait left the normal range instead of holding the stage name', () => { + // The second escalation is the whole point of having tiers: past here a + // stage name over a rising number is the interface insisting nothing is + // wrong, so the tone has to change rather than the number growing alone. + expect(providerWaitEscalation(PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS)).toBe('exceptional'); + expect(providerWaitEscalation(600)).toBe('exceptional'); + }); + + it('escalates in order, so a tier can never be skipped or reversed', () => { + expect(PROVIDER_WAIT_MEASURED_AFTER_SECONDS).toBeGreaterThan(0); + expect(PROVIDER_WAIT_EXCEPTIONAL_AFTER_SECONDS).toBeGreaterThan( + PROVIDER_WAIT_MEASURED_AFTER_SECONDS + ); + }); +}); From 245309b2f8738098453604f5ccba49abf2ce83f6 Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:53:04 +0000 Subject: [PATCH 6/6] fix(onboarding): keep the slow-wait copy request-scoped and pasteable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The elapsed counter measures the whole setup request on purpose, but the exceptional copy described the current stage. A runtime download that ran 65s and then handed off to the ACP handshake rendered "Taking longer · 66s" beside "Starting is still running" — and Starting had run one second. Move the copy to the model the timer already uses: the sentence speaks about agent setup, and the stage stops being a user-facing claim. No stageStartedAtMs, because the question being asked is how long the agent has taken, not how long a handshake took. The stage is still the most useful thing we could know, so it goes where it is diagnostic rather than rhetorical: the exceptional tier now offers a pasteable report carrying agent, stage id, setup elapsed, and build. A user who has watched "Taking longer" for two minutes can say that much themselves; what they cannot say is which stage, how long, and which build. Deliberately unlocalized and keyed by internal phase ids — whoever answers in chat reads it, and a translated stage name is one we cannot grep for. It is also the tier's only pointer-independent affordance, since the tooltip beside it is hover-only. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- locales/en.json | 5 +- locales/zh_CN.json | 5 +- .../src/components/onboarding/AGENTS.md | 8 +- .../onboarding/provider-wait-report.ts | 73 ++++++++++++++++ .../onboarding/screens/providers-screen.tsx | 82 ++++++++++++++++-- .../OnboardingProvidersScreen.stories.tsx | 7 +- .../tests/provider-wait-report.test.ts | 86 +++++++++++++++++++ 7 files changed, 252 insertions(+), 14 deletions(-) create mode 100644 packages/components/src/components/onboarding/provider-wait-report.ts create mode 100644 packages/components/tests/provider-wait-report.test.ts diff --git a/locales/en.json b/locales/en.json index 05e88e562..11f18ca74 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1013,7 +1013,10 @@ "onboarding.providers.showLess": "Show less", "onboarding.providers.showMore": "+{{count}} more", "onboarding.providers.skip": "Skip for now", - "onboarding.providers.slowWaitDetail": "{{stage}} is still running. A first run may have to download and unpack the agent. You can continue — Lody keeps working on this in the background.", + "onboarding.providers.slowWaitDetail": "Agent setup is still running. A first run may have to download and unpack the agent. You can continue — Lody keeps working on this in the background.", + "onboarding.providers.slowWaitCopied": "Setup details copied", + "onboarding.providers.slowWaitCopy": "Copy setup details", + "onboarding.providers.slowWaitCopyFailed": "Could not copy setup details", "onboarding.providers.slowWaitTitle": "This is taking longer than usual", "onboarding.providers.statusFailed": "Failed", "onboarding.providers.statusNeedsAuth": "Sign in", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 756ac77b1..45964e0d1 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1013,7 +1013,10 @@ "onboarding.providers.showLess": "收起", "onboarding.providers.showMore": "更多 +{{count}}", "onboarding.providers.skip": "稍后再配置", - "onboarding.providers.slowWaitDetail": "{{stage}} 仍在进行。首次运行可能需要下载并解包 Agent。你可以继续下一步,Lody 会在后台完成。", + "onboarding.providers.slowWaitDetail": "Agent 配置仍在进行。首次运行可能需要下载并解包 Agent。你可以继续下一步,Lody 会在后台完成。", + "onboarding.providers.slowWaitCopied": "已复制配置详情", + "onboarding.providers.slowWaitCopy": "复制配置详情", + "onboarding.providers.slowWaitCopyFailed": "复制配置详情失败", "onboarding.providers.slowWaitTitle": "这次等待比平时更久", "onboarding.providers.statusFailed": "失败", "onboarding.providers.statusNeedsAuth": "需要登录", diff --git a/packages/components/src/components/onboarding/AGENTS.md b/packages/components/src/components/onboarding/AGENTS.md index 7b247f344..766497ba9 100644 --- a/packages/components/src/components/onboarding/AGENTS.md +++ b/packages/components/src/components/onboarding/AGENTS.md @@ -7,14 +7,14 @@ - User-facing onboarding copy calls the execution choice an Agent. Reserve Provider for Settings configuration management and internal `AgentConfig`/`ProviderSetup` state. - Completion stays in the existing router and navigates to the created session when one exists. Reload recovery must target the normal product root after completion. - Desktop onboarding owns the app theme for its whole route lifetime: enter and reload in `light`, and restore the persisted source to `system` only after completion succeeds or the route unmounts. -- Managed built-in runtimes prefetch CONCURRENTLY, starting when onboarding mounts and running through the intro ceremony. They are independent artifacts keyed by agent type and installs are already deduped per machine and agent, so serial prefetch only made the felt wait the sum of every download instead of the longest one. A selected provider moves to the front of the launch order; it never restarts or waits behind work already in flight. This stays off the renderer's critical path because `handleMachineAcpBinaryProgress` fans out only to live listeners and otherwise just records a snapshot: background prefetch must not subscribe to per-percent progress, so a concurrent download cannot turn into a render storm over the ceremony animation. +- Managed built-in runtimes prefetch CONCURRENTLY, starting when onboarding mounts and running through the intro ceremony. They are independent artifacts keyed by agent type and installs are deduped per machine and agent, so serial prefetch only made the felt wait the sum of every download rather than the longest. A selected provider moves to the front of the launch order; it never restarts or waits behind work already in flight. This stays off the renderer's critical path because `handleMachineAcpBinaryProgress` fans out only to live listeners and otherwise records a snapshot: background prefetch must not subscribe to per-percent progress, or a concurrent download becomes a render storm over the ceremony animation. - `ceremony/intro-sequence.tsx` owns the four-beat illustrated intro. Keep its approved assets and direction in `intro-illustration-direction.md`; setup screens must not replace it with a generic welcome card. - Setup screens use the real `TourStill` product composition. Its Browser beat includes the production Visual Annotation surfaces; do not replace the tour with a hand-built mock. - `TourApp` reuses production components against fixture state, so it must remain inside `TourCloudBoundary`. The boundary owns fixture identity, workspace, authentication, and cloud operations; no tour child may observe or call the outer app's cloud adapter. - The tour's runtime is a stand-in on BOTH planes: `TourCloudBoundary` for cloud operations and `createTourRepo` for `runtime.repo`. The reused components read as well as write — the composer opens the workspace catalog and machine Flock documents — so every tour document must open, read empty, and report its first remote sync as done, and writes on either plane must reject rather than silently succeed. Supply the missing plane; do not fork a repo-free copy of a product component to avoid it. -- Provider rows keep the last durable verification result separate from request-scoped activity. Runtime progress must come from the refresh request that owns the config, not the machine-and-agent global snapshot. Active AgentConfig tests and durable ProviderSetup rows share the compact progress-button treatment; determinate progress fills the button. Do not add a second activity row. A failed result keeps its latest reason inspectable from the badge until success or a config mutation clears it. A runtime progress status that already reported failure (`error`, `unsupported-platform`, `incompatible-host`) resolves to the `runtime-failed` phase and wears the failure tone, never back to `checking-runtime`: the final response still owns the durable reason, but in-flight activity must not present a known failure as work still in progress. -- An indeterminate wait escalates through one binding, `providerWaitEscalation`, so tone and number agree: `normal` names the stage alone (3s reassures, 87s pressures), `measured` adds seconds, `exceptional` stops calling it ordinary. It changes what the UI CLAIMS, never inventing a denominator; downloads never escalate. -- Leaving the step detaches in-flight probes, never aborts: the machine drops a refresh once its last consumer leaves, so unmount-abort made "continue" mean "give up on this agent". A late result must never commit after edit, delete, replace, or unmount, but only the first three cancel: `invalidate` aborts, `detachAll` only blocks the commit — which is what lets copy promise background work. +- Provider rows keep the last durable result separate from request-scoped activity, and runtime progress must come from the refresh request that owns the config, not the machine-and-agent global snapshot. AgentConfig tests and ProviderSetup rows share the compact progress-button treatment; determinate progress fills it. Do not add a second activity row. A failed result keeps its reason inspectable from the badge until success or a config mutation clears it. A status that already failed (`error`, `unsupported-platform`, `incompatible-host`) resolves to `runtime-failed` and wears the failure tone, never back to `checking-runtime`: the response owns the durable reason, but in-flight activity must not present a known failure as work in progress. +- Wait escalation is one binding, `providerWaitEscalation`: `normal` names the stage, `measured` adds seconds, `exceptional` stops calling it ordinary. It changes what the UI CLAIMS, never inventing a denominator. The clock is REQUEST-scoped, so exceptional copy speaks of the whole setup, never the current stage — that would pin the number on work a second old. `buildProviderWaitReport` is that tier's pasteable block: unlocalized, phase ids, pointer-independent. +- Leaving the step detaches probes, never aborts: the machine drops a refresh once its last consumer leaves, so unmount-abort made "continue" mean "give up on this agent". No late result may commit after edit, delete, replace, or unmount, but only the first three cancel (`invalidate`); `detachAll` only blocks the commit — which is what lets copy promise background work. - The first-task primary action never strands onboarding behind run prerequisites or Session persistence. It requests product navigation immediately; when that navigation succeeds and a runnable prompt exists, Session creation and dispatch continue only as best-effort background work and never navigate or delay entry into the product. Failed navigation creates no Session and remains retryable. An empty task becomes Enter Lody rather than a disabled final action. - Workspace is required product context when the platform exposes multi-workspace onboarding, so it has no Skip or Enter Lody action. A failed workspace-list read keeps the investigation detail and a real platform-backed Retry action. Mutations use bounded waits: after a stale write the UI releases its attempt lock so the user may retry or go back, while late results never navigate or clear a newer attempt. A slow slug availability query is still pending, not failed: explain the slow network, keep Create disabled until the server answers, and let the user restart the check without treating elapsed time as validation. Query errors stay inside the slug field with their detail and Retry. A slug-less existing workspace is repaired inline because its Settings route is unreachable until the slug exists. - Every onboarding error path writes its underlying error or failure detail to `console.error` even when the UI also shows an inline message or toast. Recoverable user actions must become retryable again after failure; never leave an error with only explanatory copy when the same operation can be attempted safely. diff --git a/packages/components/src/components/onboarding/provider-wait-report.ts b/packages/components/src/components/onboarding/provider-wait-report.ts new file mode 100644 index 000000000..8a3452391 --- /dev/null +++ b/packages/components/src/components/onboarding/provider-wait-report.ts @@ -0,0 +1,73 @@ +import type { AgentConfigCliType } from '@lody/shared'; + +import type { ErrorBoundaryReportEnvironment } from '@/lib/error-boundary-report'; +import type { ProviderTestActivityPhase } from './provider-test-state'; + +/** + * The pasteable account of a setup that has run long enough to be worth asking + * about. + * + * A user whose agent has been "Taking longer" for two minutes can tell us that + * much in their own words; what they cannot tell us is which stage it stalled + * in, which build they are on, or how long it actually ran. So the escalation + * hands them one block to paste instead of a conversation where we ask for + * those three things one at a time. + * + * Deliberately NOT localized. The user's language is theirs; this text is read + * by whoever answers in the chat, and a translated stage name is a stage name + * we cannot grep for. The button that produces it is localized. + * + * Stages travel as their internal phase ids for the same reason — `Stage: + * probing-provider` names the ACP handshake exactly, where "Starting" is a word + * chosen for a badge. + */ +export type ProviderWaitReportInput = { + agentName: string; + cliType: AgentConfigCliType; + agentType: string; + phase: ProviderTestActivityPhase; + /** Elapsed for the whole setup request, which is what the UI counts. */ + elapsedSeconds: number; + /** Only ever present while downloading, the one stage with a denominator. */ + percent?: number | null; + environment?: ErrorBoundaryReportEnvironment; +}; + +const REPORT_TITLE = 'Lody: agent setup is taking longer than usual'; + +function formatOnline(online: boolean | null | undefined): string | undefined { + if (online === true) return 'yes'; + if (online === false) return 'no'; + return undefined; +} + +export function buildProviderWaitReport(input: ProviderWaitReportInput): string { + const environment = input.environment ?? {}; + const fields: Array<[string, string | undefined]> = [ + ['Agent', `${input.agentName} (${input.cliType}/${input.agentType})`], + ['Stage', input.phase], + // The counter is request-scoped, so the report says so rather than letting + // the number read as time spent in the stage above it. + ['Setup elapsed', `${Math.max(0, Math.floor(input.elapsedSeconds))}s`], + [ + 'Download', + typeof input.percent === 'number' && Number.isFinite(input.percent) + ? `${Math.min(100, Math.max(0, Math.round(input.percent)))}%` + : undefined, + ], + ['Runtime', environment.runtime], + ['OS', environment.os], + ['App version', environment.appVersion], + ['Build', environment.build], + ['Language', environment.language], + ['Online', formatOnline(environment.online)], + ['Time', environment.timestamp], + ]; + + const body = fields + .filter((entry): entry is [string, string] => Boolean(entry[1])) + .map(([label, value]) => `${label}: ${value}`) + .join('\n'); + + return `${REPORT_TITLE}\n\n${body}`; +} diff --git a/packages/components/src/components/onboarding/screens/providers-screen.tsx b/packages/components/src/components/onboarding/screens/providers-screen.tsx index 0f6a48a41..cf652bd0e 100644 --- a/packages/components/src/components/onboarding/screens/providers-screen.tsx +++ b/packages/components/src/components/onboarding/screens/providers-screen.tsx @@ -2,7 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAtomValue, useSetAtom } from 'jotai'; import { motion, AnimatePresence } from 'framer-motion'; -import { CheckCircle2, ChevronDown, ChevronUp, Loader2, Plus, Trash2, XCircle } from 'lucide-react'; +import { + CheckCircle2, + ChevronDown, + ChevronUp, + Copy, + Loader2, + Plus, + Trash2, + XCircle, +} from 'lucide-react'; import { REGISTRY_ACP_AGENTS, getBuiltinAgentByAgentType, @@ -74,6 +83,9 @@ import { resolveInitialOnboardingProviderStatus, type OnboardingProviderStatus, } from '../provider-status'; +import { collectErrorBoundaryEnvironment } from '@/lib/error-boundary-report'; +import { writeTextToClipboard } from '@/lib/clipboard'; +import { buildProviderWaitReport } from '../provider-wait-report'; import { agentRuntimeReadinessFromActivity, createProviderTestRunRegistry, @@ -422,7 +434,7 @@ export function ProvidersScreenView({ {t('common.edit', 'Edit')} {activity ? ( - + ) : status !== 'needs-auth' ? ( + ) : null} + + ); } function getProviderTestActivityPercent(activity?: ProviderTestActivity): number | null { @@ -1405,10 +1471,14 @@ function ProviderStatusBadge({ // attention, but nothing has failed". No new progress is invented, and // the elapsed counter beside it keeps the wait measurable. const exceptional = !runtimeFailed && escalation === 'exceptional'; + // Request-scoped, exactly like the counter beside it. The badge no longer + // names a stage here on purpose: the timer measures the whole setup, so a + // sentence about the current stage would attach the elapsed number to work + // that may have started a second ago. The stage still travels — in the + // copyable report, where it is diagnostic data rather than a claim. const slowDetail = t( 'onboarding.providers.slowWaitDetail', - '{{stage}} is still running. A first run may have to download and unpack the agent. You can continue — Lody keeps working on this in the background.', - { stage: stageLabel } + 'Agent setup is still running. A first run may have to download and unpack the agent. You can continue — Lody keeps working on this in the background.' ); const badge = ( { + it('carries the three things the user cannot see: stage, elapsed, and build', () => { + expect(buildProviderWaitReport(base)).toBe( + [ + 'Lody: agent setup is taking longer than usual', + '', + 'Agent: Claude Code (builtin/claude)', + 'Stage: probing-provider', + 'Setup elapsed: 74s', + 'Runtime: electron', + 'OS: darwin', + 'App version: 0.90.0', + 'Build: abc1234', + 'Language: en-US', + 'Online: yes', + 'Time: 2026-09-06T09:00:00.000Z', + ].join('\n') + ); + }); + + it('labels the counter as setup elapsed, not as time in the stage', () => { + // The timer is request-scoped, so a download that just handed off to the + // ACP handshake reports 74s of SETUP against a stage one second old. The + // label is what keeps that from reading as a claim about the stage. + const report = buildProviderWaitReport(base); + expect(report).toContain('Setup elapsed: 74s'); + expect(report).not.toContain('Stage elapsed'); + }); + + it('reports a download percentage only when one exists', () => { + expect( + buildProviderWaitReport({ + ...base, + phase: 'downloading-runtime', + percent: 63.6, + }) + ).toContain('Download: 64%'); + expect(buildProviderWaitReport({ ...base, percent: null })).not.toContain('Download:'); + expect(buildProviderWaitReport({ ...base, percent: Number.NaN })).not.toContain('Download:'); + }); + + it('drops environment fields the host could not supply', () => { + const report = buildProviderWaitReport({ ...base, environment: { runtime: 'web' } }); + expect(report).toContain('Runtime: web'); + expect(report).not.toContain('OS:'); + expect(report).not.toContain('Online:'); + }); + + it('is not localized, so a stage id stays greppable whatever the user reads', () => { + // Whoever answers in the chat reads this, not the user. A translated stage + // name is a stage name we cannot search for. + expect(buildProviderWaitReport({ ...base, phase: 'extracting-runtime' })).toContain( + 'Stage: extracting-runtime' + ); + }); + + it('never reports a negative or fractional elapsed', () => { + expect(buildProviderWaitReport({ ...base, elapsedSeconds: -5 })).toContain( + 'Setup elapsed: 0s' + ); + expect(buildProviderWaitReport({ ...base, elapsedSeconds: 12.9 })).toContain( + 'Setup elapsed: 12s' + ); + }); +});