char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somfold all projects by default in archived conversations lista6b9955

main
14.8 KiB340 linesraw
1import { assert, assertEquals, assertStringIncludes } from "@std/assert";
2import { press } from "@berry/util/dom.ts";
3import { withBerry } from "./testing.ts";
4import { tidDate } from "./ui.ts";
5
6Deno.test("project counts exclude archived subtrees from the main list and include their forks in the archive", async () => {
7  await withBerry(async ({ server, scope }) => {
8    const { app } = await import("./app.tsx");
9    document.body.append(app(scope));
10    const active = "aaaaaaaaaaaaa";
11    const collapsedFork = "bbbbbbbbbbbbb";
12    const archived = "ccccccccccccc";
13    const hiddenFork = "ddddddddddddd";
14    const nestedFork = "eeeeeeeeeeeee";
15    const crossProjectFork = "fffffffffffff";
16    const index = {
17      [active]: { id: 1, created: 1 },
18      [collapsedFork]: { id: 2, created: 2, parent: { tid: active, at: 1 } },
19      [archived]: { id: 3, created: 3, archived: 4 },
20      [hiddenFork]: { id: 4, created: 4, parent: { tid: archived, at: 1 } },
21      [nestedFork]: { id: 5, created: 5, parent: { tid: hiddenFork, at: 1 } },
22      [crossProjectFork]: { id: 6, created: 6, parent: { tid: archived, at: 1 } },
23    };
24    server.snapshot("projects", {
25      berry: {
26        name: "Berry",
27        defaultRoot: 0,
28        roots: [{ environment: "local", cwd: "/work/berry", platform: "linux" }],
29      },
30      other: {
31        name: "Other",
32        defaultRoot: 0,
33        roots: [{ environment: "local", cwd: "/work/other", platform: "linux" }],
34      },
35    });
36    server.snapshot(
37      "summaries",
38      Object.fromEntries(
39        Object.keys(index).map((tid) => [tid, {
40          environment: "local",
41          cwd: tid === crossProjectFork ? "/work/other" : "/work/berry",
42          busy: tid === nestedFork,
43          unread: tid === nestedFork,
44          updated: 1,
45          preview: "hello",
46        }]),
47      ),
48    );
49    server.snapshot("conversations", index);
50    const count = () =>
51      document.querySelector('[data-project="berry"] .project-count')!.textContent;
52    const listed = () =>
53      [...document.querySelectorAll(".conversations .item > a[href]:not(.parent-link)")].map((a) =>
54        a.getAttribute("href")
55      );
56    const expand = (tid: string) =>
57      document.querySelector(`.conversations a[href="#/${tid}"]`)!.parentElement!
58        .querySelector<HTMLButtonElement>(".chevron")!.click();
59    assertEquals(count(), "2");
60    assertEquals(listed(), [`#/${active}`, `#/${crossProjectFork}`]);
61    assertEquals(document.querySelector('[data-project="other"] .project-count')!.textContent, "1");
62    expand(active);
63    assertEquals(count(), "2");
64    assertEquals(listed(), [`#/${active}`, `#/${collapsedFork}`, `#/${crossProjectFork}`]);
65    document.querySelector<HTMLButtonElement>('[data-project="berry"] .project-toggle')!.click();
66    assertEquals(document.querySelector('[data-project="berry"] [title="answering"]'), null);
67    assertEquals(document.querySelector('[data-project="berry"] [title="unread"]'), null);
68    document.querySelector<HTMLButtonElement>(".archive-toggle")!.click();
69    assertEquals(count(), "3");
70    assertEquals(document.querySelector('[data-project="other"]'), null);
71    // the archive's projects start folded, apart from how the main list folds them
72    assertEquals(listed(), []);
73    document.querySelector<HTMLButtonElement>('[data-project="berry"] .project-toggle')!.click();
74    assertEquals(listed(), [`#/${archived}`]);
75    expand(archived);
76    expand(hiddenFork);
77    assertEquals(listed(), [`#/${archived}`, `#/${hiddenFork}`, `#/${nestedFork}`]);
78    document.querySelector<HTMLButtonElement>(".archive-toggle")!.click();
79    assertEquals(listed(), [`#/${crossProjectFork}`]);
80  });
81});
82
83Deno.test("sidebar follows latest message times while keeping forks nested", async () => {
84  const now = new Date(2025, 5, 1, 12).getTime();
85  const originalNow = Date.now;
86  Date.now = () => now;
87  let clockCleared = false;
88  const clock = {
89    setInterval: () => 1,
90    clearInterval: () => {
91      clockCleared = true;
92    },
93  };
94  try {
95    await withBerry(async ({ window, server, scope, settle }) => {
96      const { app } = await import("./app.tsx");
97      document.body.append(app(scope));
98      const older = "aaaaaaaaaaaaa";
99      const newer = "bbbbbbbbbbbbb";
100      const created = new Date(2020, 0, 2).getTime();
101      server.snapshot("conversations", {
102        [older]: { id: 1, created },
103        [newer]: { id: 2, created: created + 1 },
104      });
105      const times = () => [...document.querySelectorAll<HTMLTimeElement>(".conversations time")];
106      const order = () =>
107        [...document.querySelectorAll(".conversations .item > a[href]")].map((a) =>
108          a.getAttribute("href")
109        );
110      assertEquals(order(), [`#/${newer}`, `#/${older}`]);
111      assertEquals(times()[1].dateTime, tidDate(older).toISOString());
112      const summary = { busy: false, preview: "hello", unread: false, updated: created };
113      server.snapshot("summaries", { [older]: summary, [newer]: summary });
114      assertEquals(times()[1].dateTime, new Date(created).toISOString());
115      const latest = new Date(now - 2 * 60_000);
116      server.snapshot("summaries", {
117        [older]: { ...summary, updated: latest.getTime() },
118        [newer]: summary,
119      });
120      assertEquals(order(), [`#/${older}`, `#/${newer}`]);
121      assertEquals(times()[0].dateTime, latest.toISOString());
122
123      const firstFork = "ccccccccccccc";
124      const secondFork = "ddddddddddddd";
125      const index = {
126        [older]: { id: 1, created },
127        [newer]: { id: 2, created: created + 1 },
128        [firstFork]: { id: 3, created: created + 2, parent: { tid: older, at: 1 } },
129        [secondFork]: { id: 4, created: created + 3, parent: { tid: older, at: 1 } },
130      };
131      server.snapshot("conversations", index);
132      server.snapshot("summaries", {
133        [older]: { ...summary, updated: latest.getTime() },
134        [newer]: { ...summary, updated: latest.getTime() + 1 },
135        [firstFork]: { ...summary, updated: latest.getTime() + 3 },
136        [secondFork]: { ...summary, updated: latest.getTime() + 2 },
137      });
138      document.querySelector<HTMLAnchorElement>(`a[href="#/${older}"]`)!.parentElement!
139        .querySelector<HTMLButtonElement>(".chevron")!.click();
140      assertEquals(order(), [`#/${newer}`, `#/${older}`, `#/${firstFork}`, `#/${secondFork}`]);
141      server.snapshot(
142        "conversations",
143        Object.fromEntries(
144          Object.entries(index).map(([tid, info]) => [tid, { ...info, archived: now }]),
145        ),
146      );
147      assertEquals(order(), []);
148      document.querySelector<HTMLButtonElement>(".archive-toggle")!.click();
149      assertEquals(order(), [`#/${newer}`, `#/${older}`, `#/${firstFork}`, `#/${secondFork}`]);
150      Object.defineProperty(window.navigator, "maxTouchPoints", { configurable: true, value: 5 });
151      const item = document.querySelector(`a[href="#/${older}"]`)!.parentElement!;
152      const link = item.querySelector("a")!;
153      const editor = item.querySelector<HTMLInputElement>(".conversation-name")!;
154      link.dispatchEvent(new window.MouseEvent("dblclick") as unknown as MouseEvent);
155      assertEquals(editor.hidden, true);
156      const actions = item.querySelector<HTMLDetailsElement>(".conversation-actions")!;
157      const more = actions.querySelector("summary")!;
158      more.click();
159      const menuButtons = actions.querySelectorAll<HTMLButtonElement>("button");
160      menuButtons[0].click();
161      assertEquals(actions.open, false);
162      assertEquals(editor.hidden, false);
163      assertEquals(document.activeElement, editor);
164      press(editor, "Escape");
165      more.click();
166      menuButtons[1].click();
167      assertEquals(actions.open, false);
168      assertEquals(server.changes().at(-1), {
169        method: "PATCH",
170        path: `/api/conversations/${older}`,
171        body: { archived: false },
172      });
173      server.snapshot("conversations", index);
174      document.querySelector<HTMLButtonElement>(".archive-toggle")!.click();
175      const activeActions = document.querySelector(`a[href="#/${older}"]`)!.parentElement!
176        .querySelector<HTMLDetailsElement>(".conversation-actions")!;
177      activeActions.querySelector("summary")!.click();
178      activeActions.querySelectorAll<HTMLButtonElement>("button")[1].click();
179      assertEquals(server.changes().at(-1), {
180        method: "PATCH",
181        path: `/api/conversations/${older}`,
182        body: { archived: true },
183      });
184      await settle();
185      scope.dispose();
186      assertEquals(clockCleared, true);
187    }, { globals: clock });
188  } finally {
189    Date.now = originalNow;
190  }
191});
192
193Deno.test("a row's menu closes once the reader goes elsewhere", async () => {
194  await withBerry(async ({ window, server, scope }) => {
195    const { app } = await import("./app.tsx");
196    document.body.append(app(scope));
197    const info = { id: 1, created: 1 };
198    server.snapshot("conversations", { aaaaaaaaaaaaa: info, bbbbbbbbbbbbb: { ...info, id: 2 } });
199    const [first, second] = [...document.querySelectorAll<HTMLDetailsElement>(
200      ".conversation-actions",
201    )];
202    const pointAt = (target: Element) =>
203      target.dispatchEvent(
204        new window.PointerEvent("pointerdown", { bubbles: true }) as unknown as PointerEvent,
205      );
206    first.open = true;
207    pointAt(first.querySelector("button")!);
208    assertEquals(first.open, true);
209    pointAt(second.querySelector("summary")!);
210    assertEquals(first.open, false);
211    first.open = true;
212    document.querySelector<HTMLAnchorElement>(".conversations a")!.focus();
213    assertEquals(first.open, false);
214  });
215});
216
217Deno.test("a busy background subagent nests under its parent and is gone once idle", async () => {
218  const grandparent = "aaaaaaaaaaaaa";
219  const parent = "bbbbbbbbbbbbb";
220  const fork = "ccccccccccccc";
221  const child = "ddddddddddddd";
222  const elsewhere = "eeeeeeeeeeeee";
223  const summary = {
224    environment: "local",
225    cwd: "/work/berry",
226    busy: false,
227    unread: false,
228    updated: 1,
229    preview: "hello",
230  };
231  // the selected subagent opens the way to itself, through its owner's fork chain
232  await withBerry(async ({ server, scope }) => {
233    const { app } = await import("./app.tsx");
234    document.body.append(app(scope));
235    server.snapshot("projects", {
236      berry: {
237        name: "Berry",
238        defaultRoot: 0,
239        roots: [{ environment: "local", cwd: "/work/berry", platform: "linux" }],
240      },
241      other: {
242        name: "Other",
243        defaultRoot: 0,
244        roots: [{ environment: "local", cwd: "/work/other", platform: "linux" }],
245      },
246    });
247    server.snapshot("summaries", {
248      [grandparent]: summary,
249      [parent]: summary,
250      [fork]: summary,
251      [child]: { ...summary, busy: true },
252      [elsewhere]: { ...summary, cwd: "/work/other", busy: true },
253    });
254    server.snapshot("conversations", {
255      [grandparent]: { id: 1, created: 1 },
256      [parent]: { id: 2, created: 2, parent: { tid: grandparent, at: 1 } },
257      [fork]: { id: 3, created: 3, parent: { tid: parent, at: 1 } },
258      [child]: { id: 4, created: 4, owner: parent },
259      [elsewhere]: { id: 5, created: 5, owner: parent },
260    });
261    const listed = () =>
262      [...document.querySelectorAll(".conversations .item > a[href]:not(.parent-link)")].map((a) =>
263        a.getAttribute("href")
264      );
265    const depthOf = (tid: string) =>
266      document.querySelector(`.conversations a[href="#/${tid}"]`)!.parentElement!
267        .style.getPropertyValue("--depth");
268    const chevronOf = (tid: string) =>
269      document.querySelector(`.conversations a[href="#/${tid}"]`)!.parentElement!
270        .querySelector<HTMLButtonElement>(".chevron")!;
271    const delegated = (tid: string) =>
272      document.querySelector(`.conversations a[href="#/${tid}"]`)!.parentElement!
273        .querySelector('[aria-label="subagent"]');
274    // nested under the parent without needing to expand it, and the chevron stays honest
275    assertEquals(listed(), [
276      `#/${grandparent}`,
277      `#/${parent}`,
278      `#/${fork}`,
279      `#/${child}`,
280      `#/${elsewhere}`,
281    ]);
282    assertEquals(depthOf(child), "2");
283    // a subagent reads as delegated, where a fork keeps its chevron
284    assertEquals(delegated(child)?.textContent, "↳");
285    assertEquals(delegated(fork), null);
286    assertEquals(delegated(parent), null);
287    assertEquals(chevronOf(grandparent).title, "1 fork");
288    assertStringIncludes(chevronOf(parent).title, "1 fork");
289    assertStringIncludes(chevronOf(parent).title, "1 live subagent");
290    // a subagent owned across projects shows as a root with its parent link
291    assertEquals(
292      document.querySelector('[data-project="other"] .project-count')!.textContent,
293      "",
294    );
295    const away = document.querySelector(`.conversations a[href="#/${elsewhere}"]`)!.parentElement!;
296    assertEquals(depthOf(elsewhere), "0");
297    assertEquals(
298      away.querySelector<HTMLAnchorElement>(".parent-link")!.getAttribute("href"),
299      `#/${parent}`,
300    );
301    // collapsing hides the fork but not the live subagent, and the count stays forks-only
302    chevronOf(parent).click();
303    assertEquals(listed(), [`#/${grandparent}`, `#/${parent}`, `#/${child}`, `#/${elsewhere}`]);
304    assertEquals(
305      document.querySelector(`.conversations a[href="#/${parent}"]`)!.parentElement!
306        .querySelector(".forks")!.textContent,
307      "+1",
308    );
309    // group counts leave the transient work out
310    assertEquals(document.querySelector('[data-project="berry"] .project-count')!.textContent, "3");
311    // once idle the subagent is gone, with no lingering row or count
312    server.snapshot("summaries", {
313      [grandparent]: summary,
314      [parent]: summary,
315      [fork]: summary,
316      [child]: summary,
317      [elsewhere]: { ...summary, cwd: "/work/other" },
318    });
319    assertEquals(listed(), [`#/${grandparent}`, `#/${parent}`]);
320    assert(!document.querySelector(`.conversations a[href="#/${child}"]`));
321    assertEquals(chevronOf(parent).title, "1 fork");
322    assert(!document.querySelector(`.conversations a[href="#/${elsewhere}"]`));
323    chevronOf(parent).click();
324    assertEquals(listed(), [`#/${grandparent}`, `#/${parent}`, `#/${fork}`]);
325    // and the archive never lists them
326    server.snapshot("conversations", {
327      [grandparent]: { id: 1, created: 1 },
328      [parent]: { id: 2, created: 2, parent: { tid: grandparent, at: 1 }, archived: 2 },
329      [fork]: { id: 3, created: 3, parent: { tid: parent, at: 1 } },
330      [child]: { id: 4, created: 4, owner: parent },
331      [elsewhere]: { id: 5, created: 5, owner: parent },
332    });
333    assert(!document.querySelector(`.conversations a[href="#/${parent}"]`));
334    assertEquals(document.querySelector(".archive-toggle")!.textContent, "archived (1)");
335    document.querySelector<HTMLButtonElement>(".archive-toggle")!.click();
336    document.querySelector<HTMLButtonElement>('[data-project="berry"] .project-toggle')!.click();
337    assertEquals(listed(), [`#/${parent}`, `#/${fork}`]);
338    assertEquals(document.querySelector(".archive-toggle")!.textContent, "back to conversations");
339  }, { url: "http://berry.test/#/ddddddddddddd" });
340});