-
Notifications
You must be signed in to change notification settings - Fork 0
Solver switch: reconnect after the restart, and narrate it #228
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; | ||||||||||||||||||||||||||||||
| animation: amc-solver-switch-pulse 1.2s ease-in-out infinite; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+167
to
+173
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 Use the configured keyword casing. Stylelint rejects Proposed fix- background: currentColor;
+ background: currentcolor;📝 Committable suggestion
Suggested change
🧰 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 |
||||||||||||||||||||||||||||||
| [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() | ||
| }) | ||
| }) |
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 | 🟡 Minor | ⚡ Quick win
Reset the drop latch for each switch request.
A new call to
beginSolverSwitch()does not resetsawDrop. If a user starts another switch during the three-secondreadydisplay,solverSwitchPhase()returnsreadyimmediately because the server is connected and the prior request leftsawDroplatched. The banner then clears without reporting the new request or restart.Associate
sawDropwith a request identifier, and reset it when a new request starts.Also applies to: 51-65
🤖 Prompt for AI Agents