feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import type { ConversationSummary } from "$lib/api/types";
|
||||
import { parseDate } from "$lib/format";
|
||||
import { byActivity } from "$lib/shell/state";
|
||||
|
||||
export interface DayGroup {
|
||||
branches: ConversationSummary[];
|
||||
day: string;
|
||||
deep: ConversationSummary[];
|
||||
label: string;
|
||||
live: boolean;
|
||||
master: ConversationSummary | null;
|
||||
others: ConversationSummary[];
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function localDay(iso: string | null | undefined): string {
|
||||
const date = parseDate(iso) ?? new Date();
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function dayLabel(day: string, now = new Date()): string {
|
||||
const today = localDay(now.toISOString());
|
||||
const yesterday = localDay(new Date(now.getTime() - DAY_MS).toISOString());
|
||||
if (day === today) {
|
||||
return "Today";
|
||||
}
|
||||
if (day === yesterday) {
|
||||
return "Yesterday";
|
||||
}
|
||||
const date = new Date(`${day}T12:00:00`);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
weekday: "short",
|
||||
});
|
||||
}
|
||||
|
||||
function activityOf(row: ConversationSummary): string {
|
||||
return row.last_activity_at ?? row.created_at ?? "";
|
||||
}
|
||||
|
||||
// Days as the operator remembers them: a master per day, its branches
|
||||
// under it, deep chats by the day they last moved, forks with their
|
||||
// parent's day. Jobs never enter the rail.
|
||||
export function groupByDay(
|
||||
rows: ConversationSummary[],
|
||||
now = new Date()
|
||||
): DayGroup[] {
|
||||
const groups = new Map<string, DayGroup>();
|
||||
const dayOfMaster = new Map<string, string>();
|
||||
const group = (day: string): DayGroup => {
|
||||
let found = groups.get(day);
|
||||
if (!found) {
|
||||
found = {
|
||||
branches: [],
|
||||
day,
|
||||
deep: [],
|
||||
label: dayLabel(day, now),
|
||||
live: false,
|
||||
master: null,
|
||||
others: [],
|
||||
};
|
||||
groups.set(day, found);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
const masters = rows
|
||||
.filter((row) => row.kind === "master")
|
||||
.sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
|
||||
for (const master of masters) {
|
||||
const day = localDay(master.created_at);
|
||||
dayOfMaster.set(master.id, day);
|
||||
const g = group(day);
|
||||
if (!g.master || master.status === "open") {
|
||||
g.master = master;
|
||||
} else {
|
||||
g.others.push(master);
|
||||
}
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (row.kind === "master" || row.kind === "job") {
|
||||
continue;
|
||||
}
|
||||
const parentDay = row.parent ? dayOfMaster.get(row.parent) : undefined;
|
||||
const day = parentDay ?? localDay(activityOf(row));
|
||||
const g = group(day);
|
||||
if (row.kind === "branch") {
|
||||
g.branches.push(row);
|
||||
} else if (row.kind === "deep") {
|
||||
g.deep.push(row);
|
||||
} else {
|
||||
g.others.push(row);
|
||||
}
|
||||
}
|
||||
const out = [...groups.values()];
|
||||
for (const g of out) {
|
||||
g.branches.sort(byActivity);
|
||||
g.deep.sort(byActivity);
|
||||
g.others.sort(byActivity);
|
||||
g.live = [g.master, ...g.branches, ...g.deep, ...g.others].some(
|
||||
(row) => row && (row.running_turn || row.pending_question)
|
||||
);
|
||||
}
|
||||
return out.sort((a, b) => b.day.localeCompare(a.day));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import type { AgentInfo } from "$lib/api/types";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
|
||||
let {
|
||||
client,
|
||||
open = $bindable(false),
|
||||
onCreated,
|
||||
}: {
|
||||
client: ApiClient;
|
||||
open?: boolean;
|
||||
onCreated: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
const KINDS = ["deep", "branch", "master", "job"];
|
||||
let agents = $state<AgentInfo[]>([]);
|
||||
let form = $state({ agent: "", kind: "deep", text: "", title: "" });
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open && agents.length === 0) {
|
||||
client
|
||||
.agents()
|
||||
.then((result) => {
|
||||
({ agents } = result);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const agentsForKind = $derived(
|
||||
agents.filter((a) =>
|
||||
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
|
||||
)
|
||||
);
|
||||
|
||||
async function create() {
|
||||
busy = true;
|
||||
try {
|
||||
const created = await client.spawn({
|
||||
agent: form.agent || undefined,
|
||||
kind: form.kind,
|
||||
seed: "clean",
|
||||
text: form.text || undefined,
|
||||
title: form.title || undefined,
|
||||
});
|
||||
open = false;
|
||||
form = { agent: "", kind: "deep", text: "", title: "" };
|
||||
onCreated(created.id);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>New conversation</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
It opens in the home window of its kind (a vault file, a Telegram topic)
|
||||
and stays silent until someone speaks.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>Kind</Label>
|
||||
<Select.Root
|
||||
onValueChange={(value) => {
|
||||
form.kind = value;
|
||||
form.agent = "";
|
||||
}}
|
||||
type="single"
|
||||
value={form.kind}
|
||||
>
|
||||
<Select.Trigger class="w-full">{form.kind}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each KINDS as option (option)}
|
||||
<Select.Item label={option} value={option} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>Agent</Label>
|
||||
<Select.Root
|
||||
onValueChange={(value) => {
|
||||
form.agent = value === "default" ? "" : value;
|
||||
}}
|
||||
type="single"
|
||||
value={form.agent || "default"}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{form.agent || "frontend default"}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item label="frontend default" value="default" />
|
||||
{#each agentsForKind as agent (agent.name)}
|
||||
<Select.Item label={agent.name} value={agent.name} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="col-span-2 flex flex-col gap-1.5">
|
||||
<Label for="new-title">Title</Label>
|
||||
<Input id="new-title" placeholder="optional" bind:value={form.title} />
|
||||
</div>
|
||||
<div class="col-span-2 flex flex-col gap-1.5">
|
||||
<Label for="new-text">First message</Label>
|
||||
<textarea
|
||||
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||
id="new-text"
|
||||
placeholder="optional - without it the window waits"
|
||||
bind:value={form.text}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
onclick={() => {
|
||||
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
|
||||
open = false;
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy} onclick={create}>Create</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -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