import { Signal } from "@char/aftercare"; import { projectFor, type ProjectIndex } from "@berry/api/projects.ts"; import { moveGroup, projectEditor, sidebarOrder } from "./projects.tsx"; import { api, connected, watch } from "./client.ts"; import { conversationName } from "./conversation-name.ts"; import { conversation, draft, type ExtensionInfo, type Shared } from "./conversation.tsx"; import { newConversation } from "./new-conversation.tsx"; import { type ModelInfo, type ModelRef, settings } from "./settings.tsx"; import { syncRenderers } from "./renderers.ts"; import { computed, type Handle, keyed, relativeTime, Scope, swap, tidDate } from "./ui.ts"; import { announcements } from "./notifications.ts"; import { beforeNavigate } from "./navigation.ts"; type ConversationInfo = { id: number; created: number; title?: string; parent?: { tid: string; at: number }; owner?: string; branches?: { id: number; at: number; created: number }[]; head?: number; archived?: number; }; type Item = { tid: string; title?: string; depth: number; forks: number; agents: number; /** Whether this row is a background subagent, which reads as delegated rather than forked. */ subagent: boolean; open: boolean; archived: boolean; parentLink?: string; }; type Summary = { busy: boolean; preview: string; reply?: string; unread: boolean; updated: number; environment?: string; cwd?: string; }; const EMPTY: readonly never[] = []; /** A row of the sidebar: a conversation, where it sits in the list, and how it is doing. */ type Row = Item & { summary: Summary | null; active: boolean }; function row(first: Row, toggle: (tid: string) => void): Handle { const { tid } = first; let shown: Row | null = null; const chevron = ( {mobileArchive} ) as HTMLDetailsElement; const element = (
{chevron} {name.editor} {link} {parentLink} {archive} {actions}
) as HTMLElement; const update = (next: Row) => { const previous = shown; shown = next; parentLink.hidden = !next.parentLink; if (next.parentLink) parentLink.href = `#/${next.parentLink}`; else parentLink.removeAttribute("href"); element.className = `item${next.active ? " active" : ""}${ next.summary?.unread ? " unread" : "" }`; if (next.depth !== previous?.depth) element.style.setProperty("--depth", String(next.depth)); if ( next.forks !== previous?.forks || next.open !== previous.open || next.agents !== previous?.agents ) { chevron.disabled = next.forks === 0; chevron.setAttribute("aria-expanded", String(next.open)); chevron.title = `${next.forks} ${next.forks === 1 ? "fork" : "forks"}` + (next.agents > 0 ? `, ${next.agents} live ${next.agents === 1 ? "subagent" : "subagents"}` : ""); forks.textContent = next.open || next.forks === 0 ? "" : `+${next.forks}`; } if (next.summary !== previous?.summary || next.title !== previous?.title || previous === null) { preview.className = next.title || next.summary?.preview ? "preview" : "preview tid"; name.update(next.title, next.summary?.preview || tid); } const date = new Date(next.summary?.updated ?? tidDate(tid).getTime()); time.textContent = relativeTime(date); time.title = date.toLocaleString(); time.dateTime = date.toISOString(); if (next.summary !== previous?.summary) { busy.className = next.summary?.busy ? "busy on" : next.summary?.unread ? "busy unread" : "busy"; busy.title = next.summary?.busy ? "answering" : next.summary?.unread ? "unread" : ""; } if (next.archived !== previous?.archived) { archive.textContent = mobileArchive.textContent = next.archived ? "restore" : "archive"; } }; update(first); return { node: element, update, dispose: name.dispose }; } export function app(scope: Scope): Element { const shared: Shared = { extensions: new Signal(EMPTY), models: new Signal(EMPTY), enabled: new Signal(EMPTY), machines: new Signal(["local"]), projects: new Signal({}), refreshModels: () => { api("GET", "/api/models").then((list) => shared.models.set(list)).catch( console.error, ); }, }; scope.add( watch<{ models: ModelRef[]; environments?: Record }>("settings", (value) => { shared.enabled.set(value.models); shared.machines.set(["local", ...Object.keys(value.environments ?? {})]); }), ); // views wait for the extensions' frontends, and draw again when one changes: a card keeps the renderer it was // drawn with const frontends = new Signal<{ version: number } | null>(null); let syncing = Promise.resolve(); scope.add(watch("extensions", (list) => { syncing = syncing.then(async () => { const changed = await syncRenderers(list); shared.extensions.set(list); if (changed || frontends.get() === null) { frontends.set({ version: (frontends.get()?.version ?? 0) + 1 }); } }); })); shared.refreshModels(); // a conversation, or one of its extensions' views (`/`, then the view's own route); a draft // continuing it from one of its entries (`@`), or starting a new one from it (`/fork@`); // a new conversation (`new`, or `new/` in a project, `new/` in none); or settings const route = () => location.hash.match( /^#\/([a-z2-7]{13}(?:(?:\/fork)?@\d+|\/[^/@]+(?:\/.*)?)?|new(?:\/[^/]*)?|settings)$/, )?.[1] ?? ""; const VIEW = /^([a-z2-7]{13})\/([^/@]+(?:\/.*)?)$/; const selected = new Signal(route()); // the conversation stays put as its views open, close and move; it reads `viewing` once it is built, so first const viewing = computed(scope, [selected], () => selected.get().match(VIEW)?.[2] ?? ""); const page = computed(scope, [selected], () => selected.get().replace(VIEW, "$1")); let navigation = 0; const onHash = () => { const next = route(); const current = ++navigation; const apply = (allowed: boolean) => { if (current !== navigation) return; if (allowed) selected.set(next); else document.defaultView!.history.replaceState(null, "", `#/${selected.get()}`); }; const allowed = beforeNavigate(next); if (typeof allowed === "boolean") apply(allowed); else void allowed.then(apply, () => apply(false)); }; addEventListener("hashchange", onHash); scope.add(() => { navigation++; removeEventListener("hashchange", onHash); }); const selectedTid = () => selected.get().split(/[@/]/)[0]; /** The conversations the sidebar lists: every conversation, owned or not. */ let listed: Record = {}; let summaries: Record = {}; const projectOrder = new Signal([]); const editor = projectEditor(scope, shared.machines, shared.projects, projectOrder); // each list remembers its own folds, and the archive's projects start folded const folds = (key: string, folded: boolean) => { let flipped: Set; try { flipped = new Set(JSON.parse(localStorage.getItem(key) ?? "[]")); } catch { flipped = new Set(); } return { open: (id: string) => flipped.has(id) === folded, reveal: (id: string) => folded ? flipped.add(id) : flipped.delete(id), toggle: (id: string) => { if (!flipped.delete(id)) flipped.add(id); localStorage.setItem(key, JSON.stringify([...flipped])); }, }; }; const listFolds = folds("berry.collapsed-projects", false); const archiveFolds = folds("berry.expanded-archived-projects", true); const groupFor = (tid: string) => { const summary = summaries[tid]; return projectFor( shared.projects.get(), summary?.environment && summary.cwd ? { environment: summary.environment, cwd: summary.cwd } : undefined, ) ?? ""; }; // a background subagent sits under the conversation that owns it, as a fork under its parent const above = (tid: string) => listed[tid]?.parent?.tid ?? listed[tid]?.owner; const archived = (tid: string) => listed[tid].archived !== undefined; // in the archive, an archived conversation is a root unless one it was forked from is archived too const archivedAbove = (tid: string) => { for ( let up = listed[tid].parent?.tid; up && listed[up] && groupFor(up) === groupFor(tid); up = listed[up].parent?.tid ) { if (archived(up)) return true; } return false; }; let revealed = ""; // the list holds the conversations in use, or the archive: archiving one puts it away with the forks under it let archive = false; // forks nest under their parents, newest first; the way to the selected conversation opens itself const opened = new Set(); const reveal = () => { const group = groupFor(selectedTid()); if (listed[selectedTid()]) { const inArchive = archived(selectedTid()) || archivedAbove(selectedTid()); (inArchive ? archiveFolds : listFolds).reveal(group); } for (let tid = above(selectedTid()); tid && groupFor(tid) === group; tid = above(tid)) { opened.add(tid); } revealed = `${selectedTid()}:${group}`; }; const list =
; // the group whose heading is being dragged to a new place in the order let dragging: string | undefined; // a row's menu closes once the reader is elsewhere, another row's menu included const leaveMenus = (event: Event) => { for (const menu of list.querySelectorAll(".conversation-actions[open]")) { if (!menu.contains(event.target as Node)) menu.open = false; } }; for (const type of ["pointerdown", "focusin"]) document.addEventListener(type, leaveMenus); scope.add(() => { for (const type of ["pointerdown", "focusin"]) document.removeEventListener(type, leaveMenus); }); // where a narrow screen, which shows the list or a view but not both, goes back to the list const back = ; type Group = { kind: "group"; id: string; name: string; open: boolean; count: number; unread: boolean; busy: boolean; }; type SidebarItem = Group | (Row & { kind: "conversation" }); const rows = keyed( list, (item) => item.kind === "group" ? `group:${item.id}` : item.tid, (item) => { if (item.kind === "conversation") { const handle = row(item, toggle); return { node: handle.node, dispose: handle.dispose, update(value: SidebarItem) { if (value.kind === "conversation") handle.update?.(value); }, }; } const name = as HTMLElement; const count = as HTMLElement; const activity = as HTMLElement; const toggleGroup = ( ) as HTMLButtonElement; toggleGroup.draggable = true; const add = ( ) as HTMLButtonElement; const edit = ( ) as HTMLButtonElement; const node = (
{toggleGroup} {add} {edit}
) as HTMLElement; node.ondragstart = (event) => { dragging = item.id; event.dataTransfer?.setData("text/plain", name.textContent!); }; node.ondragend = () => dragging = undefined; node.ondragover = (event) => { if (dragging === undefined || dragging === item.id) return; event.preventDefault(); // the dragged group takes this one's place, so lands after it when moving down const ids = sidebarOrder(shared.projects.get(), projectOrder.get()); node.dataset.drop = ids.indexOf(dragging) < ids.indexOf(item.id) ? "after" : "before"; }; node.ondragleave = () => delete node.dataset.drop; node.ondrop = (event) => { event.preventDefault(); delete node.dataset.drop; const projects = shared.projects.get(); const at = sidebarOrder(projects, projectOrder.get()).indexOf(item.id); moveGroup(projects, projectOrder, dragging!, at).catch((error) => alert(error.message)); }; const update = (value: SidebarItem) => { if (value.kind !== "group") return; name.textContent = value.name; count.textContent = value.count ? String(value.count) : ""; toggleGroup.setAttribute("aria-expanded", String(value.open)); // the rows say how each is doing while they show activity.className = value.open ? "busy" : value.busy ? "busy on" : value.unread ? "busy unread" : "busy"; activity.title = value.open ? "" : value.busy ? "answering" : value.unread ? "unread" : ""; add.setAttribute("aria-label", `New conversation in ${value.name}`); edit.setAttribute("aria-label", `Edit ${value.name}`); }; update(item); return { node, update }; }, ); const archiveToggle = (
{back} {main} {editor.element}
); }