fix(ui): a stream that went quiet reconnects, the rail says so, the panel opens on the master

A phone that slept brings back an SSE socket that never errored and never
delivers: the panel read as live for hours while showing nothing new. Every
chunk now marks the connection alive and a watchdog drops the socket after
40s of silence - twice the server's keepalive - so the loop reconnects.
Each attempt gets its own controller, which is what lets the watchdog (and
a manual refresh) end one connection without ending the loop.

The connection is admitted in one dot beside the rail's search box, tapped
to reconnect at once rather than waiting out the backoff - the pill that
used to say this was dropped in the redesign and the panel had no way to
own up to being stale.

With nothing picked, the shell lands on the freshest open master instead of
a prompt to pick something: what a phone wants when the panel opens. An
explicit pick, and following the active note, still win.
This commit is contained in:
hh
2026-09-05 00:20:03 +02:00
parent 96e18156eb
commit 82029bb10d
6 changed files with 176 additions and 11 deletions
+3 -2
View File
@@ -148,7 +148,8 @@ export class ApiClient {
async stream(
path: string,
onEvent: (event: BusEvent) => void,
signal: AbortSignal
signal: AbortSignal,
onActivity?: () => void
): Promise<void> {
const response = await fetch(this.url(path), {
headers: this.headers({ Accept: "text/event-stream" }),
@@ -162,7 +163,7 @@ export class ApiClient {
payload
);
}
await readSse(response, onEvent, signal);
await readSse(response, onEvent, signal, onActivity);
}
agents(): Promise<AgentsResponse> {
+101 -7
View File
@@ -6,6 +6,11 @@ export type LiveState = "off" | "connecting" | "open" | "retrying" | "failed";
const BACKOFF_MIN_MS = 1000;
const BACKOFF_MAX_MS = 30_000;
const JITTER_MS = 250;
// The server sends a keepalive comment every 15s, so twice that of silence
// means the socket is dead even though nothing errored - which is exactly
// what a phone brings back from sleep: a stream that reads as open forever.
const STALE_AFTER_MS = 40_000;
const WATCH_EVERY_MS = 5000;
const FATAL = new Set([401, 403, 404]);
function sleep(ms: number, signal: AbortSignal): Promise<void> {
@@ -41,9 +46,13 @@ export class LiveStream {
state = $state<LiveState>("off");
detail = $state<string | null>(null);
private controller: AbortController | null = null;
private inner: AbortController | null = null;
private readonly client: () => ApiClient | null;
private readonly path: string;
private readonly options: LiveOptions;
private seen = 0;
private watcher: ReturnType<typeof setInterval> | null = null;
private wake: (() => void) | null = null;
constructor(
client: () => ApiClient | null,
@@ -65,6 +74,7 @@ export class LiveStream {
}
const controller = new AbortController();
this.controller = controller;
this.watch();
this.loop(controller.signal).catch((error: unknown) => {
this.state = "failed";
this.detail = describe(error);
@@ -72,31 +82,108 @@ export class LiveStream {
}
stop(): void {
this.unwatch();
this.controller?.abort();
this.controller = null;
this.inner = null;
this.state = "off";
}
/**
* Reconnect now: the operator tapped the dot, or the app came back to the
* foreground. A failed stream has no loop left to nudge, so it restarts.
*/
refresh(): void {
if (!this.controller || this.state === "failed") {
this.stop();
this.start();
return;
}
this.state = "connecting";
this.detail = null;
this.inner?.abort();
this.wake?.();
}
/**
* A stream that stopped delivering without erroring - the phone slept, the
* network changed under it - reads as open forever. Silence past the
* server's keepalive is the only tell, so the socket is dropped and the
* loop reconnects.
*/
private watch(): void {
if (this.watcher || typeof setInterval === "undefined") {
return;
}
this.watcher = setInterval(() => {
if (this.state !== "open" || Date.now() - this.seen < STALE_AFTER_MS) {
return;
}
this.state = "retrying";
this.detail = "stream went quiet";
this.inner?.abort();
}, WATCH_EVERY_MS);
}
private unwatch(): void {
if (this.watcher) {
clearInterval(this.watcher);
this.watcher = null;
}
}
private async loop(signal: AbortSignal): Promise<void> {
let delay = BACKOFF_MIN_MS;
while (!signal.aborted) {
this.state = delay === BACKOFF_MIN_MS ? "connecting" : "retrying";
// Each attempt gets its own controller, so the watchdog (and a manual
// refresh) can drop one socket without ending the loop.
const inner = new AbortController();
this.inner = inner;
const relay = () => inner.abort();
signal.addEventListener("abort", relay, { once: true });
// biome-ignore lint/performance/noAwaitInLoops: one connection at a time, by design
const outcome = await this.connectOnce(signal, () => {
const outcome = await this.connectOnce(inner.signal, () => {
delay = BACKOFF_MIN_MS;
});
signal.removeEventListener("abort", relay);
this.inner = null;
if (signal.aborted || outcome === "fatal") {
return;
}
await sleep(delay + Math.random() * JITTER_MS, signal);
delay = Math.min(delay * 2, BACKOFF_MAX_MS);
// A socket dropped on purpose reconnects at once; a broken one waits.
const wait = outcome === "now" ? 0 : delay + Math.random() * JITTER_MS;
await this.pause(wait, signal);
delay =
outcome === "now"
? BACKOFF_MIN_MS
: Math.min(delay * 2, BACKOFF_MAX_MS);
}
}
/** Backoff that a manual refresh can cut short. */
private pause(ms: number, signal: AbortSignal): Promise<void> {
if (ms <= 0) {
return Promise.resolve();
}
return new Promise((resolve) => {
const done = () => {
this.wake = null;
resolve();
};
this.wake = done;
sleep(ms, signal).then(() => {
if (this.wake === done) {
done();
}
}, done);
});
}
private async connectOnce(
signal: AbortSignal,
onOpen: () => void
): Promise<"retry" | "fatal"> {
): Promise<"retry" | "fatal" | "now"> {
try {
const client = this.client();
if (!client) {
@@ -104,8 +191,9 @@ export class LiveStream {
}
await this.options.prepare?.();
if (signal.aborted) {
return "fatal";
return this.controller ? "now" : "fatal";
}
this.seen = Date.now();
await client.stream(
this.path,
(event) => {
@@ -117,19 +205,25 @@ export class LiveStream {
}
this.options.onEvent(event);
},
signal
signal,
() => {
this.seen = Date.now();
}
);
this.state = "retrying";
this.detail = "stream closed";
return "retry";
} catch (error) {
if (signal.aborted) {
return "fatal";
// Aborted while the session lives on: this was the watchdog or a
// manual refresh, so reconnect without waiting out the backoff.
return this.controller ? "now" : "fatal";
}
if (error instanceof ApiError && FATAL.has(error.status)) {
this.state = "failed";
this.detail = `${error.status}: ${error.message}`;
this.controller = null;
this.unwatch();
return "fatal";
}
this.state = "retrying";
+5 -1
View File
@@ -38,7 +38,10 @@ function drain(buffer: string, onEvent: (event: BusEvent) => void): string {
export async function readSse(
response: Response,
onEvent: (event: BusEvent) => void,
signal?: AbortSignal
signal?: AbortSignal,
// Every chunk the socket delivers, keepalive comments included: proof the
// connection is still alive on a phone that just came back from sleep.
onActivity?: () => void
): Promise<void> {
const { body } = response;
if (!body) {
@@ -58,6 +61,7 @@ export async function readSse(
if (done) {
break;
}
onActivity?.();
buffer = drain(buffer + decoder.decode(value, { stream: true }), onEvent);
}
} finally {
+5
View File
@@ -160,6 +160,11 @@ export class ConversationIndex {
this.live.stop();
}
/** The dot was tapped: drop the socket, reload the rows, reconnect. */
reconnect(): void {
this.live.refresh();
}
private upsert(row: ConversationSummary): void {
const index = this.conversations.findIndex((c) => c.id === row.id);
if (index >= 0) {
+33 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import type { ApiClient } from "$lib/api/client";
import Rail from "$lib/rail/rail.svelte";
import ConversationView from "./conversation-view.svelte";
@@ -14,6 +14,7 @@
showFollow = false,
companion = false,
onOpenFile,
fallbackToMaster = false,
}: {
client: ApiClient;
index: ConversationIndex;
@@ -23,6 +24,9 @@
// The thread lives in the note beside this panel: open on activity.
companion?: boolean;
onOpenFile?: (path: string) => void;
// Nothing picked yet: land on the freshest master rather than on a prompt.
// What the phone wants - the panel opens where the day is happening.
fallbackToMaster?: boolean;
} = $props();
// 48rem of panel: below it the switcher names the thread, above it the rail does.
@@ -35,6 +39,34 @@
return () => index.stop();
});
// The latest master that is still open, by its own activity.
const latestMaster = $derived.by(() => {
const masters = index.conversations.filter(
(row) => row.kind === "master" && row.status === "open"
);
let best: (typeof masters)[number] | undefined;
for (const row of masters) {
const when = row.last_activity_at ?? row.created_at ?? "";
const top = best ? (best.last_activity_at ?? best.created_at ?? "") : "";
if (!best || when > top) {
best = row;
}
}
return best ?? null;
});
$effect(() => {
if (!fallbackToMaster || selected || !index.loaded) {
return;
}
const master = latestMaster;
if (master) {
untrack(() => {
selected = master.id;
});
}
});
function open(id: string) {
selected = id;
}
+29
View File
@@ -4,6 +4,7 @@
import SearchIcon from "@lucide/svelte/icons/search";
import { onMount } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { LiveState } from "$lib/api/live.svelte";
import type {
ConversationSummary,
SearchFile,
@@ -40,6 +41,23 @@
const DAYS_SHOWN = 14;
const TICK_MS = 30_000;
// The connection, said in one dot beside the search box: the panel can sit
// untouched for hours on a phone, and this is where it admits it went quiet.
const LIVE_DOT: Record<LiveState, string> = {
connecting: "bg-warn",
failed: "bg-destructive",
off: "bg-muted-foreground/40",
open: "bg-ok",
retrying: "bg-warn animate-pulse-dot",
};
const LIVE_LABEL: Record<LiveState, string> = {
connecting: "Connecting",
failed: "Offline",
off: "Not connected",
open: "Live",
retrying: "Reconnecting",
};
let query = $state("");
let expanded = $state<Set<string>>(new Set());
let remote = $state<SearchResponse | null>(null);
@@ -173,6 +191,17 @@
></span>
{/if}
</label>
<button
aria-label={LIVE_LABEL[index.live.state]}
class="grid size-8 shrink-0 place-items-center rounded-md hover:bg-accent"
onclick={() => index.reconnect()}
title="{LIVE_LABEL[index.live.state]} tap to reconnect"
type="button"
>
<span
class={cn("size-2 rounded-full", LIVE_DOT[index.live.state])}
></span>
</button>
<button
aria-label="New conversation"
class="rule-word inline-flex size-8 items-center justify-center rounded-md hover:bg-accent"