-
Notifications
You must be signed in to change notification settings - Fork 0
feat(solver): narrate the switch — a banner for the restart #221 made real #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SolverMode | undefined>() | ||
| 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 ( | ||
| <Show when={label()}> | ||
| {(text) => ( | ||
| <div data-component="amicode-solver-switch" data-phase={phase()} role="status" aria-live="polite"> | ||
| <i aria-hidden="true" /> | ||
| <span>{text()}</span> | ||
| </div> | ||
| )} | ||
| </Show> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,6 +41,11 @@ | |
| --font-weight-emphasis: 600; --font-weight-strong: 650; | ||
|
|
||
| --border-width: 1px; | ||
|
|
||
| /* ── elevation ── the one float shadow, for transient overlays that sit above | ||
| the app ground (toasts, the solver-switch banner). Named so it stops being | ||
| re-invented as a scattered rgba() literal. */ | ||
| --elev-float: 0 8px 24px rgb(0 0 0 / 0.35); | ||
| } | ||
|
|
||
| /* ── accent role tokens — per-scheme OPACITY, same hue ── | ||
|
|
@@ -127,3 +132,50 @@ button, [role="button"], a, input, textarea, [data-slot="card"] { | |
| /* the rail: controls use the control corner */ | ||
| [data-component="sidebar-rail"] button, | ||
| [data-component="chat-first-rail"] button { border-radius: var(--radius-md); } | ||
|
|
||
|
|
||
| /* solver-switch banner (opencode#78 follow-up) — narrates the server restart a | ||
| solver switch triggers. Progress reads NEUTRAL (yellow may never be a | ||
| foreground on light); completion is the brand chip: --accent fill with | ||
| near-black --accent-ink, identical in both schemes. */ | ||
| [data-component="amicode-solver-switch"] { | ||
| position: fixed; | ||
| bottom: var(--space-4); | ||
| left: 50%; | ||
| transform: translateX(-50%); | ||
| z-index: 40; | ||
| display: flex; | ||
| align-items: center; | ||
| gap: var(--space-2); | ||
| padding: var(--space-2) var(--space-4); | ||
| border-radius: var(--radius-full); | ||
| border: var(--border-width) solid var(--v2-border-border-base); | ||
| background: var(--v2-background-bg-layer-01); | ||
| color: var(--v2-text-text-base); | ||
| font-size: var(--font-size-sm); | ||
| font-weight: var(--font-weight-emphasis); | ||
| box-shadow: var(--elev-float); | ||
| transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease; | ||
| } | ||
| [data-component="amicode-solver-switch"][data-phase="ready"] { | ||
| background: var(--accent); | ||
| border-color: var(--accent); | ||
| color: var(--accent-ink); | ||
| } | ||
| /* currentColor, so the dot is neutral ink while switching and near-black on the | ||
| yellow chip — never a yellow glyph on a light ground. */ | ||
| [data-component="amicode-solver-switch"] > i { | ||
| width: 6px; | ||
| height: 6px; | ||
| border-radius: var(--radius-full); | ||
| background: currentColor; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Fix the Stylelint keyword case. Line 171 uses 🧰 Tools🪛 Stylelint (17.14.0)[error] 171-171: Expected "currentColor" to be "currentcolor" (value-keyword-case) (value-keyword-case) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| animation: amc-solver-switch-pulse 1.2s ease-in-out infinite; | ||
| } | ||
| [data-component="amicode-solver-switch"][data-phase="ready"] > i { | ||
| animation: none; | ||
| } | ||
| /* the global prefers-reduced-motion reset above collapses this duration. */ | ||
| @keyframes amc-solver-switch-pulse { | ||
| 0%, 100% { opacity: 0.35; } | ||
| 50% { opacity: 1; } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { describe, expect, test } from "bun:test" | ||
| import { | ||
| SOLVER_SWITCH_MAX_MS, | ||
| SOLVER_SWITCH_STALL_MS, | ||
| solverModeName, | ||
| solverSwitchExpired, | ||
| solverSwitchLabel, | ||
| solverSwitchPhase, | ||
| } from "./solver-switch" | ||
|
|
||
| describe("solver switch phase", () => { | ||
| test("no target is idle, connected or not", () => { | ||
| expect(solverSwitchPhase({ target: undefined, connected: true, sawDrop: false })).toBe("idle") | ||
| expect(solverSwitchPhase({ target: undefined, connected: false, sawDrop: true })).toBe("idle") | ||
| }) | ||
|
|
||
| test("requested → restarting → ready over one switch", () => { | ||
| const target = "piccolo" as const | ||
| expect(solverSwitchPhase({ target, connected: true, sawDrop: false })).toBe("requested") | ||
| expect(solverSwitchPhase({ target, connected: false, sawDrop: false })).toBe("restarting") | ||
| expect(solverSwitchPhase({ target, connected: true, sawDrop: true })).toBe("ready") | ||
| }) | ||
|
|
||
| test("a drop while still down stays restarting — sawDrop does not short-circuit it", () => { | ||
| expect(solverSwitchPhase({ target: "hp", connected: false, sawDrop: true })).toBe("restarting") | ||
| }) | ||
| }) | ||
|
|
||
| describe("solver switch expiry", () => { | ||
| test("a request that never drops the server is abandoned", () => { | ||
| expect(solverSwitchExpired("requested", SOLVER_SWITCH_STALL_MS - 1)).toBe(false) | ||
| expect(solverSwitchExpired("requested", SOLVER_SWITCH_STALL_MS + 1)).toBe(true) | ||
| }) | ||
|
|
||
| test("a restart gets the full ceiling, not the stall window", () => { | ||
| expect(solverSwitchExpired("restarting", SOLVER_SWITCH_STALL_MS + 1)).toBe(false) | ||
| expect(solverSwitchExpired("restarting", SOLVER_SWITCH_MAX_MS + 1)).toBe(true) | ||
| }) | ||
|
|
||
| test("idle and ready never expire — the caller clears those", () => { | ||
| expect(solverSwitchExpired("idle", SOLVER_SWITCH_MAX_MS * 10)).toBe(false) | ||
| expect(solverSwitchExpired("ready", SOLVER_SWITCH_MAX_MS * 10)).toBe(false) | ||
| }) | ||
| }) | ||
|
|
||
| describe("solver switch copy", () => { | ||
| test("names match the capsule's own labels", () => { | ||
| expect(solverModeName("hp")).toBe("Piccolissimo + Altissimo") | ||
| expect(solverModeName("piccolo")).toBe("Piccolo") | ||
| }) | ||
|
|
||
| test("every visible phase reads as an upgrade, never as a fault", () => { | ||
| expect(solverSwitchLabel("requested", "hp")).toBe("Switching to Piccolissimo + Altissimo…") | ||
| expect(solverSwitchLabel("restarting", "hp")).toBe("Restarting session server…") | ||
| expect(solverSwitchLabel("ready", "piccolo")).toBe("Piccolo ready") | ||
| expect(solverSwitchLabel("restarting", "hp")).not.toMatch(/drop|lost|error|fail/i) | ||
| }) | ||
|
|
||
| test("idle and targetless render nothing", () => { | ||
| expect(solverSwitchLabel("idle", "hp")).toBeUndefined() | ||
| expect(solverSwitchLabel("ready", undefined)).toBeUndefined() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import type { SolverMode } from "./solver-toggle" | ||
|
|
||
| // AMICODE: the solver-switch banner's decision layer (opencode#78 follow-up). | ||
| // | ||
| // A switch is not instant and it is not quiet: the extension watcher sees | ||
| // {status:"switching"}, re-preps the session config, and RESTARTS the opencode | ||
| // server underneath the webview. For those seconds the SSE stream is simply | ||
| // gone. Without a signal that reads as an upgrade, the drop reads as a fault — | ||
| // or worse, as the endless "thinking" wave the reconnect loop hides behind. | ||
| // | ||
| // Deliberately narrower than the removed ConnectionBanner (unmounted 2026-08-07, | ||
| // f696388/a03aa04): this speaks ONLY for a switch the app itself requested. | ||
| // General connection drops stay silent — reintroducing that warning is a | ||
| // separate call, and not this one to make. | ||
| // | ||
| // Pure helpers so the phase contract is testable without a DOM, matching the | ||
| // decision-helper split in solver-toggle.tsx. | ||
|
|
||
| export type SolverSwitchPhase = "idle" | "requested" | "restarting" | "ready" | ||
|
|
||
| /** Phase from the two observable facts: is a switch outstanding, and has the | ||
| * stream dropped yet. `sawDrop` is latched by the caller — once the server has | ||
| * gone down, coming back up means "ready", not "still waiting to start". */ | ||
| export function solverSwitchPhase(input: { | ||
| target: SolverMode | undefined | ||
| connected: boolean | ||
| sawDrop: boolean | ||
| }): SolverSwitchPhase { | ||
| if (!input.target) return "idle" | ||
| if (!input.connected) return "restarting" | ||
| return input.sawDrop ? "ready" : "requested" | ||
| } | ||
|
|
||
| /** The extension watcher polls solver-mode.json every 1s, so a request that has | ||
| * not taken the server down well inside this window is not going to — no | ||
| * extension host, a stale binary, a write that never landed. Abandon quietly | ||
| * rather than leave a permanent pill on screen (opencode#132's failure mode). */ | ||
| export const SOLVER_SWITCH_STALL_MS = 12_000 | ||
| /** Total ceiling, inherited from the stale #14 wizard's safety valve: never trap | ||
| * the user behind theater, however wedged the restart is. */ | ||
| export const SOLVER_SWITCH_MAX_MS = 90_000 | ||
|
|
||
| export function solverSwitchExpired(phase: SolverSwitchPhase, elapsedMs: number): boolean { | ||
| if (phase === "requested") return elapsedMs > SOLVER_SWITCH_STALL_MS | ||
| if (phase === "restarting") return elapsedMs > SOLVER_SWITCH_MAX_MS | ||
| return false | ||
| } | ||
|
|
||
| /** The names the capsule already shows — one name end to end, so the banner and | ||
| * the toggle never disagree about what the user just picked. */ | ||
| export function solverModeName(mode: SolverMode): string { | ||
| return mode === "hp" ? "Piccolissimo + Altissimo" : "Piccolo" | ||
| } | ||
|
|
||
| export function solverSwitchLabel(phase: SolverSwitchPhase, target: SolverMode | undefined): string | undefined { | ||
| if (!target || phase === "idle") return undefined | ||
| if (phase === "requested") return `Switching to ${solverModeName(target)}…` | ||
| if (phase === "restarting") return "Restarting session server…" | ||
| return `${solverModeName(target)} ready` | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| // AMICODE: re-export shim (wildcard export path) — logic in ../amicode/solver-switch.ts. | ||
| export { | ||
| SOLVER_SWITCH_MAX_MS, | ||
| SOLVER_SWITCH_STALL_MS, | ||
| solverModeName, | ||
| solverSwitchExpired, | ||
| solverSwitchLabel, | ||
| solverSwitchPhase, | ||
| type SolverSwitchPhase, | ||
| } from "../amicode/solver-switch" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reset lifecycle state for each solver-switch request.
beginSolverSwitchonly updatestargetandstartedAt. A second request can inheritsawDrop === truefrom a completed request.If the target changes while connected,
solverSwitchPhasereturns"ready"immediately. If the target is unchanged, the existing ready timeout remains active because no effect tracksstartedAt; it can clear the new request before the server drops.Associate
sawDrop, elapsed time, and the completion timeout with a request generation. Reset them for everybeginSolverSwitchcall. Add a lifecycle test for consecutive requests.🤖 Prompt for AI Agents