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.
409 lines
13 KiB
Svelte
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}
|
|
/>
|