char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somallow project reorderingb4fc6cf

main
23.0 KiB659 linesraw
1import { Signal } from "@char/aftercare";
2import { projectFor, type ProjectIndex } from "@berry/api/projects.ts";
3import { moveGroup, projectEditor, sidebarOrder } from "./projects.tsx";
4import { api, connected, watch } from "./client.ts";
5import { conversationName } from "./conversation-name.ts";
6import { conversation, draft, type ExtensionInfo, type Shared } from "./conversation.tsx";
7import { newConversation } from "./new-conversation.tsx";
8import { type ModelInfo, type ModelRef, settings } from "./settings.tsx";
9import { syncRenderers } from "./renderers.ts";
10import { computed, type Handle, keyed, relativeTime, Scope, swap, tidDate } from "./ui.ts";
11import { announcements } from "./notifications.ts";
12import { beforeNavigate } from "./navigation.ts";
13
14type ConversationInfo = {
15  id: number;
16  created: number;
17  title?: string;
18  parent?: { tid: string; at: number };
19  owner?: string;
20  branches?: { id: number; at: number; created: number }[];
21  head?: number;
22  archived?: number;
23};
24type Item = {
25  tid: string;
26  title?: string;
27  depth: number;
28  forks: number;
29  agents: number;
30  /** Whether this row is a background subagent, which reads as delegated rather than forked. */
31  subagent: boolean;
32  open: boolean;
33  archived: boolean;
34  parentLink?: string;
35};
36type Summary = {
37  busy: boolean;
38  preview: string;
39  reply?: string;
40  unread: boolean;
41  updated: number;
42  environment?: string;
43  cwd?: string;
44};
45
46const EMPTY: readonly never[] = [];
47/** A row of the sidebar: a conversation, where it sits in the list, and how it is doing. */
48type Row = Item & { summary: Summary | null; active: boolean };
49
50function row(first: Row, toggle: (tid: string) => void): Handle<Row> {
51  const { tid } = first;
52  let shown: Row | null = null;
53  const chevron = (
54    <button
55      type="button"
56      class="chevron"
57      _onclick={() => toggle(tid)}
58    />
59  ) as HTMLButtonElement;
60  const preview = <span title="Double-click to rename" /> as HTMLElement;
61  const forks = <span class="forks" /> as HTMLElement;
62  const busy = <span /> as HTMLElement;
63  const time = <time /> as HTMLTimeElement;
64  const parentLink = (
65    <a class="parent-link" aria-label="Parent conversation" title="parent conversation" hidden>↖</a>
66  ) as HTMLAnchorElement;
67  const link = (
68    <a href={`#/${tid}`} title={tid}>
69      {first.subagent
70        ? <span class="delegated" role="img" aria-label="subagent" title="subagent">↳</span>
71        : undefined}
72      {preview}
73      {forks}
74      {busy}
75      {time}
76    </a>
77  ) as HTMLAnchorElement;
78  const name = conversationName(
79    link,
80    preview,
81    (title) => api("PATCH", `/api/conversations/${tid}`, { title }),
82  );
83  const changeArchived = () => {
84    actions.open = false;
85    void api("PATCH", `/api/conversations/${tid}`, { archived: !shown!.archived }).catch(
86      console.error,
87    );
88  };
89  const archive = (
90    <button
91      type="button"
92      class="archive"
93      _onclick={changeArchived}
94    />
95  ) as HTMLButtonElement;
96  const mobileArchive = (
97    <button
98      type="button"
99      class="link"
100      _onclick={changeArchived}
101    />
102  ) as HTMLButtonElement;
103  const actions = (
104    <details
105      class="conversation-actions"
106      _onkeydown={(event) => {
107        if (event.key === "Escape") {
108          actions.open = false;
109          actions.querySelector("summary")!.focus();
110        }
111      }}
112    >
113      <summary aria-label="Conversation actions" title="Conversation actions">⋯</summary>
114      <div class="conversation-menu">
115        <button
116          type="button"
117          class="link"
118          _onclick={() => {
119            actions.open = false;
120            name.start();
121          }}
122        >
123          rename
124        </button>
125        {mobileArchive}
126      </div>
127    </details>
128  ) as HTMLDetailsElement;
129  const element = (
130    <div>
131      {chevron}
132      {name.editor}
133      {link}
134      {parentLink}
135      {archive}
136      {actions}
137    </div>
138  ) as HTMLElement;
139  const update = (next: Row) => {
140    const previous = shown;
141    shown = next;
142    parentLink.hidden = !next.parentLink;
143    if (next.parentLink) parentLink.href = `#/${next.parentLink}`;
144    else parentLink.removeAttribute("href");
145    element.className = `item${next.active ? " active" : ""}${
146      next.summary?.unread ? " unread" : ""
147    }`;
148    if (next.depth !== previous?.depth) element.style.setProperty("--depth", String(next.depth));
149    if (
150      next.forks !== previous?.forks || next.open !== previous.open ||
151      next.agents !== previous?.agents
152    ) {
153      chevron.disabled = next.forks === 0;
154      chevron.setAttribute("aria-expanded", String(next.open));
155      chevron.title = `${next.forks} ${next.forks === 1 ? "fork" : "forks"}` +
156        (next.agents > 0
157          ? `, ${next.agents} live ${next.agents === 1 ? "subagent" : "subagents"}`
158          : "");
159      forks.textContent = next.open || next.forks === 0 ? "" : `+${next.forks}`;
160    }
161    if (next.summary !== previous?.summary || next.title !== previous?.title || previous === null) {
162      preview.className = next.title || next.summary?.preview ? "preview" : "preview tid";
163      name.update(next.title, next.summary?.preview || tid);
164    }
165    const date = new Date(next.summary?.updated ?? tidDate(tid).getTime());
166    time.textContent = relativeTime(date);
167    time.title = date.toLocaleString();
168    time.dateTime = date.toISOString();
169    if (next.summary !== previous?.summary) {
170      busy.className = next.summary?.busy
171        ? "busy on"
172        : next.summary?.unread
173        ? "busy unread"
174        : "busy";
175      busy.title = next.summary?.busy ? "answering" : next.summary?.unread ? "unread" : "";
176    }
177    if (next.archived !== previous?.archived) {
178      archive.textContent = mobileArchive.textContent = next.archived ? "restore" : "archive";
179    }
180  };
181  update(first);
182  return { node: element, update, dispose: name.dispose };
183}
184
185export function app(scope: Scope): Element {
186  const shared: Shared = {
187    extensions: new Signal<readonly ExtensionInfo[]>(EMPTY),
188    models: new Signal<readonly ModelInfo[]>(EMPTY),
189    enabled: new Signal<readonly ModelRef[]>(EMPTY),
190    machines: new Signal<readonly string[]>(["local"]),
191    projects: new Signal<ProjectIndex>({}),
192    refreshModels: () => {
193      api<ModelInfo[]>("GET", "/api/models").then((list) => shared.models.set(list)).catch(
194        console.error,
195      );
196    },
197  };
198  scope.add(
199    watch<{ models: ModelRef[]; environments?: Record<string, unknown> }>("settings", (value) => {
200      shared.enabled.set(value.models);
201      shared.machines.set(["local", ...Object.keys(value.environments ?? {})]);
202    }),
203  );
204  // views wait for the extensions' frontends, and draw again when one changes: a card keeps the renderer it was
205  // drawn with
206  const frontends = new Signal<{ version: number } | null>(null);
207  let syncing = Promise.resolve();
208  scope.add(watch<ExtensionInfo[]>("extensions", (list) => {
209    syncing = syncing.then(async () => {
210      const changed = await syncRenderers(list);
211      shared.extensions.set(list);
212      if (changed || frontends.get() === null) {
213        frontends.set({ version: (frontends.get()?.version ?? 0) + 1 });
214      }
215    });
216  }));
217  shared.refreshModels();
218
219  // a conversation, or one of its extensions' views (`<tid>/<extension>`, then the view's own route); a draft
220  // continuing it from one of its entries (`<tid>@<entry>`), or starting a new one from it (`<tid>/fork@<entry>`);
221  // a new conversation (`new`, or `new/<project>` in a project, `new/` in none); or settings
222  const route = () =>
223    location.hash.match(
224      /^#\/([a-z2-7]{13}(?:(?:\/fork)?@\d+|\/[^/@]+(?:\/.*)?)?|new(?:\/[^/]*)?|settings)$/,
225    )?.[1] ?? "";
226  const VIEW = /^([a-z2-7]{13})\/([^/@]+(?:\/.*)?)$/;
227  const selected = new Signal(route());
228  // the conversation stays put as its views open, close and move; it reads `viewing` once it is built, so first
229  const viewing = computed(scope, [selected], () => selected.get().match(VIEW)?.[2] ?? "");
230  const page = computed(scope, [selected], () => selected.get().replace(VIEW, "$1"));
231  let navigation = 0;
232  const onHash = () => {
233    const next = route();
234    const current = ++navigation;
235    const apply = (allowed: boolean) => {
236      if (current !== navigation) return;
237      if (allowed) selected.set(next);
238      else document.defaultView!.history.replaceState(null, "", `#/${selected.get()}`);
239    };
240    const allowed = beforeNavigate(next);
241    if (typeof allowed === "boolean") apply(allowed);
242    else void allowed.then(apply, () => apply(false));
243  };
244  addEventListener("hashchange", onHash);
245  scope.add(() => {
246    navigation++;
247    removeEventListener("hashchange", onHash);
248  });
249  const selectedTid = () => selected.get().split(/[@/]/)[0];
250
251  /** The conversations the sidebar lists: every conversation, owned or not. */
252  let listed: Record<string, ConversationInfo> = {};
253  let summaries: Record<string, Summary> = {};
254  const projectOrder = new Signal<readonly string[]>([]);
255  const editor = projectEditor(scope, shared.machines, shared.projects, projectOrder);
256  // each list remembers its own folds, and the archive's projects start folded
257  const folds = (key: string, folded: boolean) => {
258    let flipped: Set<string>;
259    try {
260      flipped = new Set(JSON.parse(localStorage.getItem(key) ?? "[]"));
261    } catch {
262      flipped = new Set();
263    }
264    return {
265      open: (id: string) => flipped.has(id) === folded,
266      reveal: (id: string) => folded ? flipped.add(id) : flipped.delete(id),
267      toggle: (id: string) => {
268        if (!flipped.delete(id)) flipped.add(id);
269        localStorage.setItem(key, JSON.stringify([...flipped]));
270      },
271    };
272  };
273  const listFolds = folds("berry.collapsed-projects", false);
274  const archiveFolds = folds("berry.expanded-archived-projects", true);
275  const groupFor = (tid: string) => {
276    const summary = summaries[tid];
277    return projectFor(
278      shared.projects.get(),
279      summary?.environment && summary.cwd
280        ? { environment: summary.environment, cwd: summary.cwd }
281        : undefined,
282    ) ?? "";
283  };
284  // a background subagent sits under the conversation that owns it, as a fork under its parent
285  const above = (tid: string) => listed[tid]?.parent?.tid ?? listed[tid]?.owner;
286  const archived = (tid: string) => listed[tid].archived !== undefined;
287  // in the archive, an archived conversation is a root unless one it was forked from is archived too
288  const archivedAbove = (tid: string) => {
289    for (
290      let up = listed[tid].parent?.tid;
291      up && listed[up] && groupFor(up) === groupFor(tid);
292      up = listed[up].parent?.tid
293    ) {
294      if (archived(up)) return true;
295    }
296    return false;
297  };
298  let revealed = "";
299  // the list holds the conversations in use, or the archive: archiving one puts it away with the forks under it
300  let archive = false;
301  // forks nest under their parents, newest first; the way to the selected conversation opens itself
302  const opened = new Set<string>();
303  const reveal = () => {
304    const group = groupFor(selectedTid());
305    if (listed[selectedTid()]) {
306      const inArchive = archived(selectedTid()) || archivedAbove(selectedTid());
307      (inArchive ? archiveFolds : listFolds).reveal(group);
308    }
309    for (let tid = above(selectedTid()); tid && groupFor(tid) === group; tid = above(tid)) {
310      opened.add(tid);
311    }
312    revealed = `${selectedTid()}:${group}`;
313  };
314
315  const list = <div class="conversations" />;
316  // the group whose heading is being dragged to a new place in the order
317  let dragging: string | undefined;
318  // a row's menu closes once the reader is elsewhere, another row's menu included
319  const leaveMenus = (event: Event) => {
320    for (const menu of list.querySelectorAll<HTMLDetailsElement>(".conversation-actions[open]")) {
321      if (!menu.contains(event.target as Node)) menu.open = false;
322    }
323  };
324  for (const type of ["pointerdown", "focusin"]) document.addEventListener(type, leaveMenus);
325  scope.add(() => {
326    for (const type of ["pointerdown", "focusin"]) document.removeEventListener(type, leaveMenus);
327  });
328  // where a narrow screen, which shows the list or a view but not both, goes back to the list
329  const back = <a href="#/" class="back" title="conversations" aria-label="conversations" />;
330  type Group = {
331    kind: "group";
332    id: string;
333    name: string;
334    open: boolean;
335    count: number;
336    unread: boolean;
337    busy: boolean;
338  };
339  type SidebarItem = Group | (Row & { kind: "conversation" });
340  const rows = keyed<SidebarItem, string>(
341    list,
342    (item) => item.kind === "group" ? `group:${item.id}` : item.tid,
343    (item) => {
344      if (item.kind === "conversation") {
345        const handle = row(item, toggle);
346        return {
347          node: handle.node,
348          dispose: handle.dispose,
349          update(value: SidebarItem) {
350            if (value.kind === "conversation") handle.update?.(value);
351          },
352        };
353      }
354      const name = <span class="project-name" /> as HTMLElement;
355      const count = <span class="project-count" /> as HTMLElement;
356      const activity = <span /> as HTMLElement;
357      const toggleGroup = (
358        <button
359          type="button"
360          class="project-toggle"
361          _onclick={() => {
362            (archive ? archiveFolds : listFolds).toggle(item.id);
363            draw();
364          }}
365        >
366          {name}
367          {count}
368          {activity}
369        </button>
370      ) as HTMLButtonElement;
371      toggleGroup.draggable = true;
372      const add = (
373        <button
374          type="button"
375          class="link project-action"
376          title="new conversation"
377          _onclick={() => location.hash = `#/new/${item.id}`}
378        >
379          +
380        </button>
381      ) as HTMLButtonElement;
382      const edit = (
383        <button
384          type="button"
385          class="link project-action"
386          title="edit project"
387          _onclick={() => editor.open(item.id)}
388        >
389          ⋯
390        </button>
391      ) as HTMLButtonElement;
392      const node = (
393        <div class="project-group" data-project={item.id}>
394          {toggleGroup}
395          {add}
396          {edit}
397        </div>
398      ) as HTMLElement;
399      node.ondragstart = (event) => {
400        dragging = item.id;
401        event.dataTransfer?.setData("text/plain", name.textContent!);
402      };
403      node.ondragend = () => dragging = undefined;
404      node.ondragover = (event) => {
405        if (dragging === undefined || dragging === item.id) return;
406        event.preventDefault();
407        // the dragged group takes this one's place, so lands after it when moving down
408        const ids = sidebarOrder(shared.projects.get(), projectOrder.get());
409        node.dataset.drop = ids.indexOf(dragging) < ids.indexOf(item.id) ? "after" : "before";
410      };
411      node.ondragleave = () => delete node.dataset.drop;
412      node.ondrop = (event) => {
413        event.preventDefault();
414        delete node.dataset.drop;
415        const projects = shared.projects.get();
416        const at = sidebarOrder(projects, projectOrder.get()).indexOf(item.id);
417        moveGroup(projects, projectOrder, dragging!, at).catch((error) => alert(error.message));
418      };
419      const update = (value: SidebarItem) => {
420        if (value.kind !== "group") return;
421        name.textContent = value.name;
422        count.textContent = value.count ? String(value.count) : "";
423        toggleGroup.setAttribute("aria-expanded", String(value.open));
424        // the rows say how each is doing while they show
425        activity.className = value.open
426          ? "busy"
427          : value.busy
428          ? "busy on"
429          : value.unread
430          ? "busy unread"
431          : "busy";
432        activity.title = value.open ? "" : value.busy ? "answering" : value.unread ? "unread" : "";
433        add.setAttribute("aria-label", `New conversation in ${value.name}`);
434        edit.setAttribute("aria-label", `Edit ${value.name}`);
435      };
436      update(item);
437      return { node, update };
438    },
439  );
440  const archiveToggle = (
441    <button
442      type="button"
443      class="link archive-toggle"
444      _onclick={() => {
445        archive = !archive;
446        draw();
447      }}
448    />
449  ) as HTMLButtonElement;
450  function draw() {
451    if (revealed !== `${selectedTid()}:${groupFor(selectedTid())}`) reveal();
452    const owned = (tid: string) => listed[tid].owner !== undefined;
453    // a subagent shows only while it works, and never in the archive or a group's count
454    const ordered = Object.keys(listed)
455      .filter((tid) => !owned(tid) || (!archive && summaries[tid]?.busy && !archived(tid)))
456      .sort((a, b) =>
457        (summaries[b]?.updated ?? listed[b].created) -
458          (summaries[a]?.updated ?? listed[a].created) || b.localeCompare(a)
459      );
460    const children = Map.groupBy(ordered, (tid) => {
461      const up = above(tid);
462      return up && listed[up] && groupFor(up) === groupFor(tid) ? up : null;
463    });
464    const active = selectedTid();
465    const items: SidebarItem[] = [];
466    const visit = (tid: string, depth: number) => {
467      const below = children.get(tid) ?? [];
468      const forks = below.filter((fork) => !owned(fork) && (archive || !archived(fork)));
469      const agents = below.filter(owned);
470      const open = opened.has(tid);
471      const up = above(tid);
472      items.push({
473        kind: "conversation",
474        tid,
475        parentLink: up && listed[up] && groupFor(up) !== groupFor(tid) ? up : undefined,
476        title: listed[tid].title,
477        depth,
478        forks: forks.length,
479        agents: agents.length,
480        subagent: owned(tid),
481        open,
482        archived: archived(tid),
483        summary: summaries[tid] ?? null,
484        active: tid === active,
485      });
486      if (open) { for (const fork of forks) visit(fork, depth + 1); }
487      // a subagent shows whether or not the forks beside it are open
488      for (const agent of agents) visit(agent, depth + 1);
489    };
490    const roots = archive
491      ? ordered.filter((tid) => archived(tid) && !archivedAbove(tid))
492      : (children.get(null) ?? []).filter((tid) => !archived(tid));
493    const projects = shared.projects.get();
494    // without projects, the list is as it always was: no headings, nothing to fold
495    const headed = Object.keys(projects).length > 0;
496    for (const id of sidebarOrder(projects, projectOrder.get())) {
497      const group = { id, name: id === "" ? "no project" : projects[id].name };
498      const members = ordered.filter((tid) =>
499        !owned(tid) && groupFor(tid) === group.id &&
500        (archive === (archived(tid) || archivedAbove(tid)))
501      );
502      // an empty project stays, to be started in; the archive shows only what it holds
503      if (members.length === 0 && (archive || group.id === "")) continue;
504      const open = !headed || (archive ? archiveFolds : listFolds).open(group.id);
505      if (headed) {
506        items.push({
507          kind: "group",
508          ...group,
509          open,
510          count: members.length,
511          busy: members.some((tid) => summaries[tid]?.busy),
512          unread: members.some((tid) => summaries[tid]?.unread),
513        });
514      }
515      if (open) {
516        for (const tid of roots.filter((tid) => groupFor(tid) === group.id)) visit(tid, 0);
517      }
518    }
519    rows.sync(items);
520    list.toggleAttribute("data-archive", archive);
521    back.classList.toggle(
522      "unread",
523      Object.keys(listed).some((tid) => tid !== active && summaries[tid]?.unread),
524    );
525
526    const archivedCount =
527      Object.entries(listed).filter(([tid, info]) => info.archived !== undefined && !owned(tid))
528        .length;
529    archiveToggle.hidden = !archive && archivedCount === 0;
530    archiveToggle.textContent = archive ? "back to conversations" : `archived (${archivedCount})`;
531  }
532  function toggle(tid: string) {
533    if (!opened.delete(tid)) opened.add(tid);
534    draw();
535  }
536  scope.add(watch<Record<string, ConversationInfo>>("conversations", (index) => {
537    listed = { ...index };
538    reveal();
539    draw();
540  }));
541  const announcing = announcements({
542    active: selectedTid,
543    name: (tid) => listed[tid]?.title ?? summaries[tid]?.preview ?? "",
544  });
545  scope.add(watch<Record<string, Summary>>("summaries", (value) => {
546    summaries = value;
547    announcing(value);
548    draw();
549  }));
550  scope.add(
551    selected.subscribe(() => {
552      reveal();
553      draw();
554    }).unsubscribe,
555  );
556  scope.add(rows.dispose);
557  scope.add(watch<ProjectIndex>("projects", (value) => shared.projects.set(value)));
558  scope.add(shared.projects.subscribe(draw).unsubscribe);
559  scope.add(watch<{ ids: string[] }>("project-order", (value) => projectOrder.set(value.ids)));
560  scope.add(projectOrder.subscribe(draw).unsubscribe);
561  const clock = setInterval(draw, 60_000);
562  scope.add(() => clearInterval(clock));
563  draw();
564
565  const main = <div class="view" />;
566  // each view brings its bar and its pane, which take their own cells of the app's grid
567  swap(
568    scope,
569    main,
570    computed(
571      scope,
572      [page, frontends],
573      () => (frontends.get() ? { route: page.get() } : null),
574    ),
575    (view, viewScope) =>
576      view === null ? [<header class="bar" />, <div class="pane empty" />] : view.route === ""
577        ? [
578          <header class="bar" />,
579          <div class="pane empty">
580            <p>
581              <a href="#/new">start a conversation</a>, or pick one
582            </p>
583          </div>,
584        ]
585        : view.route === "settings"
586        ? settings(viewScope, shared)
587        : view.route === "new" || view.route.startsWith("new/")
588        ? newConversation(viewScope, shared, view.route === "new" ? null : view.route.slice(4))
589        : view.route.includes("@")
590        ? draft(
591          viewScope,
592          view.route.split(/[@/]/)[0],
593          Number(view.route.split("@")[1]),
594          view.route.includes("/fork"),
595        )
596        : conversation(viewScope, view.route, shared, viewing),
597  );
598
599  const dot = <span class="dot" title="connection" /> as HTMLElement;
600  scope.add(
601    connected.subscribeImmediate((up) => (dot.className = up ? "dot up" : "dot")).unsubscribe,
602  );
603
604  return (
605    <div
606      class={selected.derive((route) =>
607        route === "" ? "app listing" : VIEW.test(route) ? "app viewing" : "app"
608      )}
609    >
610      <header class="brand-bar">
611        {dot}
612        <span class="brand">berry</span>
613        <a
614          href="#/settings"
615          class={selected.derive((current) => (current === "settings" ? "active" : ""))}
616        >
617          settings
618        </a>
619        <button
620          type="button"
621          class="link"
622          title="restart berry, interrupting then auto-restarting conversation turns"
623          _onclick={async function () {
624            this.disabled = true;
625            try {
626              await api("POST", "/api/restart");
627            } catch (error) {
628              alert(error instanceof Error ? error.message : String(error));
629            } finally {
630              this.disabled = false;
631            }
632          }}
633        >
634          restart
635        </button>
636      </header>
637      <nav class="sidebar">
638        <a
639          href="#/new"
640          class={selected.derive((current) =>
641            current === "new" || current.startsWith("new/") ? "new active" : "new"
642          )}
643        >
644          new conversation
645        </a>
646        {list}
647        <footer class="sidebar-footer">
648          <button type="button" class="link" _onclick={() => editor.open()}>
649            new project
650          </button>
651          {archiveToggle}
652        </footer>
653      </nav>
654      {back}
655      {main}
656      {editor.element}
657    </div>
658  );
659}