feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
Reference in New Issue
Block a user