From 139ef044610c4efef79ed5969101e1dd4955cb17 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Fri, 21 Aug 2026 13:17:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(solver):=20narrate=20the=20switch=20?= =?UTF-8?q?=E2=80=94=20a=20banner=20for=20the=20restart=20#221=20made=20re?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #221 made the solver toggle do a real switch: the extension watcher sees {status:"switching"}, re-preps the session config, and restarts the opencode server. The webview survives that; its SSE stream does not. Nothing narrated the gap, so a deliberate tier change looked like a hang — the reconnect loop retries silently, which reads as an endless "thinking" wave. The old ConnectionBanner used to cover this, but it was unmounted on 2026-08-07 (f696388, a03aa04) after opencode#132's stuck pill, and the component has been dead code since. This does NOT reinstate it: the new banner speaks only for a switch the app itself requested, so a transient blip can never strand it, and whether general drops deserve a warning again stays an open question rather than one this change answers by the back door. - solver-switch.ts: the phase contract as pure helpers (requested → restarting → ready), matching solver-toggle.tsx's decision-helper split so it is testable without a DOM. sawDrop is latched — once the server has gone down, coming back up is the switch completing, not the request still waiting to be picked up. - Two expiry windows, not one. A request that has not taken the server down inside 12s is not going to (no extension host, stale binary, a write that never landed) and is abandoned quietly; a restart in flight gets the full 90s ceiling inherited from the stale #14 wizard's safety valve. Collapsing them into a single timeout would either strand the pill or cut a slow restart off mid-flight. - beginSolverSwitch() only MIRRORS a request, it never causes one. hp still rides the validated credential and piccolo rides POST /amicode/solver-mode — a banner that could initiate a flip would be the duplicate writer ADR 0001 forbids. Progress renders neutral and completion renders as the brand chip (--accent fill, near-black --accent-ink). That is the design system's rule, not a preference: #fff676 is ~1.1:1 on white, so yellow may never be a foreground on light — if it needs to be yellow there, it has to be a filled chip. Not included: the staged multi-step overlay from the stale #14. It polled GET /amicode/solver-mode, which does not exist (only POST shipped), and its hp stages assume an hp flip from a button — which #221 removed by design. Closes #78 follow-up. --- .../components/amicode-defaults-capsule.tsx | 8 +- .../src/components/solver-switch-banner.tsx | 99 +++++++++++++++++++ .../src/components/status-popover-body.tsx | 2 + packages/app/src/design-polish.css | 52 ++++++++++ packages/app/src/pages/layout-new.tsx | 5 + packages/ui/src/amicode/solver-switch.test.ts | 63 ++++++++++++ packages/ui/src/amicode/solver-switch.ts | 60 +++++++++++ .../src/components/amicode-solver-switch.tsx | 10 ++ 8 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/solver-switch-banner.tsx create mode 100644 packages/ui/src/amicode/solver-switch.test.ts create mode 100644 packages/ui/src/amicode/solver-switch.ts create mode 100644 packages/ui/src/components/amicode-solver-switch.tsx diff --git a/packages/app/src/components/amicode-defaults-capsule.tsx b/packages/app/src/components/amicode-defaults-capsule.tsx index a8273e245..d3749f850 100644 --- a/packages/app/src/components/amicode-defaults-capsule.tsx +++ b/packages/app/src/components/amicode-defaults-capsule.tsx @@ -12,6 +12,7 @@ import { solverConnectionDot, type SolverMode, } from "@opencode-ai/ui/amicode-solver-toggle" +import { beginSolverSwitch } from "@/components/solver-switch-banner" import { ConnectionCard, type ConnectionActionView, @@ -112,7 +113,12 @@ export function AmicodeDefaultsCapsule(props: { compute?: AmicodeComputeControl } const submitCredential = async (payload: CredentialSubmitPayload) => { const result = await props.compute!.onSubmit(payload) - if (hpAfterConnect(result)) pick("hp") + if (hpAfterConnect(result)) { + // #167 writes {mode:"hp",status:"switching"} on this same valid outcome, + // so the restart starts here — narrate it (opencode#78 follow-up). + beginSolverSwitch("hp") + pick("hp") + } return result } const disconnectCompute = (id: string) => { diff --git a/packages/app/src/components/solver-switch-banner.tsx b/packages/app/src/components/solver-switch-banner.tsx new file mode 100644 index 000000000..70c15c5d9 --- /dev/null +++ b/packages/app/src/components/solver-switch-banner.tsx @@ -0,0 +1,99 @@ +import { Show, createEffect, createSignal, onCleanup } from "solid-js" +import { + solverSwitchExpired, + solverSwitchLabel, + solverSwitchPhase, + type SolverSwitchPhase, +} from "@opencode-ai/ui/amicode-solver-switch" +import type { SolverMode } from "@opencode-ai/ui/amicode-solver-toggle" +import { useServerSDK } from "@/context/server-sdk" + +// Amicode: the visible half of a solver switch (opencode#78 follow-up). +// +// #221 made the switch REAL — the extension watcher re-preps the session config +// and restarts the opencode server. The webview survives that; its SSE stream +// does not. Nothing narrated the gap, so a deliberate tier change looked like a +// hang (the reconnect loop retries silently, which reads as an endless +// "thinking" wave). +// +// Scope is deliberately narrow. The old ConnectionBanner spoke for EVERY drop +// and was unmounted on 2026-08-07 (f696388/a03aa04) after opencode#132's stuck +// pill; this one only ever speaks for a switch the app itself requested, so it +// cannot get stuck on a transient blip and does not reinstate that decision. + +// Module-level: the two call sites that can start a switch live in different +// component trees (the popover's connections state and the home chrome's), and +// both must reach the one banner in the layout. +const [target, setTarget] = createSignal() +const [startedAt, setStartedAt] = createSignal(0) + +/** Announce a switch the app just requested. `hp` rides a validated credential, + * `piccolo` rides POST /amicode/solver-mode — this only mirrors that request, + * it never causes one. */ +export function beginSolverSwitch(mode: SolverMode) { + setTarget(mode) + setStartedAt(Date.now()) +} + +function endSolverSwitch() { + setTarget(undefined) + setStartedAt(0) +} + +export function SolverSwitchBanner() { + const sdk = useServerSDK() + const [sawDrop, setSawDrop] = createSignal(false) + const [elapsed, setElapsed] = createSignal(0) + + // One clock, alive only while a switch is outstanding: it drives the expiry + // check, which has nothing else to react to (the stall case is defined by the + // absence of any status change). + createEffect(() => { + if (!target()) { + setSawDrop(false) + setElapsed(0) + return + } + const timer = setInterval(() => setElapsed(Date.now() - startedAt()), 500) + onCleanup(() => clearInterval(timer)) + }) + + // Latch the drop: once the server has gone down, coming back up is the + // switch completing rather than the request still waiting to be picked up. + createEffect(() => { + if (target() && sdk().event.status() === "disconnected") setSawDrop(true) + }) + + const phase = (): SolverSwitchPhase => + solverSwitchPhase({ + target: target(), + connected: sdk().event.status() === "connected", + sawDrop: sawDrop(), + }) + + createEffect(() => { + const current = phase() + if (current === "idle") return + // Hold the completed chip briefly — it is the only confirmation the user + // gets inside the app, and the extension's toast lands outside the webview. + if (current === "ready") { + const done = setTimeout(endSolverSwitch, 3000) + onCleanup(() => clearTimeout(done)) + return + } + if (solverSwitchExpired(current, elapsed())) endSolverSwitch() + }) + + const label = () => solverSwitchLabel(phase(), target()) + + return ( + + {(text) => ( +
+