Files
beaver-gateway/ui/src/lib/rail/rail.svelte
T
hh 421248b0ad 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.
2026-09-05 00:20:03 +02:00

409 lines
13 KiB
Svelte

<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import PlusIcon from "@lucide/svelte/icons/plus";
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,
SearchResponse,
} from "$lib/api/types";
import ErrorNote from "$lib/components/error-note.svelte";
import { clip } from "$lib/format";
import type { ConversationIndex } from "$lib/panel/index.svelte";
import Strip from "$lib/shell/strip.svelte";
import { cn } from "$lib/utils";
import { type DayGroup, daySummary, groupByDay } from "./days";
import NewConversation from "./new-conversation.svelte";
let {
client,
index,
selected = null,
href,
onOpen,
onOpenFile,
class: className = "",
}: {
client: ApiClient;
index: ConversationIndex;
selected?: string | null;
href?: (id: string) => string;
onOpen?: (id: string) => void;
onOpenFile?: (path: string) => void;
class?: string;
} = $props();
const SEARCH_MIN = 2;
const SEARCH_DEBOUNCE_MS = 250;
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);
let searching = $state(false);
let createOpen = $state(false);
let now = $state(Date.now());
let timer: ReturnType<typeof setTimeout> | undefined;
onMount(() => {
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
return () => clearInterval(tick);
});
const needle = $derived(query.trim().toLowerCase());
const groups = $derived(groupByDay(index.conversations, new Date(now)));
const local = $derived.by((): ConversationSummary[] => {
if (!needle) {
return [];
}
return index.conversations.filter(
(row) =>
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
(row.last_item?.text ?? "").toLowerCase().includes(needle)
);
});
const found = $derived.by((): ConversationSummary[] => {
const seen = new Set(local.map((row) => row.id));
const extra = (remote?.query === needle ? remote.conversations : []).filter(
(row) => !seen.has(row.id)
);
return [...local, ...extra];
});
const files = $derived<SearchFile[]>(
remote?.query === needle ? remote.files : []
);
$effect(() => {
const q = needle;
clearTimeout(timer);
if (q.length < SEARCH_MIN) {
remote = null;
searching = false;
return;
}
searching = true;
timer = setTimeout(async () => {
try {
const result = await client.search(q);
if (result.query === query.trim().toLowerCase()) {
remote = result;
}
} catch {
remote = null;
} finally {
searching = false;
}
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
});
function isOpen(group: DayGroup, position: number): boolean {
return position === 0 || group.live || expanded.has(group.day);
}
function toggle(day: string) {
const next = new Set(expanded);
if (next.has(day)) {
next.delete(day);
} else {
next.add(day);
}
expanded = next;
}
let jobsOpen = $state<Set<string>>(new Set());
function toggleJobs(day: string) {
const next = new Set(jobsOpen);
if (next.has(day)) {
next.delete(day);
} else {
next.add(day);
}
jobsOpen = next;
}
const refresh = () => index.load().catch(() => undefined);
const warm = (id: string) => index.prefetch([id]);
// What the agent did on its own that day: digests and scheduled runs.
function quietSummary(group: DayGroup): string {
const parts: string[] = [];
if (group.forks.length) {
parts.push(
`${group.forks.length} ${group.forks.length === 1 ? "digest" : "digests"}`
);
}
if (group.jobs.length) {
parts.push(
`${group.jobs.length} ${group.jobs.length === 1 ? "job run" : "job runs"}`
);
}
return parts.join(" · ");
}
const linkOf = (id: string) => href?.(id);
</script>
<div class={cn("flex h-full min-h-0 flex-col", className)}>
<div class="flex items-center gap-2 px-3 py-2">
<label
class="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md bg-strip px-2 ring-1 ring-border focus-within:ring-ring"
>
<SearchIcon class="size-3.5 shrink-0 text-icon" />
<input
aria-label="Search conversations and memory"
class="h-full w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Search"
spellcheck="false"
type="search"
bind:value={query}
>
{#if searching}
<span
class="size-1.5 shrink-0 animate-pulse-dot rounded-full bg-signal"
></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"
onclick={() => {
createOpen = true;
}}
type="button"
>
<PlusIcon class="size-4" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 pb-6">
{#if index.live.state === "failed"}
<ErrorNote
message={index.live.detail ?? "event stream failed"}
retry={() => index.start()}
/>
{:else if !index.loaded}
<p class="px-1 py-3 text-muted-foreground text-sm">Loading…</p>
{:else if needle}
<section class="flex flex-col gap-2 pt-1">
<h2 class="label-quiet px-1">
{found.length === 0 && !searching ? "Nothing matches" : "Conversations"}
</h2>
{#if found.length > 0}
<div class="bay-rack">
{#each found as row (row.id)}
<Strip
{client}
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
{row}
/>
{/each}
</div>
{/if}
{#if files.length > 0}
<h2 class="label-quiet px-1 pt-2">Memory</h2>
<div class="bay-rack">
{#each files as file (file.path)}
<button
class="strip w-full text-left text-sm"
onclick={() => onOpenFile?.(file.path)}
type="button"
>
<span class="size-5"></span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate font-medium">{file.path}</span>
{#if file.snippet}
<span class="truncate text-muted-foreground text-xs">
{clip(file.snippet, 96)}
</span>
{/if}
</span>
<span class="tabular text-muted-foreground text-xs">
{file.line ? `:${file.line}` : ""}
</span>
</button>
{/each}
</div>
{/if}
</section>
{:else}
{#each groups.slice(0, DAYS_SHOWN) as group, position (group.day)}
{@const shown = isOpen(group, position)}
<section class="flex flex-col gap-1.5 pt-2">
<button
aria-expanded={shown}
class="flex items-center gap-1.5 px-1 text-left"
onclick={() => toggle(group.day)}
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 text-icon transition-transform duration-150",
shown && "rotate-90"
)}
/>
<span
class={cn(
"font-medium text-sm",
position === 0 ? "text-foreground" : "text-muted-foreground"
)}
>
{group.label}
</span>
{#if group.live}
<span
class="size-1.5 rounded-full bg-signal animate-pulse-dot"
></span>
{/if}
<span class="truncate text-muted-foreground text-xs">
{daySummary(group, position === 0)}
</span>
</button>
{#if shown}
<div class="bay-rack">
{#if group.master}
<Strip
{client}
current={group.master.id === selected}
detail={group.master.last_item?.text ?? ""}
href={linkOf(group.master.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
row={group.master}
/>
{/if}
{#each group.branches as row (row.id)}
<Strip
{client}
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
{row}
/>
{/each}
{#each group.deep as row (row.id)}
<Strip
{client}
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
{row}
/>
{/each}
{#each group.others as row (row.id)}
<Strip
{client}
current={row.id === selected}
detail="{row.kind} · {row.status}"
href={linkOf(row.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
{row}
/>
{/each}
{#if group.forks.length + group.jobs.length > 0}
{@const shownQuiet = jobsOpen.has(group.day)}
<button
aria-expanded={shownQuiet}
class="strip w-full text-left text-muted-foreground text-xs"
onclick={() => toggleJobs(group.day)}
type="button"
>
<span class="size-5"></span>
<span>
{quietSummary(group)}
· {shownQuiet ? "hide" : "show"}
</span>
<span></span>
</button>
{#if shownQuiet}
{#each [...group.forks, ...group.jobs] as row (row.id)}
<Strip
{client}
current={row.id === selected}
detail={row.last_item?.text ?? `${row.kind} · ${row.status}`}
href={linkOf(row.id)}
{now}
onChanged={refresh}
onclick={onOpen}
onWarm={warm}
{row}
/>
{/each}
{/if}
{/if}
</div>
{/if}
</section>
{/each}
{#if groups.length === 0}
<p class="px-1 py-3 text-muted-foreground text-sm">
No conversations yet. Start one, or wait for the morning master.
</p>
{/if}
{/if}
</div>
</div>
<NewConversation
{client}
onCreated={(id) => onOpen?.(id)}
bind:open={createOpen}
/>