import { assert, assertEquals, assertStringIncludes } from "@std/assert"; import { press } from "@berry/util/dom.ts"; import { withBerry } from "./testing.ts"; import { tidDate } from "./ui.ts"; Deno.test("project counts exclude archived subtrees from the main list and include their forks in the archive", async () => { await withBerry(async ({ server, scope }) => { const { app } = await import("./app.tsx"); document.body.append(app(scope)); const active = "aaaaaaaaaaaaa"; const collapsedFork = "bbbbbbbbbbbbb"; const archived = "ccccccccccccc"; const hiddenFork = "ddddddddddddd"; const nestedFork = "eeeeeeeeeeeee"; const crossProjectFork = "fffffffffffff"; const index = { [active]: { id: 1, created: 1 }, [collapsedFork]: { id: 2, created: 2, parent: { tid: active, at: 1 } }, [archived]: { id: 3, created: 3, archived: 4 }, [hiddenFork]: { id: 4, created: 4, parent: { tid: archived, at: 1 } }, [nestedFork]: { id: 5, created: 5, parent: { tid: hiddenFork, at: 1 } }, [crossProjectFork]: { id: 6, created: 6, parent: { tid: archived, at: 1 } }, }; server.snapshot("projects", { berry: { name: "Berry", defaultRoot: 0, roots: [{ environment: "local", cwd: "/work/berry", platform: "linux" }], }, other: { name: "Other", defaultRoot: 0, roots: [{ environment: "local", cwd: "/work/other", platform: "linux" }], }, }); server.snapshot( "summaries", Object.fromEntries( Object.keys(index).map((tid) => [tid, { environment: "local", cwd: tid === crossProjectFork ? "/work/other" : "/work/berry", busy: tid === nestedFork, unread: tid === nestedFork, updated: 1, preview: "hello", }]), ), ); server.snapshot("conversations", index); const count = () => document.querySelector('[data-project="berry"] .project-count')!.textContent; const listed = () => [...document.querySelectorAll(".conversations .item > a[href]:not(.parent-link)")].map((a) => a.getAttribute("href") ); const expand = (tid: string) => document.querySelector(`.conversations a[href="#/${tid}"]`)!.parentElement! .querySelector(".chevron")!.click(); assertEquals(count(), "2"); assertEquals(listed(), [`#/${active}`, `#/${crossProjectFork}`]); assertEquals(document.querySelector('[data-project="other"] .project-count')!.textContent, "1"); expand(active); assertEquals(count(), "2"); assertEquals(listed(), [`#/${active}`, `#/${collapsedFork}`, `#/${crossProjectFork}`]); document.querySelector('[data-project="berry"] .project-toggle')!.click(); assertEquals(document.querySelector('[data-project="berry"] [title="answering"]'), null); assertEquals(document.querySelector('[data-project="berry"] [title="unread"]'), null); document.querySelector(".archive-toggle")!.click(); assertEquals(count(), "3"); assertEquals(document.querySelector('[data-project="other"]'), null); // the archive's projects start folded, apart from how the main list folds them assertEquals(listed(), []); document.querySelector('[data-project="berry"] .project-toggle')!.click(); assertEquals(listed(), [`#/${archived}`]); expand(archived); expand(hiddenFork); assertEquals(listed(), [`#/${archived}`, `#/${hiddenFork}`, `#/${nestedFork}`]); document.querySelector(".archive-toggle")!.click(); assertEquals(listed(), [`#/${crossProjectFork}`]); }); }); Deno.test("sidebar follows latest message times while keeping forks nested", async () => { const now = new Date(2025, 5, 1, 12).getTime(); const originalNow = Date.now; Date.now = () => now; let clockCleared = false; const clock = { setInterval: () => 1, clearInterval: () => { clockCleared = true; }, }; try { await withBerry(async ({ window, server, scope, settle }) => { const { app } = await import("./app.tsx"); document.body.append(app(scope)); const older = "aaaaaaaaaaaaa"; const newer = "bbbbbbbbbbbbb"; const created = new Date(2020, 0, 2).getTime(); server.snapshot("conversations", { [older]: { id: 1, created }, [newer]: { id: 2, created: created + 1 }, }); const times = () => [...document.querySelectorAll(".conversations time")]; const order = () => [...document.querySelectorAll(".conversations .item > a[href]")].map((a) => a.getAttribute("href") ); assertEquals(order(), [`#/${newer}`, `#/${older}`]); assertEquals(times()[1].dateTime, tidDate(older).toISOString()); const summary = { busy: false, preview: "hello", unread: false, updated: created }; server.snapshot("summaries", { [older]: summary, [newer]: summary }); assertEquals(times()[1].dateTime, new Date(created).toISOString()); const latest = new Date(now - 2 * 60_000); server.snapshot("summaries", { [older]: { ...summary, updated: latest.getTime() }, [newer]: summary, }); assertEquals(order(), [`#/${older}`, `#/${newer}`]); assertEquals(times()[0].dateTime, latest.toISOString()); const firstFork = "ccccccccccccc"; const secondFork = "ddddddddddddd"; const index = { [older]: { id: 1, created }, [newer]: { id: 2, created: created + 1 }, [firstFork]: { id: 3, created: created + 2, parent: { tid: older, at: 1 } }, [secondFork]: { id: 4, created: created + 3, parent: { tid: older, at: 1 } }, }; server.snapshot("conversations", index); server.snapshot("summaries", { [older]: { ...summary, updated: latest.getTime() }, [newer]: { ...summary, updated: latest.getTime() + 1 }, [firstFork]: { ...summary, updated: latest.getTime() + 3 }, [secondFork]: { ...summary, updated: latest.getTime() + 2 }, }); document.querySelector(`a[href="#/${older}"]`)!.parentElement! .querySelector(".chevron")!.click(); assertEquals(order(), [`#/${newer}`, `#/${older}`, `#/${firstFork}`, `#/${secondFork}`]); server.snapshot( "conversations", Object.fromEntries( Object.entries(index).map(([tid, info]) => [tid, { ...info, archived: now }]), ), ); assertEquals(order(), []); document.querySelector(".archive-toggle")!.click(); assertEquals(order(), [`#/${newer}`, `#/${older}`, `#/${firstFork}`, `#/${secondFork}`]); Object.defineProperty(window.navigator, "maxTouchPoints", { configurable: true, value: 5 }); const item = document.querySelector(`a[href="#/${older}"]`)!.parentElement!; const link = item.querySelector("a")!; const editor = item.querySelector(".conversation-name")!; link.dispatchEvent(new window.MouseEvent("dblclick") as unknown as MouseEvent); assertEquals(editor.hidden, true); const actions = item.querySelector(".conversation-actions")!; const more = actions.querySelector("summary")!; more.click(); const menuButtons = actions.querySelectorAll("button"); menuButtons[0].click(); assertEquals(actions.open, false); assertEquals(editor.hidden, false); assertEquals(document.activeElement, editor); press(editor, "Escape"); more.click(); menuButtons[1].click(); assertEquals(actions.open, false); assertEquals(server.changes().at(-1), { method: "PATCH", path: `/api/conversations/${older}`, body: { archived: false }, }); server.snapshot("conversations", index); document.querySelector(".archive-toggle")!.click(); const activeActions = document.querySelector(`a[href="#/${older}"]`)!.parentElement! .querySelector(".conversation-actions")!; activeActions.querySelector("summary")!.click(); activeActions.querySelectorAll("button")[1].click(); assertEquals(server.changes().at(-1), { method: "PATCH", path: `/api/conversations/${older}`, body: { archived: true }, }); await settle(); scope.dispose(); assertEquals(clockCleared, true); }, { globals: clock }); } finally { Date.now = originalNow; } }); Deno.test("a row's menu closes once the reader goes elsewhere", async () => { await withBerry(async ({ window, server, scope }) => { const { app } = await import("./app.tsx"); document.body.append(app(scope)); const info = { id: 1, created: 1 }; server.snapshot("conversations", { aaaaaaaaaaaaa: info, bbbbbbbbbbbbb: { ...info, id: 2 } }); const [first, second] = [...document.querySelectorAll( ".conversation-actions", )]; const pointAt = (target: Element) => target.dispatchEvent( new window.PointerEvent("pointerdown", { bubbles: true }) as unknown as PointerEvent, ); first.open = true; pointAt(first.querySelector("button")!); assertEquals(first.open, true); pointAt(second.querySelector("summary")!); assertEquals(first.open, false); first.open = true; document.querySelector(".conversations a")!.focus(); assertEquals(first.open, false); }); }); Deno.test("a busy background subagent nests under its parent and is gone once idle", async () => { const grandparent = "aaaaaaaaaaaaa"; const parent = "bbbbbbbbbbbbb"; const fork = "ccccccccccccc"; const child = "ddddddddddddd"; const elsewhere = "eeeeeeeeeeeee"; const summary = { environment: "local", cwd: "/work/berry", busy: false, unread: false, updated: 1, preview: "hello", }; // the selected subagent opens the way to itself, through its owner's fork chain await withBerry(async ({ server, scope }) => { const { app } = await import("./app.tsx"); document.body.append(app(scope)); server.snapshot("projects", { berry: { name: "Berry", defaultRoot: 0, roots: [{ environment: "local", cwd: "/work/berry", platform: "linux" }], }, other: { name: "Other", defaultRoot: 0, roots: [{ environment: "local", cwd: "/work/other", platform: "linux" }], }, }); server.snapshot("summaries", { [grandparent]: summary, [parent]: summary, [fork]: summary, [child]: { ...summary, busy: true }, [elsewhere]: { ...summary, cwd: "/work/other", busy: true }, }); server.snapshot("conversations", { [grandparent]: { id: 1, created: 1 }, [parent]: { id: 2, created: 2, parent: { tid: grandparent, at: 1 } }, [fork]: { id: 3, created: 3, parent: { tid: parent, at: 1 } }, [child]: { id: 4, created: 4, owner: parent }, [elsewhere]: { id: 5, created: 5, owner: parent }, }); const listed = () => [...document.querySelectorAll(".conversations .item > a[href]:not(.parent-link)")].map((a) => a.getAttribute("href") ); const depthOf = (tid: string) => document.querySelector(`.conversations a[href="#/${tid}"]`)!.parentElement! .style.getPropertyValue("--depth"); const chevronOf = (tid: string) => document.querySelector(`.conversations a[href="#/${tid}"]`)!.parentElement! .querySelector(".chevron")!; const delegated = (tid: string) => document.querySelector(`.conversations a[href="#/${tid}"]`)!.parentElement! .querySelector('[aria-label="subagent"]'); // nested under the parent without needing to expand it, and the chevron stays honest assertEquals(listed(), [ `#/${grandparent}`, `#/${parent}`, `#/${fork}`, `#/${child}`, `#/${elsewhere}`, ]); assertEquals(depthOf(child), "2"); // a subagent reads as delegated, where a fork keeps its chevron assertEquals(delegated(child)?.textContent, "↳"); assertEquals(delegated(fork), null); assertEquals(delegated(parent), null); assertEquals(chevronOf(grandparent).title, "1 fork"); assertStringIncludes(chevronOf(parent).title, "1 fork"); assertStringIncludes(chevronOf(parent).title, "1 live subagent"); // a subagent owned across projects shows as a root with its parent link assertEquals( document.querySelector('[data-project="other"] .project-count')!.textContent, "", ); const away = document.querySelector(`.conversations a[href="#/${elsewhere}"]`)!.parentElement!; assertEquals(depthOf(elsewhere), "0"); assertEquals( away.querySelector(".parent-link")!.getAttribute("href"), `#/${parent}`, ); // collapsing hides the fork but not the live subagent, and the count stays forks-only chevronOf(parent).click(); assertEquals(listed(), [`#/${grandparent}`, `#/${parent}`, `#/${child}`, `#/${elsewhere}`]); assertEquals( document.querySelector(`.conversations a[href="#/${parent}"]`)!.parentElement! .querySelector(".forks")!.textContent, "+1", ); // group counts leave the transient work out assertEquals(document.querySelector('[data-project="berry"] .project-count')!.textContent, "3"); // once idle the subagent is gone, with no lingering row or count server.snapshot("summaries", { [grandparent]: summary, [parent]: summary, [fork]: summary, [child]: summary, [elsewhere]: { ...summary, cwd: "/work/other" }, }); assertEquals(listed(), [`#/${grandparent}`, `#/${parent}`]); assert(!document.querySelector(`.conversations a[href="#/${child}"]`)); assertEquals(chevronOf(parent).title, "1 fork"); assert(!document.querySelector(`.conversations a[href="#/${elsewhere}"]`)); chevronOf(parent).click(); assertEquals(listed(), [`#/${grandparent}`, `#/${parent}`, `#/${fork}`]); // and the archive never lists them server.snapshot("conversations", { [grandparent]: { id: 1, created: 1 }, [parent]: { id: 2, created: 2, parent: { tid: grandparent, at: 1 }, archived: 2 }, [fork]: { id: 3, created: 3, parent: { tid: parent, at: 1 } }, [child]: { id: 4, created: 4, owner: parent }, [elsewhere]: { id: 5, created: 5, owner: parent }, }); assert(!document.querySelector(`.conversations a[href="#/${parent}"]`)); assertEquals(document.querySelector(".archive-toggle")!.textContent, "archived (1)"); document.querySelector(".archive-toggle")!.click(); document.querySelector('[data-project="berry"] .project-toggle')!.click(); assertEquals(listed(), [`#/${parent}`, `#/${fork}`]); assertEquals(document.querySelector(".archive-toggle")!.textContent, "back to conversations"); }, { url: "http://berry.test/#/ddddddddddddd" }); });