char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somthe queue lists what it holds in the order the agent will receive it: steers before follow-upsd17332c

main
5.0 KiB144 linesraw
1import type { InboxItem } from "@earendil-works/pi-durable";
2import { imageSummary } from "@berry/api/images.ts";
3import { api } from "./client.ts";
4import { json, keyed, type Scope } from "./ui.ts";
5import { event } from "./event.tsx";
6
7// the order the harness places them in: every write at the next boundary, a steer at each boundary from there, and
8// the follow-ups once the run ends, each in the order they were queued
9const PLACED: Record<InboxItem["mode"], number> = { write: 0, steer: 1, followUp: 2 };
10
11/** Queued submissions retain their editors until the submission leaves the inbox. */
12export function inbox(scope: Scope, tid: string) {
13  const element = <div class="inbox" /> as HTMLElement;
14  const rows = keyed(element, (item: InboxItem) => item.id, (initial) => {
15    const mode = (
16      <span class="mode">
17        {initial.mode === "write"
18          ? "pending"
19          : initial.mode === "followUp"
20          ? "follow-up"
21          : initial.mode}
22      </span>
23    );
24    if (initial.mode === "write") {
25      const content = initial.entry.kind === "berry.event" ? event(initial.entry.data) : (
26        <details>
27          <summary>{typeof initial.entry.kind === "string" ? initial.entry.kind : "event"}</summary>
28          <pre>{json(initial.entry)}</pre>
29        </details>
30      );
31      return {
32        node: <div class="queued" hidden={content === null}>{mode} {content ?? undefined}</div>,
33      };
34    }
35
36    let item = initial;
37    let editing = false;
38    let pending = false;
39    let disposed = false;
40    const content = <span class="queued-content" /> as HTMLElement;
41    const images = <span class="queued-images" /> as HTMLElement;
42    const error = <div class="queued-error" role="alert" hidden /> as HTMLElement;
43    const textarea = <textarea aria-label="Queued message text" rows={3} /> as HTMLTextAreaElement;
44    const edit = <button type="button">Edit</button> as HTMLButtonElement;
45    const retract = <button type="button" class="danger">Retract</button> as HTMLButtonElement;
46    const save = <button type="submit" class="primary">Save</button> as HTMLButtonElement;
47    const cancel = <button type="button">Cancel</button> as HTMLButtonElement;
48    const actions = <div class="queued-actions">{edit}{retract}</div> as HTMLElement;
49    const form = (
50      <form class="queued-editor" hidden>
51        {textarea}
52        <div class="queued-actions">{save}{cancel}</div>
53      </form>
54    ) as HTMLFormElement;
55    const node = (
56      <div class="queued">
57        <div>{mode} {content} {images}</div>
58        {actions}
59        {form}
60        {error}
61      </div>
62    );
63    const text = () =>
64      typeof item.content === "string"
65        ? item.content
66        : item.content.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n");
67    const render = () => {
68      content.textContent = text();
69      content.hidden = editing;
70      images.textContent = imageSummary(item.content);
71      actions.hidden = editing;
72      form.hidden = !editing;
73      textarea.readOnly = pending;
74      for (const button of [edit, retract, save, cancel]) button.disabled = pending;
75      save.disabled ||= !textarea.value.trim() && !imageSummary(item.content);
76    };
77    const mutate = async (method: "PATCH" | "DELETE") => {
78      if (disposed || pending) return;
79      if (method === "PATCH" && !textarea.value.trim() && !imageSummary(item.content)) return;
80      pending = true;
81      error.hidden = true;
82      render();
83      try {
84        await api(
85          method,
86          `/api/conversations/${tid}/inbox/${item.id}`,
87          method === "PATCH" ? { text: textarea.value } : undefined,
88        );
89        if (disposed) return;
90        // snapshots own content and removal; a late response must not restore a consumed submission
91        editing = false;
92      } catch (cause) {
93        if (disposed) return;
94        error.textContent = cause instanceof Error ? cause.message : String(cause);
95        error.hidden = false;
96      } finally {
97        if (!disposed) {
98          pending = false;
99          render();
100        }
101      }
102    };
103    edit.addEventListener("click", () => {
104      if (pending || disposed) return;
105      editing = true;
106      textarea.value = text();
107      error.hidden = true;
108      render();
109      textarea.focus();
110    });
111    cancel.addEventListener("click", () => {
112      if (pending || disposed) return;
113      editing = false;
114      textarea.value = text();
115      error.hidden = true;
116      render();
117      edit.focus();
118    });
119    textarea.addEventListener("input", render);
120    form.addEventListener("submit", (event) => {
121      event.preventDefault();
122      void mutate("PATCH");
123    });
124    retract.addEventListener("click", () => void mutate("DELETE"));
125    render();
126    return {
127      node,
128      update(next: InboxItem) {
129        if (next.mode === "write") return;
130        item = next;
131        render();
132      },
133      dispose() {
134        disposed = true;
135      },
136    };
137  });
138  scope.add(rows.dispose);
139  return {
140    element,
141    update: (items: readonly InboxItem[]) =>
142      rows.sync([...items].sort((a, b) => PLACED[a.mode] - PLACED[b.mode])),
143  };
144}