From dea441498e6b0650034fe1b5bd3a9e1146e48de5 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Fri, 21 Aug 2026 15:00:03 -0400 Subject: [PATCH 1/2] fix(app): reconnect the event stream after the server restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 event stream reports failures through its `onSseError` callback and then simply stops yielding — the async iterator neither throws nor completes. The reconnect loop only comes round when that iterator ends, so it parked inside `for await` forever: the catch, the loop tail and the 250ms retry were never reached, and the client stayed disconnected from a server that was already back. Only a page reload recovered it. Traced in the browser against a real restart. Before: +1.9s loop iteration start +1.9s stream obtained -> CONNECTED +18.2s onSseError: TypeError: network error +53.2s (end — no catch, no loop tail, no retry, still disconnected 35s after the server was listening again) After: +17.6s onSseError -> abort +17.6s loop iteration start +17.6s stream obtained -> CONNECTED This is not a rare edge. #221 made the solver toggle restart the opencode server by design, so every solver switch left the webview dead until reload — and it is the same shape as opencode#132's stuck reconnecting banner, which is very likely why ConnectionBanner was unmounted in f696388 rather than fixed. The abort is extracted into applySseError() and covered by tests: on its own it reads as redundant next to the disconnect, which is exactly how it would get tidied away again. The early return for an already-closed stream is what stops our own abort from recursing. Refs #132. --- packages/app/src/context/server-sdk.test.ts | 42 ++++++++++++++++++++- packages/app/src/context/server-sdk.tsx | 26 ++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index fea525217..f0d490145 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test" -import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import { + adaptServerEvent, + applySseError, + coalesceServerEvents, + enqueueServerEvent, + resumeStreamAfterPageShow, +} from "./server-sdk" import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" @@ -26,6 +32,40 @@ describe("resumeStreamAfterPageShow", () => { }) }) +describe("applySseError", () => { + const spy = () => { + const calls = { disconnect: 0, abort: 0 } + return { + calls, + disconnect: () => calls.disconnect++, + abort: () => calls.abort++, + } + } + + test("a real stream failure ABORTS the attempt, not just marks it disconnected", () => { + // The regression: the v1 stream's iterator never throws or completes on + // failure, so the reconnect loop only comes round if the attempt is + // aborted. Marking disconnected without aborting leaves the client dead on + // a server that is already back. + const s = spy() + expect(applySseError({ closed: false, ...s })).toBe(true) + expect(s.calls).toEqual({ disconnect: 1, abort: 1 }) + }) + + test("an already-closed stream is left alone — that is our own abort coming back", () => { + const s = spy() + expect(applySseError({ closed: true, ...s })).toBe(false) + expect(s.calls).toEqual({ disconnect: 0, abort: 0 }) + }) + + test("repeated failures keep aborting — recovery must not depend on a first-error latch", () => { + const s = spy() + applySseError({ closed: false, ...s }) + applySseError({ closed: false, ...s }) + expect(s.calls.abort).toBe(2) + }) +}) + describe("adaptServerEvent", () => { test("preserves V2 events while adapting permission requests for existing consumers", () => { const current = { diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 1425a53fd..8fada1d13 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -163,6 +163,24 @@ export function resumeStreamAfterPageShow(_event: PageTransitionEvent, start: () start() } +/** What an SSE stream error must do, extracted so the ABORT — the part that + * reads as redundant and is easy to delete — is covered by a test. + * + * The v1 event stream reports failures through its `onSseError` callback and + * then simply stops yielding: the async iterator neither throws nor completes. + * The reconnect loop only comes round when that iterator ends, so without the + * abort it parks inside `for await` forever and the client stays disconnected + * from a server that is already back — opencode#132's stuck banner, and every + * solver switch since #221, which restarts the server by design. + * + * Returns whether this was a real failure, so the caller keeps its log latch. */ +export function applySseError(input: { closed: boolean; disconnect: () => void; abort: () => void }): boolean { + if (input.closed) return false + input.disconnect() + input.abort() + return true +} + type ServerEventEmitter = ReturnType> type ServerSDKBase = { server: ServerConnection.Any @@ -282,8 +300,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS try { const kind = await protocol const onSseError = (error: unknown) => { - if (isStreamClosed(error, attempt?.signal)) return - setStreamStatus("disconnected") + const real = applySseError({ + closed: isStreamClosed(error, attempt?.signal), + disconnect: () => setStreamStatus("disconnected"), + abort: () => attempt?.abort(), + }) + if (!real) return if (streamErrorLogged) return streamErrorLogged = true console.error("[global-sdk] event stream error", { From 819350eeea41ba8bc240198e72bcb1584e8bd3df Mon Sep 17 00:00:00 2001 From: kate bonner Date: Fri, 21 Aug 2026 13:17:09 -0400 Subject: [PATCH 2/2] =?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) => ( +
+