feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph

This commit is contained in:
hh
2026-09-02 03:48:26 +02:00
parent 85b14e2c2f
commit 025f2dbc3f
60 changed files with 5071 additions and 2347 deletions
+320
View File
@@ -0,0 +1,320 @@
<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 {
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, 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;
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.kind !== "job" &&
((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;
}
function summary(group: DayGroup): string {
const parts: string[] = [];
if (group.branches.length) {
parts.push(
`${group.branches.length} ${group.branches.length === 1 ? "branch" : "branches"}`
);
}
if (group.deep.length) {
parts.push(`${group.deep.length} deep`);
}
if (group.others.length) {
parts.push(`${group.others.length} more`);
}
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="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
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{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">
{summary(group)}
</span>
</button>
{#if shown}
<div class="bay-rack">
{#if group.master}
<Strip
current={group.master.id === selected}
detail={group.master.last_item?.text ?? ""}
href={linkOf(group.master.id)}
{now}
onclick={onOpen}
row={group.master}
/>
{/if}
{#each group.branches as row (row.id)}
<Strip
class="strip-child"
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
{#each group.deep as row (row.id)}
<Strip
current={row.id === selected}
detail={row.last_item?.text ?? ""}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
{#each group.others as row (row.id)}
<Strip
class="strip-child"
current={row.id === selected}
href={linkOf(row.id)}
{now}
onclick={onOpen}
{row}
/>
{/each}
</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}
/>