165 lines
4.9 KiB
Svelte
165 lines
4.9 KiB
Svelte
<script lang="ts">
|
|
import SearchIcon from "@lucide/svelte/icons/search";
|
|
import { goto } from "$app/navigation";
|
|
import { base } from "$app/paths";
|
|
import type { ConversationSummary } from "$lib/api/types";
|
|
import { clip, fmtRelative, shortId } from "$lib/format";
|
|
import { gateway } from "$lib/gateway.svelte";
|
|
import { SECTIONS, SYSTEM_PAGES } from "$lib/nav";
|
|
import { ui } from "$lib/ui.svelte";
|
|
import { cn } from "$lib/utils";
|
|
import KindMark from "./kind-mark.svelte";
|
|
import { byActivity } from "./state";
|
|
|
|
interface Item {
|
|
hint: string;
|
|
href: string;
|
|
id: string;
|
|
kind: "section" | "conversation";
|
|
label: string;
|
|
row?: ConversationSummary;
|
|
}
|
|
|
|
const LIMIT = 10;
|
|
let query = $state("");
|
|
let cursor = $state(0);
|
|
let input = $state<HTMLInputElement | null>(null);
|
|
|
|
const items = $derived.by((): Item[] => {
|
|
const needle = query.trim().toLowerCase();
|
|
const sections: Item[] = [...SECTIONS, ...SYSTEM_PAGES]
|
|
.filter((s) => !needle || s.label.toLowerCase().includes(needle))
|
|
.map((s) => ({
|
|
hint: "section",
|
|
href: `${base}${s.href}`,
|
|
id: `s:${s.href}`,
|
|
kind: "section",
|
|
label: s.label,
|
|
}));
|
|
const conversations: Item[] = gateway.conversations
|
|
.filter(
|
|
(row) =>
|
|
!needle ||
|
|
(row.title ?? "").toLowerCase().includes(needle) ||
|
|
row.id.startsWith(needle) ||
|
|
row.agent.toLowerCase().includes(needle) ||
|
|
(row.last_item?.text ?? "").toLowerCase().includes(needle)
|
|
)
|
|
.sort(byActivity)
|
|
.slice(0, needle ? LIMIT : 5)
|
|
.map((row) => ({
|
|
hint: `${row.status === "open" ? "" : `${row.status} · `}${fmtRelative(row.last_activity_at ?? row.created_at)}`,
|
|
href: `${base}/conversations/${row.id}`,
|
|
id: row.id,
|
|
kind: "conversation",
|
|
label: row.title ?? `${row.kind} · ${shortId(row.id)}`,
|
|
row,
|
|
}));
|
|
return needle
|
|
? [...conversations, ...sections]
|
|
: [...sections.slice(0, 4), ...conversations];
|
|
});
|
|
|
|
$effect(() => {
|
|
if (ui.palette) {
|
|
query = "";
|
|
cursor = 0;
|
|
queueMicrotask(() => input?.focus());
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (cursor >= items.length) {
|
|
cursor = Math.max(0, items.length - 1);
|
|
}
|
|
});
|
|
|
|
function go(item: Item) {
|
|
ui.closeAll();
|
|
goto(item.href);
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
cursor = Math.min(items.length - 1, cursor + 1);
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
cursor = Math.max(0, cursor - 1);
|
|
} else if (event.key === "Enter" && items[cursor]) {
|
|
event.preventDefault();
|
|
go(items[cursor]);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if ui.palette}
|
|
<div
|
|
aria-label="Go to"
|
|
aria-modal="true"
|
|
class="fixed inset-0 z-40 flex items-start justify-center px-4 pt-[12vh]"
|
|
role="dialog"
|
|
>
|
|
<button
|
|
aria-label="Close"
|
|
class="absolute inset-0 animate-fade-in bg-foreground/15 backdrop-blur-[2px]"
|
|
onclick={() => ui.closePalette()}
|
|
type="button"
|
|
></button>
|
|
<div
|
|
class="relative flex w-full max-w-lg animate-island-in flex-col overflow-hidden rounded-xl border bg-popover shadow-float"
|
|
>
|
|
<label class="flex items-center gap-2 border-b px-3">
|
|
<SearchIcon class="size-4 shrink-0 text-icon" />
|
|
<input
|
|
autocomplete="off"
|
|
class="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
onkeydown={onKeydown}
|
|
placeholder="Conversation, section…"
|
|
spellcheck="false"
|
|
type="text"
|
|
bind:this={input}
|
|
bind:value={query}
|
|
>
|
|
<kbd
|
|
class="rounded border px-1 font-sans text-[10px] text-muted-foreground"
|
|
>
|
|
esc
|
|
</kbd>
|
|
</label>
|
|
<div class="max-h-[50vh] overflow-y-auto py-1" role="listbox">
|
|
{#each items as item, index (item.id)}
|
|
<div
|
|
aria-selected={index === cursor}
|
|
class={cn(
|
|
"flex cursor-pointer items-center gap-2.5 px-3 py-2 text-sm",
|
|
index === cursor && "bg-accent"
|
|
)}
|
|
onclick={() => go(item)}
|
|
onkeydown={(event) => event.key === "Enter" && go(item)}
|
|
onmousemove={() => {
|
|
cursor = index;
|
|
}}
|
|
role="option"
|
|
tabindex="-1"
|
|
>
|
|
{#if item.row}
|
|
<KindMark kind={item.row.kind} />
|
|
{:else}
|
|
<span class="size-5 shrink-0"></span>
|
|
{/if}
|
|
<span class="min-w-0 flex-1 truncate">{clip(item.label, 80)}</span>
|
|
<span class="tabular shrink-0 text-muted-foreground text-xs">
|
|
{item.hint}
|
|
</span>
|
|
</div>
|
|
{:else}
|
|
<p class="px-3 py-3 text-muted-foreground text-sm">
|
|
Nothing matches.
|
|
</p>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|