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 8019c76e53
commit 421248b0ad
6 changed files with 176 additions and 11 deletions
+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"