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:
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user