From 7125193c126f7352253620e9f0f9375ef44ff62d Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:26:57 -0500 Subject: [PATCH 01/14] shell: History, every chat the person has had, and its search One page (`/history`, the sidebar's History above Apps) lists every chat the person has had, the newest first: their chats, archived ones too, those shared with them, and their Session Archive's conversations. A row says its title, whom it was with, its first words, when it was last said and Archived on the archived ones; All, Archived and Imported filter it. The search box filters by title as it is typed, and asks the person's search for the messages that hold its words. The sidebar's Session Archive section goes: History lists its conversations (Paul, 2026-10-31). Co-Authored-By: Claude Opus 5.5 --- cell/shell-tests/history-page.test.mjs | 53 +++++ cell/shell/history-page.js | 287 +++++++++++++++++++++++++ cell/shell/icons.js | 3 + cell/shell/index.html | 8 +- cell/shell/session-archive.js | 73 ++----- cell/shell/shell.css | 46 +++- cell/shell/shell.js | 53 ++++- cell/src/lib.rs | 5 +- cell/src/shell.rs | 22 +- crates/templates/src/lib.rs | 4 +- docs/api.md | 34 ++- xtask/src/js_syntax.rs | 2 +- 12 files changed, 495 insertions(+), 95 deletions(-) create mode 100644 cell/shell-tests/history-page.test.mjs create mode 100644 cell/shell/history-page.js diff --git a/cell/shell-tests/history-page.test.mjs b/cell/shell-tests/history-page.test.mjs new file mode 100644 index 000000000..99bd3940d --- /dev/null +++ b/cell/shell-tests/history-page.test.mjs @@ -0,0 +1,53 @@ +// cell/shell/history-page.js's rows (Paul, 2026-10-31): every chat and +// imported conversation, the newest first; All, Archived and Imported; +// words held by a title (an import's first words too) or found by the +// person's search; the search's hint bounded as the API bounds it; and a +// row's time. +// Run by `cargo xtask check` on the pinned Node (xtask/src/js_syntax.rs). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { historyRows, hintOf, when } from "../shell/history-page.js"; + +const chat = (name, at, extra = {}) => ({ name, title: name, at, archived: false, ...extra }); +const entry = (key, ended_at, extra = {}) => ({ key, title: key, ended_at, preview: "", ...extra }); +const keys = (rows) => rows.map((r) => r.key); +const chats = [chat("trip", 30), chat("taxes", 10, { archived: true }), chat("made-now", undefined)]; +const entries = [entry("old-talk", "1970-01-01T00:00:00.020Z", { preview: "about the garden" }), entry("no-time", undefined)]; + +test("every chat and import, the newest first: a chat with no message yet first, an import with no time last", () => { + assert.deepEqual(keys(historyRows({ chats, entries })), ["chat:made-now", "chat:trip", "imported:old-talk", "chat:taxes", "imported:no-time"]); +}); + +test("Archived lists the archived chats alone; Imported, the archive's conversations alone", () => { + assert.deepEqual(keys(historyRows({ chats, entries, filter: "archived" })), ["chat:taxes"]); + assert.deepEqual(keys(historyRows({ chats, entries, filter: "imported" })), ["imported:old-talk", "imported:no-time"]); +}); + +test("words: a title holding them all, an import's first words, or the search's finds, with its snippet", () => { + assert.deepEqual(keys(historyRows({ chats, entries, q: "TR" })), ["chat:trip"]); + assert.deepEqual(keys(historyRows({ chats, entries, q: "the garden" })), ["imported:old-talk"]); + assert.deepEqual(keys(historyRows({ chats, entries, q: "trip garden" })), []); + const found = historyRows({ chats, entries, q: "receipts", found: new Map([["taxes", "the receipts are in"]]) }); + assert.deepEqual(found.map((r) => [r.key, r.snippet]), [["chat:taxes", "the receipts are in"]]); + const imported = historyRows({ chats, entries, q: "lunch", foundImported: new Map([["no-time", "lunch at noon"]]) }); + assert.deepEqual(keys(imported), ["imported:no-time"]); + // a filter holds over what was found + assert.deepEqual(keys(historyRows({ chats, entries, q: "receipts", filter: "imported", found: new Map([["taxes", "x"]]) })), []); +}); + +test("the search's hint: eight words and 256 bytes at most, never a character cut", () => { + assert.equal(hintOf(" one two three "), "one two three"); + assert.equal(hintOf("a b c d e f g h i j"), "a b c d e f g h"); + const long = "é".repeat(200); + assert.ok(new TextEncoder().encode(hintOf(long)).length <= 256); + assert.equal(hintOf(long), "é".repeat(128)); +}); + +test("a row's time: the time today, the day this year, with its year before it", () => { + const now = new Date(2026, 9, 31, 15, 0).getTime(); + assert.equal(when(undefined, now), ""); + assert.match(when(new Date(2026, 9, 31, 9, 5).getTime(), now), /9:05/); + assert.doesNotMatch(when(new Date(2026, 9, 12).getTime(), now), /2026/); + assert.match(when(new Date(2025, 9, 12).getTime(), now), /2025/); +}); diff --git a/cell/shell/history-page.js b/cell/shell/history-page.js new file mode 100644 index 000000000..c923fcc9b --- /dev/null +++ b/cell/shell/history-page.js @@ -0,0 +1,287 @@ +// The History page (`/history`; Paul, 2026-10-31): every chat the person +// has had, the newest first, with room to search them, so the sidebar can +// stay short ("just like gmail makes it low cost to archive a thread +// because you know you can always search it"). It lists: +// - their chats, live and archived (the list's `archived`), and those +// shared with them; a delegation thread's chat is its thread's line +// (threads.js), not a chat of its own here; +// - their Session Archive's conversations (session-archive.js: its +// index), each opening read-only as the archive's page shows it. +// A row says its title, whom it was with (its agent's face and name; +// "Imported" for the archive's), when it was last said, and "Archived" on +// the ones the person archived. All, Archived and Imported filter it. +// The search box filters by title as it is typed, and asks the person's +// search (`GET /api/search`) for the messages that hold its words: each +// chat found there once, with its newest snippet under it. The archive's +// conversations are matched by their titles and first words alone until +// the archive is searched too (#774), through `h.archive.search` (q → +// [{key, snippet}]) once it has one. +// Opening a row opens it in the middle column. +// Archived and imported rows end with `h.continueControl(source)` once it +// is given (continue.js: Continue with an agent of theirs). +// +// The shell owns the page's place and its helpers (`h`): `api`, `el`, +// `icon`, `mark` (a chat's face), `chats`, `isThread`, `own`, `chatTitle`, +// `agentsOf`, `agents` (the person's own, by identity), `titleOf`, +// `archive` ({name, entries, openSession}), `openChat`, `leaveSidebar` and +// `continueControl`. + +// The search API's hint: eight words, 256 bytes at most (launcher.js's). +const HINT_WORDS = 8; +const HINT_MAX_BYTES = 256; +// how long typing rests before the person's search is asked +const SEARCH_WAIT_MS = 250; +// the rows drawn at first, and again with each Show more +const ROWS_STEP = 100; +const FILTERS = [["all", "All"], ["archived", "Archived"], ["imported", "Imported"]]; + +const wordsOf = (q) => q.toLowerCase().split(/\s+/).filter(Boolean); +const holds = (text, words) => words.every((w) => text.toLowerCase().includes(w)); +export function hintOf(q) { + const hint = [...q.trim().split(/\s+/).slice(0, HINT_WORDS).join(" ")]; + while (new TextEncoder().encode(hint.join("")).length > HINT_MAX_BYTES) hint.pop(); + return hint.join(""); +} +// An imported conversation's time: when it was last said, else begun +// (the archive's UTC times; README's "Times are UTC"). +const importedAt = (e) => Date.parse(e.ended_at ?? e.started_at ?? "") || 0; +// When a row was last said, as History says it: the time today, else the +// day, with its year when it is not this one. +export function when(at, now = Date.now()) { + if (!at) return ""; + const d = new Date(at), today = new Date(now); + if (d.toDateString() === today.toDateString()) return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + return d.toLocaleDateString([], { month: "short", day: "numeric", ...(d.getFullYear() === today.getFullYear() ? {} : { year: "numeric" }) }); +} +// The rows, the newest first: chats (`kind: "chat"`) and imported +// conversations (`kind: "imported"`), filtered, and with words, those whose +// title holds them all (an import's first words too) or whose messages +// the search found (`found`: chat name → snippet; `foundImported`: key → +// snippet). +export function historyRows({ chats, entries, filter = "all", q = "", found = new Map(), foundImported = new Map() }) { + const words = wordsOf(q); + const rows = []; + if (filter !== "imported") { + for (const c of chats) { + if (filter === "archived" && !c.archived) continue; + const snippet = found.get(c.name); + if (words.length && !holds(c.title, words) && snippet === undefined) continue; + rows.push({ kind: "chat", key: `chat:${c.name}`, at: c.at, ...c, snippet }); + } + } + if (filter !== "archived") { + for (const e of entries) { + const snippet = foundImported.get(e.key); + if (words.length && !holds(`${e.title ?? ""}\n${e.preview ?? ""}`, words) && snippet === undefined) continue; + rows.push({ kind: "imported", key: `imported:${e.key}`, at: importedAt(e), entry: e, title: e.title || "Untitled", snippet }); + } + } + // a chat made now (no message yet) first, as the sidebar has it; an + // import that says no time, last + const rank = (r) => r.at || (r.kind === "chat" ? Infinity : 0); + return rows.sort((a, b) => rank(b) - rank(a) || (a.key < b.key ? -1 : 1)); +} + +export function createHistoryPage(page, h) { + const { el, icon } = h; + let shown = false; + let filter = "all"; + let q = ""; + let limit = ROWS_STEP; + // the person's search for `q`: what it found, or why it failed + const search = { q: "", asked: 0, timer: 0, found: new Map(), foundImported: new Map(), failed: "" }; + // each row's Continue, kept across drawings (what it is doing is its own) + const controls = new Map(); + let parts = null; + + function build() { + const wrap = el("div", "history-layout"); + const head = el("header", "history-intro"); + head.append(el("h1", null, "History")); + const bar = el("label", "history-search"); + const input = el("input"); + Object.assign(input, { type: "search", placeholder: "Search chats", autocomplete: "off", spellcheck: false, maxLength: 256, value: q }); + input.setAttribute("aria-label", "Search chats"); + input.oninput = () => { + q = input.value; + limit = ROWS_STEP; + render(); + ask(); + }; + input.onkeydown = (e) => { + if (e.key === "Escape" && input.value) { + e.stopPropagation(); + input.value = ""; + input.oninput(); + } + }; + bar.append(icon("search"), input); + const chips = el("div", "history-filters"); + chips.setAttribute("role", "group"); + chips.setAttribute("aria-label", "Show"); + for (const [key, text] of FILTERS) { + const chip = el("button", "history-filter", text); + chip.type = "button"; + chip.dataset.filter = key; + chip.onclick = () => { + filter = key; + limit = ROWS_STEP; + render(); + }; + chips.append(chip); + } + const status = el("p", "history-status"); + status.setAttribute("role", "status"); + const list = el("div", "history-list"); + const more = el("button", "history-more", "Show more"); + more.type = "button"; + more.onclick = () => { + limit += ROWS_STEP; + render(); + }; + head.append(bar, chips); + wrap.append(head, status, list, more); + page.replaceChildren(wrap); + parts = { input, chips, status, list, more }; + } + + // The person's search, once typing rests: a slower answer to an older + // query never replaces a newer one's. + function ask() { + clearTimeout(search.timer); + const hint = hintOf(q); + if (!hint) { + Object.assign(search, { q: "", found: new Map(), foundImported: new Map(), failed: "" }); + return; + } + if (hint === search.q) return; + search.timer = setTimeout(async () => { + const mine = ++search.asked; + try { + const [answer, imported] = await Promise.all([ + h.api("GET", `/api/search?q=${encodeURIComponent(hint)}`), + h.archive.search ? h.archive.search(hint) : [], + ]); + if (mine !== search.asked) return; + // each chat's newest snippet (the answer is the newest first) + const found = new Map(); + for (const m of answer.messages ?? []) if (!found.has(m.fragment)) found.set(m.fragment, m.snippet); + const foundImported = new Map(); + for (const m of imported) if (!foundImported.has(m.key)) foundImported.set(m.key, m.snippet); + Object.assign(search, { q: hint, found, foundImported, failed: "" }); + } catch (e) { + if (mine !== search.asked) return; + Object.assign(search, { q: hint, found: new Map(), foundImported: new Map(), failed: e.message }); + } + render(); + }, SEARCH_WAIT_MS); + } + + // whom a chat was with: its lead agent's name, when it is one of the + // person's own; a chat shared with them says so + function withWhom(f) { + const lead = h.agentsOf(f.name)[0]; + const agent = lead && h.agents().get(lead); + if (agent) return h.titleOf(agent.fragment); + return h.own(f) ? "" : "Shared"; + } + + function row(r) { + const item = el("div", "history-row"); + item.dataset.key = r.key; + item.dataset.kind = r.kind; + const open = el("button", "history-open"); + open.type = "button"; + const face = el("span", "history-face"); + if (r.kind === "chat") face.append(h.mark(r.name, "small")); + else face.append(icon("archive")); + const main = el("span", "history-main"); + const line = el("span", "history-line"); + line.append(el("span", "label", r.title)); + if (r.archived) line.append(el("span", "history-chip", "Archived")); + const time = el("time", null, when(r.at)); + if (r.at) { + time.dateTime = new Date(r.at).toISOString(); + time.title = new Date(r.at).toLocaleString(); + } + line.append(time); + const sub = el("span", "history-sub"); + const who = r.kind === "chat" ? r.with : "Imported"; + if (who) sub.append(el("span", "history-with", who)); + const words = r.snippet ?? (r.kind === "chat" ? r.preview : r.entry.preview) ?? ""; + if (words) sub.append(el("span", r.snippet === undefined ? "history-preview" : "history-preview found", words)); + main.append(line, sub); + open.append(face, main); + open.onclick = () => { + if (r.kind === "chat") h.openChat(r.name); + else h.archive.openSession(r.entry.key); + h.leaveSidebar(); + }; + item.append(open); + return item; + } + // Continue with an agent of theirs: an archived chat's, or an imported + // one's, made once and kept + function controlOf(r) { + const source = r.kind === "imported" ? { archive: h.archive.name(), key: r.entry.key } : r.archived ? { chat: r.name } : null; + if (!source || !h.continueControl) return null; + if (!controls.has(r.key)) controls.set(r.key, h.continueControl(source)); + return controls.get(r.key); + } + // The rows put in place by key, as the sidebar's are (shell.js `patch`): + // a row that shows the same keeps its element (its focus, the scroll + // around it), its Continue kept in it. + function place(rows) { + const box = parts.list; + const was = new Map([...box.children].filter((e) => e.dataset.key).map((e) => [e.dataset.key, e])); + const next = rows.map((r) => { + const fresh = row(r); + const old = was.get(r.key); + const item = old?.firstElementChild.isEqualNode(fresh.firstElementChild) ? old : fresh; + const control = controlOf(r); + if (control && control.parentNode !== item) item.append(control); + return item; + }); + next.forEach((item, i) => { + if (box.children[i] !== item) box.insertBefore(item, box.children[i] ?? null); + }); + while (box.children.length > next.length) box.lastElementChild.remove(); + } + + function render() { + if (!shown) return; + if (!parts) build(); + const chats = h.chats().filter((f) => !h.isThread(f)).map((f) => ({ + name: f.name, title: h.chatTitle(f.name), at: f.previewAt, archived: !!f.archived, preview: f.preview ?? "", with: withWhom(f), + })); + const entries = h.archive.name() ? h.archive.entries() : []; + const searched = hintOf(q) === search.q; + const rows = historyRows({ chats, entries, filter, q, ...(searched ? { found: search.found, foundImported: search.foundImported } : {}) }); + for (const chip of parts.chips.children) chip.setAttribute("aria-pressed", String(chip.dataset.filter === filter)); + const words = wordsOf(q).length > 0; + const waiting = words && !searched; + parts.status.textContent = search.failed && searched ? `Messages were not searched: ${search.failed}` : waiting ? "Searching messages…" : ""; + parts.status.hidden = !parts.status.textContent; + if (!rows.length) { + const empty = words ? (waiting ? "" : `Nothing found for “${q.trim()}”`) : filter === "archived" ? "No archived chats" : filter === "imported" ? "No imported chats" : "No chats yet"; + parts.list.replaceChildren(...(empty ? [el("p", "muted history-empty", empty)] : [])); + } else place(rows.slice(0, limit)); + parts.more.hidden = rows.length <= limit; + // Continue controls of rows no longer listed go + const keys = new Set(rows.map((r) => r.key)); + for (const key of controls.keys()) if (!keys.has(key)) controls.delete(key); + } + + return { + open() { + shown = true; + render(); + parts.input.focus({ preventScroll: true }); + }, + close() { + shown = false; + }, + // the person's list changed, or the archive's index: drawn again + render, + }; +} diff --git a/cell/shell/icons.js b/cell/shell/icons.js index eadcd70df..de0b346ce 100644 --- a/cell/shell/icons.js +++ b/cell/shell/icons.js @@ -38,6 +38,9 @@ const ICON = { // a pushpin (Lucide's pin) pin: [["path", { d: "M12 17v5" }], ["path", { d: "M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" }]], archive: [["rect", { x: 2, y: 3, width: 20, height: 5, rx: 1 }], ["path", { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" }]], + // a clock turned back (Lucide's history), and a magnifier (Lucide's search) + history: [["path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }], ["path", { d: "M3 3v5h5" }], ["path", { d: "M12 7v5l4 2" }]], + search: [["circle", { cx: 11, cy: 11, r: 8 }], ["path", { d: "m21 21-4.3-4.3" }]], }; const SVG_NS = "http://www.w3.org/2000/svg"; diff --git a/cell/shell/index.html b/cell/shell/index.html index 62408f562..1e3d1e198 100644 --- a/cell/shell/index.html +++ b/cell/shell/index.html @@ -54,11 +54,8 @@
- - + + @@ -95,6 +92,7 @@

+ diff --git a/cell/shell/session-archive.js b/cell/shell/session-archive.js index edee5ed92..74d107cb3 100644 --- a/cell/shell/session-archive.js +++ b/cell/shell/session-archive.js @@ -1,11 +1,12 @@ -// The person's Session Archive in the sidebar (templates/sessions/README.md): -// a heading below their chats listing the archive's chats, newest first, as -// its chats' index lists them (`index/chat.json`, read with this page's -// session). One opens in the middle column, read-only: the archive's own -// page for it (`/?s=`), framed as a chat is; the archive's page lists -// the rest, its scheduled runs and side tasks folded away. The archive is -// the one fragment of kind `sessions` the person owns; the shell makes -// none (`fragment session import` does). +// The person's Session Archive (templates/sessions/README.md): its chats, +// newest first, as its chats' index lists them (`index/chat.json`, read +// with this page's session), which the History page lists with the +// person's chats (history-page.js; Paul, 2026-10-31: in place of a +// sidebar section of their own). One opens in the middle column, +// read-only: the archive's own page for it (`/?s=`), framed as a chat +// is; the archive's page lists the rest, its scheduled runs and side tasks +// folded away. The archive is the one fragment of kind `sessions` the +// person owns; the shell makes none (`fragment session import` does). // // The index is read again as the person's list is read (each change it // tells this page of) at most once a minute, at once when the archive is @@ -44,14 +45,12 @@ export function historyOf(c, key, archive) { return { ...base, messages: tail, earlier: said.length - tail.length }; } +// `changed`: the index read again, or the conversation open in it moved. export function createSessionArchive({ - api, ui: { $, el, patch, renderHeading, notice }, data: { allFragments, own, seg, primaryAgent, agents, chatLabel }, - selection: { current, page, getFrame }, navigation: { openChat, leaveSidebar }, load: loadList, store, + api, ui: { renderHeading, notice }, data: { allFragments, own, seg, primaryAgent, agents, chatLabel }, + selection: { getFrame }, navigation: { openChat, leaveSidebar }, load: loadList, store, changed, }) { - // the rows the sidebar shows; the archive's page lists every one - const SHOWN = 30; const READ_EVERY_MS = 60_000; - const OPEN = "shell.archive.open.v1"; const read = { name: null, at: 0, asked: 0, entries: [] }; let open = null; @@ -62,17 +61,17 @@ export function createSessionArchive({ if (!name) { read.name = null; read.entries = []; - return render(); + return changed(); } - if (!force && name === read.name && Date.now() - read.at < READ_EVERY_MS) return render(); + if (!force && name === read.name && Date.now() - read.at < READ_EVERY_MS) return; const mine = ++read.asked; read.at = Date.now(); // failed, what was read before stays: a failure is not "none" const index = await api("GET", `/api/f/${seg(name)}/file?path=${encodeURIComponent("index/chat.json")}`).catch((e) => (e.status === 404 ? { sessions: [] } : null)); - if (mine !== read.asked || !index) return render(); + if (mine !== read.asked || !index) return; read.name = name; read.entries = Array.isArray(index.sessions) ? index.sessions : []; - render(); + changed(); } document.addEventListener("visibilitychange", () => { if (!document.hidden) load().catch(() => {}); @@ -80,7 +79,7 @@ export function createSessionArchive({ // The archive's page says which conversation it shows (its `?s=`, or none // for its list) as it loads, the person moving in it too, answering the - // theme this page sends each frame: the heading and the row follow. + // theme this page sends each frame: the heading follows. // It asks to continue one (`continue`), or to open the chat it was // continued in (`chat`): the shell does either, as the person. addEventListener("message", (e) => { @@ -93,7 +92,6 @@ export function createSessionArchive({ } open = typeof d.key === "string" ? d.key : null; renderHeading(); - render(); }); // ---- Continue: a conversation of the archive, picked up as a chat ---- @@ -178,41 +176,6 @@ export function createSessionArchive({ } } - const shownOpen = () => store.get(OPEN, true) !== false; - - function render() { - const box = $("archive"); - box.hidden = !read.name || !read.entries.length; - if (box.hidden) return; - const toggle = $("archive-toggle"); - toggle.setAttribute("aria-expanded", String(shownOpen())); - toggle.onclick = () => { - store.set(OPEN, !shownOpen()); - render(); - }; - $("archive-rows").hidden = !shownOpen(); - const here = current() === read.name && !page(); - const rows = read.entries.slice(0, SHOWN).map((e) => { - const active = here && open === e.key; - const row = el("button", `row archive-row${active ? " active" : ""}`); - row.type = "button"; - row.dataset.key = `archived:${e.key}`; - row.setAttribute("aria-current", active ? "page" : "false"); - row.append(el("span", "label", e.title || "Untitled")); - row.onclick = () => { openSession(e.key); leaveSidebar(); }; - return row; - }); - if (read.entries.length > SHOWN) { - const all = el("button", "row archive-all"); - all.type = "button"; - all.dataset.key = "archived:all"; - all.append(el("span", "label", `All ${read.entries.length}`)); - all.onclick = () => { openSession(null); leaveSidebar(); }; - rows.push(all); - } - patch($("archive-rows"), rows); - } - // one conversation (its key), or with none the archive's list function openSession(key) { if (!read.name) return; @@ -223,5 +186,5 @@ export function createSessionArchive({ // what the middle column's heading says while the archive is open there const title = () => read.entries.find((e) => e.key === open)?.title || "Session Archive"; - return { load, render, title, continueSession }; + return { load, title, continueSession, archive: () => read.name, entries: () => read.entries, openSession }; } diff --git a/cell/shell/shell.css b/cell/shell/shell.css index e563be7fa..b40ac8758 100644 --- a/cell/shell/shell.css +++ b/cell/shell/shell.css @@ -148,14 +148,6 @@ body.resizing-row, body.resizing-row * { cursor: row-resize !important; } .sheet iframe { display: block; width: 100%; height: 26rem; max-height: none; border: 0; } .empty-row { font-size: 13px; color: var(--text-tertiary); padding: 6px 8px; } /* the Session Archive (session-archive.js): a quiet heading that folds its rows */ -.archive { display: flex; flex-direction: column; gap: 1px; margin: 8px 0; } -.archive-toggle { display: flex; align-items: center; gap: 4px; width: fit-content; padding: 4px 8px; border: 0; background: none; font: 600 12px/1.4 var(--font-sans); color: var(--text-tertiary); cursor: pointer; } -.archive-toggle:hover { color: var(--text-secondary); } -.archive-toggle svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: transform .14s; } -.archive-toggle[aria-expanded="true"] svg { transform: rotate(90deg); } -.archive .rows { max-height: 24vh; } -.archive-row .label { color: var(--text-secondary); } -.archive-row.active .label, .archive-all .label { color: var(--text-primary); } .spacer { flex: 1; } /* ---- chat column ---- */ @@ -706,8 +698,46 @@ iframe[data-blocked] { display: none; } .app-dock:disabled { opacity: .6; cursor: default; } .apps-archived { margin-top: 24px; } .apps-archived summary { padding: 6px 2px; color: var(--text-secondary); font-size: 14px; cursor: pointer; } +/* the History page (history-page.js): in the work area as the Apps page + is; a search box, All · Archived · Imported, then a row a chat: its face, + its title, whom it was with and its first words (or what the search + found), when it was last said, and Archived on the archived ones */ +.history-layout { display: flex; flex-direction: column; max-width: 820px; margin: 0 auto; padding: 34px clamp(20px, 4vw, 56px) 88px; } +.history-intro h1 { margin: 0 0 18px; font-size: clamp(26px, 2.5vw, 32px); font-weight: 600; line-height: 1.2; letter-spacing: -.035em; } +.history-search { display: flex; align-items: center; gap: 10px; height: 44px; padding: 0 14px; border: 1px solid var(--border-strong); border-radius: 12px; color: var(--text-tertiary); cursor: text; } +.history-search:focus-within { border-color: var(--text-tertiary); } +.history-search svg { width: 17px; height: 17px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.history-search input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; background: none; color: var(--text-primary); font: inherit; font-size: 15px; box-shadow: none; } +.history-search input:focus-visible { outline: none; } +.history-search input::placeholder { color: var(--text-tertiary); opacity: 1; } +.history-filters { display: flex; gap: 6px; margin: 12px 0 8px; } +.history-filter { height: 28px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--text-secondary); font: inherit; font-size: 13px; cursor: pointer; } +.history-filter:hover { background: var(--bg-hover); } +.history-filter[aria-pressed="true"] { border-color: transparent; background: var(--bg-active); color: var(--text-primary); } +.history-status { margin: 4px 2px 8px; color: var(--text-tertiary); font-size: 13px; } +.history-list { display: flex; flex-direction: column; } +.history-empty { margin: 16px 2px; font-size: 14px; } +.history-row { display: flex; align-items: center; gap: 8px; min-width: 0; border-radius: 12px; } +.history-row:hover { background: var(--bg-hover); } +.history-open { display: flex; flex: 1; align-items: center; gap: 12px; min-width: 0; padding: 9px 10px; border: 0; border-radius: inherit; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.history-face { display: grid; flex: none; place-items: center; width: 30px; height: 30px; color: var(--text-tertiary); } +.history-face .agent-avatar.small { width: 28px; height: 28px; } +.history-face > svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.history-main { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } +.history-line, .history-sub { display: flex; align-items: center; gap: 8px; min-width: 0; } +.history-line .label { min-width: 0; overflow: hidden; color: var(--text-primary); font-size: 14.5px; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } +.history-chip { flex: none; padding: 0 7px; border-radius: 999px; background: var(--bg-active); color: var(--text-secondary); font-size: 11px; line-height: 18px; } +.history-line time { flex: none; margin-left: auto; color: var(--text-tertiary); font-size: 12px; line-height: 20px; } +.history-sub { color: var(--text-tertiary); font-size: 13px; line-height: 18px; } +.history-with { flex: none; color: var(--text-secondary); } +.history-with + .history-preview::before { content: "·"; margin-right: 8px; color: var(--text-tertiary); } +.history-preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.history-preview.found { color: var(--text-secondary); } +.history-more { align-self: center; margin-top: 14px; height: 32px; padding: 0 16px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--text-secondary); font: inherit; font-size: 13px; cursor: pointer; } +.history-more:hover { background: var(--bg-hover); } @container settings (max-width: 620px) { .apps-layout { padding: 18px 16px 60px; } + .history-layout { padding: 18px 16px 60px; } .app-row-card { width: 72px; height: 45px; } .app-row-controls, .app-row-said, .app-row-progress { padding-left: 0; } .app-row-panel { margin-left: 0; padding: 16px; gap: 20px; } diff --git a/cell/shell/shell.js b/cell/shell/shell.js index 1da3b3fe1..ac3592e95 100644 --- a/cell/shell/shell.js +++ b/cell/shell/shell.js @@ -15,6 +15,7 @@ import { colorOf } from "./agent-color.js"; import { icon } from "./icons.js"; import { appArt, openIconSheet } from "./app-icon.js"; import { createAppsPage } from "./apps-page.js"; +import { createHistoryPage } from "./history-page.js"; import { createTitles } from "./fragment-title.js"; import { createLayout, store, reducedMotion, springFrames } from "./layout.js"; import { createViewer } from "./viewer.js"; @@ -865,10 +866,10 @@ const { renderHeading, renderChats, loadInvites, primaryAgent, newChatAgent, own listLive, load, invites: () => state.invites, setInvites: (v) => { state.invites = v; }, }); const sessionArchive = createSessionArchive({ - api, ui: { $, el, patch, renderHeading: () => renderHeading(), notice }, + api, ui: { renderHeading: () => renderHeading(), notice }, data: { allFragments: () => state.fragments, own, seg, primaryAgent: () => primaryAgent(), agents: () => state.agents, chatLabel: (agent) => chatLabel(agent) }, - selection: { current: () => state.current, page: () => state.page, getFrame: (name) => state.frames.get(name) }, - navigation: { openChat, leaveSidebar }, load: () => load(), store, + selection: { getFrame: (name) => state.frames.get(name) }, + navigation: { openChat, leaveSidebar }, load: () => load(), store, changed: () => historyPage.render(), }); // Delegation threads: a line each over the chat an agent asked from, its @@ -920,6 +921,7 @@ function openChat(name, push = true, path = null) { $("layout").classList.remove("settings-view"); $("settings-page").hidden = true; leaveApps(); + leaveHistory(); $("frames").hidden = false; state.current = name; store.set(CURRENT, name); @@ -930,7 +932,6 @@ function openChat(name, push = true, path = null) { for (const [n, frame] of state.frames) frame.hidden = n !== name; renderHeading(); renderChats(); - sessionArchive.render(); markSeen(); prewake(); navigated(); @@ -1442,10 +1443,12 @@ function computerIs(v) { } // ---- where the page is: `/` its chats, `/settings` its settings, `/apps` -// its apps, `/welcome` a new person's welcome; each view says so in the -// address, so a reload or a link stays put ---- +// its apps, `/history` every chat they have had, `/welcome` a new person's +// welcome; each view says so in the address, so a reload or a link stays +// put ---- const SETTINGS = "/settings"; const APPS = "/apps"; +const HISTORY = "/history"; const WELCOME = "/welcome"; function at(path) { if (location.pathname !== path) history.pushState(null, "", path); @@ -1454,6 +1457,7 @@ addEventListener("popstate", () => { const shown = !$("layout").hidden; if (shown && location.pathname === SETTINGS) openSettings(false).catch((e) => notice("Settings did not open", e.message)); else if (shown && location.pathname === APPS) openApps(false); + else if (shown && location.pathname === HISTORY) openHistory(false); else if (shown && location.pathname === "/" && byName(state.current)) openChat(state.current, false); else startOrWait(); }); @@ -1483,6 +1487,7 @@ function openApps(push = true) { $("layout").classList.add("settings-view"); $("frames").hidden = true; $("settings-page").hidden = true; + leaveHistory(); $("apps-page").hidden = false; renderHeading(); renderChats(); @@ -1496,6 +1501,37 @@ function leaveApps() { } $("apps-link").onclick = () => openApps(); +// ---- the History page (history-page.js): every chat the person has had, +// archived and imported ones too, and its search, in the work area as the +// Apps page is ---- +const historyPage = createHistoryPage($("history-page"), { + api, el, icon, mark: (name, size) => mark(name, size), chats, isThread: (f) => threads.isPair(f), own, chatTitle: (name) => chatTitle(name), + agentsOf, agents: () => state.agents, titleOf, + archive: { name: () => sessionArchive.archive(), entries: () => sessionArchive.entries(), openSession: (key) => sessionArchive.openSession(key) }, + openChat, leaveSidebar, +}); +function openHistory(push = true) { + if (push) at(HISTORY); + if (state.page !== "History") views++; + hideNotice(); + state.page = "History"; + $("layout").classList.add("settings-view"); + $("frames").hidden = true; + $("settings-page").hidden = true; + leaveApps(); + $("history-page").hidden = false; + renderHeading(); + renderChats(); + leaveSidebar(); + historyPage.open(); +} +// a chat, settings or apps in its place +function leaveHistory() { + $("history-page").hidden = true; + historyPage.close(); +} +$("history-link").onclick = () => openHistory(); + const settings = createSettings({ api, $, seg, account: { person: () => state.me, setPerson: (p) => { state.me = p; }, isGuest: () => state.guest, setGuest, @@ -1508,6 +1544,7 @@ const settings = createSettings({ $("layout").classList.add("settings-view"); $("frames").hidden = true; leaveApps(); + leaveHistory(); }, entered: () => { renderHeading(); renderChats(); leaveSidebar(); } }, notices: { hideNotice, noticeCurrent: (gen) => gen === noticeGen, notice }, @@ -1593,6 +1630,7 @@ async function load(changed = false) { sessionArchive.load().catch(() => {}); renderApps(); appsPage.listed().catch(() => {}); + historyPage.render(); renderHeading(); renderComputer(); rosterChanged(); @@ -1709,12 +1747,13 @@ async function start(open) { // of theirs (not the deployment's plan) may reach their ledger a moment // after it is given: its first run waits for it (`seatFirst`) // settings or the Apps page, as the address asks - const settings = !open && (location.pathname === SETTINGS || location.pathname === APPS); + const settings = !open && [SETTINGS, APPS, HISTORY].includes(location.pathname); if (!open && !settings && await owesFirstAgent()) return creatingAgent(mine?.seat ? {} : null); if (!state.fragments.length && !state.invites.length && !open && !settings && state.guest) return welcome(); showLayout(); const pick = open ?? (byName(state.current) ? state.current : (shown(chats())[0] ?? chats()[0])?.name); if (settings && location.pathname === APPS) openApps(false); + else if (settings && location.pathname === HISTORY) openHistory(false); else if (settings) await openSettings(false); else if (pick) openChat(pick); else if (state.guest) guestHome(mine.offer); diff --git a/cell/src/lib.rs b/cell/src/lib.rs index 14bdb1711..63db48732 100644 --- a/cell/src/lib.rs +++ b/cell/src/lib.rs @@ -1195,8 +1195,9 @@ async fn platform_answer(mut req: Request, env: &Env, ctx: &Context, cfg: &'stat None => Err(CellError::new(ErrorCode::NotFound, format!("no shell file {}", file.join("/")))), }, // the shell, for everyone: signed out it asks them to sign in; - // `/settings` opens its settings, `/apps` the person's apps, `/welcome` a new person's welcome - (Method::Get, [""] | ["settings"] | ["apps"] | ["welcome"]) => shell::page(&req, env, cfg, &url).await, + // `/settings` opens its settings, `/apps` the person's apps, `/history` + // every chat they have had, `/welcome` a new person's welcome + (Method::Get, [""] | ["settings"] | ["apps"] | ["history"] | ["welcome"]) => shell::page(&req, env, cfg, &url).await, // the operators' admin page: to anyone else, the 404 below (Method::Get, ["admin"]) => shell::admin_page(&req, env, cfg, &url).await, // Finite's about page, privacy policy and terms, for anyone (signed out too) diff --git a/cell/src/shell.rs b/cell/src/shell.rs index f12ff47b6..2e18daef9 100644 --- a/cell/src/shell.rs +++ b/cell/src/shell.rs @@ -1,8 +1,9 @@ //! The shell (docs/cloudflare-v1.md, decisions 6–12): the platform's one -//! page, at `/`, `/settings`, `/apps` and `/welcome`, Finite's about page, privacy -//! policy and terms at `/about`, `/privacy` and `/terms`, and its files at -//! `/__shell/`, the release's (cell/shell/), read from its Static -//! Assets (assets.rs). It holds no key: its script calls the API with +//! page, at `/`, `/settings`, `/apps`, `/history` and `/welcome`, Finite's +//! about page, privacy policy and terms at `/about`, `/privacy` and +//! `/terms`, and its files at `/__shell/`, the release's +//! (cell/shell/), read from its Static Assets (assets.rs). It holds no +//! key: its script calls the API with //! the person's platform session (lib.rs `shell_session`) and frames their //! fragments, each signed in on its own origin by the platform's frame mint. //! @@ -89,12 +90,13 @@ fn frame_src(cfg: &Config, url: &Url) -> String { format!("'self' {platform} {scheme}://*.{}{port}", cfg.host_suffix) } -/// `GET /`, `GET /settings`, `GET /apps` and `GET /welcome`: the shell's page, for -/// anyone (signed out, it asks them to sign in; the shell opens the view -/// its path names), naming the release that answers it and when it was -/// deployed (`fragment_templates::shell_page`): its script reloads at a -/// safe moment once a later answer names another, and Settings ends with -/// both (docs/api.md, The shell). A few KiB, read whole to fill it. +/// `GET /`, `GET /settings`, `GET /apps`, `GET /history` and `GET /welcome`: +/// the shell's page, for anyone (signed out, it asks them to sign in; the +/// shell opens the view its path names), naming the release that answers +/// it and when it was deployed (`fragment_templates::shell_page`): its +/// script reloads at a safe moment once a later answer names another, and +/// Settings ends with both (docs/api.md, The shell). A few KiB, read whole +/// to fill it. pub async fn page(req: &Request, env: &Env, cfg: &Config, url: &Url) -> CellResult { // its bytes are checked against their hash as they are read: UTF-8, as the release's index.html is let bytes = crate::assets::read(env, page_file("index.html")).await?; diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index d88c94543..f140cb09b 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -112,7 +112,7 @@ pub fn shell_page(text: &str, release: &str, deployed: Option) -> String { /// each with its content type: of the rest of cell/shell/, its pages are /// served at their own paths, and its credits and licenses not at all. #[cfg(feature = "platform")] -const SHELL_PUBLISHED: [(&str, &str); 47] = [ +const SHELL_PUBLISHED: [(&str, &str); 48] = [ // the cell's server-rendered pages' base look (share.rs `sheet_html`), // on the platform's origin and, as /__sheet.css, on a fragment's ("sheet.css", "text/css; charset=utf-8"), @@ -143,6 +143,8 @@ const SHELL_PUBLISHED: [(&str, &str); 47] = [ ("app-icon.js", "text/javascript; charset=utf-8"), // the Apps page: each app's dock, card, icon and who has access (docs/api.md, The shell) ("apps-page.js", "text/javascript; charset=utf-8"), + // the History page: every chat they have had, and its search (docs/api.md, The shell) + ("history-page.js", "text/javascript; charset=utf-8"), ("activity.js", "text/javascript; charset=utf-8"), ("fragment-title.js", "text/javascript; charset=utf-8"), ("tooltips.js", "text/javascript; charset=utf-8"), diff --git a/docs/api.md b/docs/api.md index 52804514e..9d3130e00 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3103,11 +3103,11 @@ means authorization is disabled. Existing own-key behavior below is unchanged. ## The shell (phase 5) -The platform's one page is `/`, `/settings`, `/apps` and `/welcome` -(cell/shell/, its files at `/__shell/`, the release's: Serving, -The release's files): its script reads the path, opening its -settings at `/settings`, its Apps page at `/apps`, the welcome at -`/welcome`, and the person's +The platform's one page is `/`, `/settings`, `/apps`, `/history` and +`/welcome` (cell/shell/, its files at `/__shell/`, the release's: +Serving, The release's files): its script reads the path, opening its +settings at `/settings`, its Apps page at `/apps`, its History at +`/history`, the welcome at `/welcome`, and the person's chats at `/`, and puts the view it shows in the address, so a reload stays put. Finite's about page, privacy policy and terms are `/about`, `/privacy` and `/terms` (cell/shell/about.html, privacy.html, @@ -3207,6 +3207,28 @@ Icons, and preview controls), and **Sharing** (theirs) or **People** refusal is said under its row. Settings have no sharing section: who may open what is the Apps page's. +**Its History** (`/history`, the sidebar's History, above Apps; in the +work area as the Apps page is; cell/shell/history-page.js; Paul, +2026-10-31: every chat goes to a good place, so archiving is cheap and +the sidebar stays short) lists every chat the person has had, the newest +first (a chat with nothing said yet first, as in the sidebar): their +chats, archived ones too, those shared with them (a delegation thread's +chat is its thread's line, not listed), and their Session Archive's +conversations (its `index/chat.json`: templates/sessions/README.md). +Each row is the chat's face, its title, whom it was with (its lead +agent, when it is one of theirs; "Shared" on one someone else owns; +"Imported" on the archive's), its first words, when it was last said +(the time today, else the day, with its year past this one), and +"Archived" on the ones they archived. All, Archived and Imported filter +it. Its search box filters by title as it is typed (an import's first +words too), and once typing rests asks `GET /api/search` (its first 8 +words and 256 bytes) for the messages that hold its words: each chat +found there is listed once, its newest snippet in place of its first +words; a search that fails says so above the rows and filters by title +alone. A hundred rows show, then Show more. A row opens its chat in the +middle column, or an import read-only as its archive's page shows it +(`/?s=`). + The row's title, icon and preview open the app; its address is visible beside an open-in-new-tab link. The pencil edits the display name inline, through the manifest's `meta.title`, leaving the address alone. Dock is a @@ -3254,7 +3276,7 @@ else their main agent): the ones they pinned (`PUT first (by the row's `previewAt`; one with nothing said yet above them all), five of them (the open one too, wherever it is), then More, which shows the rest, and Less. Chats someone else shared with them are the last -section, Shared. Its apps (kind `app` or `brain`) +section, Shared. At its foot History (above), Apps and Settings. Its apps (kind `app` or `brain`) are the desktop's: a dock of their icons and titles under the viewer's windows, less the archived and those they took off it (`PUT /api/fragments/{name}/dock`, above; search still finds them), then Add, open beside the chat on a diff --git a/xtask/src/js_syntax.rs b/xtask/src/js_syntax.rs index 850d99b89..abf08615d 100644 --- a/xtask/src/js_syntax.rs +++ b/xtask/src/js_syntax.rs @@ -62,7 +62,7 @@ const SHELL: &str = "cell/shell/shell.js"; const TEST_SUFFIX: &str = ".test.mjs"; /// The shell's tests, which a walk must find: the chat page's control.js's, /// chat-list.js's, onboarding.js's and threads.js's. -const SHELL_TESTS: [&str; 4] = ["cell/shell-tests/chat-control.test.mjs", "cell/shell-tests/chat-list.test.mjs", "cell/shell-tests/onboarding.test.mjs", "cell/shell-tests/threads.test.mjs"]; +const SHELL_TESTS: [&str; 5] = ["cell/shell-tests/chat-control.test.mjs", "cell/shell-tests/chat-list.test.mjs", "cell/shell-tests/history-page.test.mjs", "cell/shell-tests/onboarding.test.mjs", "cell/shell-tests/threads.test.mjs"]; /// How Node is told to parse a file (`--input-type`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] From c03dcf2c204df4afb2cbe28e7fad06f1c6a4e36e Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:26:57 -0500 Subject: [PATCH 02/14] shell: a message sent in an archived chat brings it back A chat's page tells the shell as the person's message is sent there (`{fragment: "said"}`), and the shell unarchives a chat they archived, as an email thread comes back once it is written in again (History; Paul, 2026-10-31). Co-Authored-By: Claude Opus 5.5 --- cell/shell/history-page.js | 3 ++- cell/shell/shell.js | 9 +++++++++ docs/api.md | 9 +++++++-- templates/chat/site/chat.js | 9 +++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/cell/shell/history-page.js b/cell/shell/history-page.js index c923fcc9b..4f660639f 100644 --- a/cell/shell/history-page.js +++ b/cell/shell/history-page.js @@ -16,7 +16,8 @@ // conversations are matched by their titles and first words alone until // the archive is searched too (#774), through `h.archive.search` (q → // [{key, snippet}]) once it has one. -// Opening a row opens it in the middle column. +// Opening a row opens it in the middle column; a message the person sends +// in a chat they archived brings it back to the sidebar (shell.js, `said`). // Archived and imported rows end with `h.continueControl(source)` once it // is given (continue.js: Continue with an agent of theirs). // diff --git a/cell/shell/shell.js b/cell/shell/shell.js index ac3592e95..04041df87 100644 --- a/cell/shell/shell.js +++ b/cell/shell/shell.js @@ -1709,6 +1709,15 @@ const refresh = createSessionRefresh({ }); const { heard, navigated, reopenWindows, release, DEPLOYED } = refresh; const frameOfWindow = (win) => [...state.frames.values()].find((f) => f.contentWindow === win); +// A chat's page says the person sent a message there (`said`): a chat they +// archived comes back to their sidebar, as an email thread does once it is +// written in again (the History page; Paul, 2026-10-31). Their own flag, +// of the frame's own chat. +addEventListener("message", (event) => { + if (event.data?.fragment !== "said") return; + const name = frameOfWindow(event.source)?.dataset.fragment; + if (name && byName(name)?.kind === "chat" && byName(name).archived) setArchived(name, false); +}); addEventListener("message", (event) => { const d = event.data; if (d?.fragment !== "agents?" && d?.fragment !== "busy") return; diff --git a/docs/api.md b/docs/api.md index 9d3130e00..aaed31dd3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3227,7 +3227,9 @@ found there is listed once, its newest snippet in place of its first words; a search that fails says so above the rows and filters by title alone. A hundred rows show, then Show more. A row opens its chat in the middle column, or an import read-only as its archive's page shows it -(`/?s=`). +(`/?s=`). A message the person sends in a chat they archived brings +it back to their sidebar: its page says so (`{fragment: "said"}`, below) +and the shell sends `PUT /api/fragments/{name}/archived` `false`. The row's title, icon and preview open the app; its address is visible beside an open-in-new-tab link. The pencil edits the display name inline, @@ -3592,7 +3594,10 @@ storage for the frame keeps no draft). Only that person gets it back: a draft of anyone else's is let go as the page loads, and signing out or Use another account first asks each chat's page to let its draft go (`{fragment: "forget-draft"}`, answered `{fragment: "draft-forgotten"}`, -waited for at most a second), then sends the form. **Nothing else is +waited for at most a second), then sends the form. A chat's page tells +the shell each time the person's message is sent there (`{fragment: +"said"}`), which brings a chat they archived back to their sidebar +(History, above). **Nothing else is lost**: a chat's page tells the shell, as it changes, whether it is busy with what no storage keeps (`{fragment: "busy", busy}`: files chosen, a voice memo, a message on its way or one that failed (its Retry, a retry diff --git a/templates/chat/site/chat.js b/templates/chat/site/chat.js index 2f7a955f6..b8ef4375b 100644 --- a/templates/chat/site/chat.js +++ b/templates/chat/site/chat.js @@ -2574,6 +2574,7 @@ export function mount(root) { await fragment.post("chat", body, { id }); unsent = null; $("banner").hidden = true; + tellSaid(); return; } const to = addressed(text); @@ -2605,6 +2606,7 @@ export function mount(root) { renderQuoting(); $("banner").hidden = true; expectTurn(record, to, text); + tellSaid(); } catch (err) { if (!input.value) input.value = typed; for (const f of files) f.uploading = false; @@ -2617,6 +2619,13 @@ export function mount(root) { } } + // The shell that frames this page is told the person sent a message here + // (`{fragment: "said"}`): a chat they archived comes back to their + // sidebar (docs/api.md, The shell's History). + function tellSaid() { + if (framed && state.me) window.parent.postMessage({ fragment: "said" }, state.me.platform); + } + // Someone signed in, in a chat with an agent: a turn starts on it soon // (the one it names, else the lead), unless that agent is busy. function expectTurn(record, to, text, since = Date.now()) { From ce30865033c063ddc4cdf5f6da5b0fd8e2decc0b Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:31:37 -0500 Subject: [PATCH 03/14] shell: History lists what was said, each row with its time A chat whose list row says nothing was said yet has its first record read, once while its row is the same: a continued chat's history card gives its time and newest words, a message its time and first line, and a chat with none, never used, is no history and is left out. A row without its time is not shown until it has one, and nothing sorts as the newest for want of a time. Co-Authored-By: Claude Opus 5.5 --- cell/shell-tests/history-page.test.mjs | 17 +++++-- cell/shell/history-page.js | 68 ++++++++++++++++++++++---- cell/shell/shell.js | 2 +- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/cell/shell-tests/history-page.test.mjs b/cell/shell-tests/history-page.test.mjs index 99bd3940d..3534dfb09 100644 --- a/cell/shell-tests/history-page.test.mjs +++ b/cell/shell-tests/history-page.test.mjs @@ -7,16 +7,25 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { historyRows, hintOf, when } from "../shell/history-page.js"; +import { firstOf, historyRows, hintOf, when } from "../shell/history-page.js"; const chat = (name, at, extra = {}) => ({ name, title: name, at, archived: false, ...extra }); const entry = (key, ended_at, extra = {}) => ({ key, title: key, ended_at, preview: "", ...extra }); const keys = (rows) => rows.map((r) => r.key); -const chats = [chat("trip", 30), chat("taxes", 10, { archived: true }), chat("made-now", undefined)]; +const chats = [chat("trip", 30), chat("taxes", 10, { archived: true }), chat("no-time", undefined)]; const entries = [entry("old-talk", "1970-01-01T00:00:00.020Z", { preview: "about the garden" }), entry("no-time", undefined)]; -test("every chat and import, the newest first: a chat with no message yet first, an import with no time last", () => { - assert.deepEqual(keys(historyRows({ chats, entries })), ["chat:made-now", "chat:trip", "imported:old-talk", "chat:taxes", "imported:no-time"]); +test("every chat and import, the newest first; one that says no time last, never the newest", () => { + assert.deepEqual(keys(historyRows({ chats, entries })), ["chat:trip", "imported:old-talk", "chat:taxes", "chat:no-time", "imported:no-time"]); +}); + +test("a chat's first record: a history card's time and newest words, a message's first line, none nothing", () => { + const card = { at: 7, body: { kind: "history", title: "t", summary: "the summary\nmore", messages: [{ role: "user", text: "first" }, { role: "assistant", text: "\n the last words\nand more" }] } }; + assert.deepEqual(firstOf(card), { at: 7, line: "the last words" }); + assert.deepEqual(firstOf({ at: 8, body: { kind: "history", summary: "only a summary", messages: [] } }), { at: 8, line: "only a summary" }); + assert.deepEqual(firstOf({ at: 9, body: { text: "hello there\nsecond" } }), { at: 9, line: "hello there" }); + assert.deepEqual(firstOf({ at: 10, body: { kind: "stop", turn: "x" } }), { at: 10, line: "" }); + assert.equal(firstOf(undefined), null); }); test("Archived lists the archived chats alone; Imported, the archive's conversations alone", () => { diff --git a/cell/shell/history-page.js b/cell/shell/history-page.js index 4f660639f..ad6f7120c 100644 --- a/cell/shell/history-page.js +++ b/cell/shell/history-page.js @@ -4,12 +4,19 @@ // because you know you can always search it"). It lists: // - their chats, live and archived (the list's `archived`), and those // shared with them; a delegation thread's chat is its thread's line -// (threads.js), not a chat of its own here; +// (threads.js), not a chat of its own here, and a chat nothing was ever +// said in is no history (its first record, below); // - their Session Archive's conversations (session-archive.js: its // index), each opening read-only as the archive's page shows it. // A row says its title, whom it was with (its agent's face and name; // "Imported" for the archive's), when it was last said, and "Archived" on // the ones the person archived. All, Archived and Imported filter it. +// A chat whose list row says nothing was said there yet (no `previewAt`: +// a continued chat holds its history card alone until its agent speaks) +// has its first record read, once while its row is the same: a history +// card gives its time and its newest words, a message its time and first +// line, and none leaves it out. Until it is read it is not listed: no +// row shows without its time, nor sorts as the newest for want of one. // The search box filters by title as it is typed, and asks the person's // search (`GET /api/search`) for the messages that hold its words: each // chat found there once, with its newest snippet under it. The archive's @@ -22,7 +29,7 @@ // is given (continue.js: Continue with an agent of theirs). // // The shell owns the page's place and its helpers (`h`): `api`, `el`, -// `icon`, `mark` (a chat's face), `chats`, `isThread`, `own`, `chatTitle`, +// `icon`, `seg`, `mark` (a chat's face), `chats`, `isThread`, `own`, `chatTitle`, // `agentsOf`, `agents` (the person's own, by identity), `titleOf`, // `archive` ({name, entries, openSession}), `openChat`, `leaveSidebar` and // `continueControl`. @@ -34,6 +41,9 @@ const HINT_MAX_BYTES = 256; const SEARCH_WAIT_MS = 250; // the rows drawn at first, and again with each Show more const ROWS_STEP = 100; +// chats whose first record is read at once, and at most per drawing +const FIRSTS_AT_ONCE = 4; +const FIRSTS_MAX = 32; const FILTERS = [["all", "All"], ["archived", "Archived"], ["imported", "Imported"]]; const wordsOf = (q) => q.toLowerCase().split(/\s+/).filter(Boolean); @@ -46,6 +56,16 @@ export function hintOf(q) { // An imported conversation's time: when it was last said, else begun // (the archive's UTC times; README's "Times are UTC"). const importedAt = (e) => Date.parse(e.ended_at ?? e.started_at ?? "") || 0; +const firstLine = (text) => (typeof text === "string" ? text.split("\n").map((l) => l.trim()).find(Boolean) ?? "" : ""); +// What a chat's first record says of it (docs/chat-records.md): a history +// card its time and its newest words (else its summary's), a message its +// time and its first line; anything else its time alone. +export function firstOf(record) { + if (!record) return null; + const b = record.body; + if (b?.kind === "history") return { at: record.at, line: firstLine(b.messages?.at(-1)?.text) || firstLine(b.summary) }; + return { at: record.at, line: firstLine(typeof b === "string" ? b : b?.text) }; +} // When a row was last said, as History says it: the time today, else the // day, with its year when it is not this one. export function when(at, now = Date.now()) { @@ -77,10 +97,8 @@ export function historyRows({ chats, entries, filter = "all", q = "", found = ne rows.push({ kind: "imported", key: `imported:${e.key}`, at: importedAt(e), entry: e, title: e.title || "Untitled", snippet }); } } - // a chat made now (no message yet) first, as the sidebar has it; an - // import that says no time, last - const rank = (r) => r.at || (r.kind === "chat" ? Infinity : 0); - return rows.sort((a, b) => rank(b) - rank(a) || (a.key < b.key ? -1 : 1)); + // one that says no time last, never the newest + return rows.sort((a, b) => (b.at || 0) - (a.at || 0) || (a.key < b.key ? -1 : 1)); } export function createHistoryPage(page, h) { @@ -93,6 +111,11 @@ export function createHistoryPage(page, h) { const search = { q: "", asked: 0, timer: 0, found: new Map(), foundImported: new Map(), failed: "" }; // each row's Continue, kept across drawings (what it is doing is its own) const controls = new Map(); + // a chat with nothing said yet: its first record as read, by name, with + // its list row as it was then (`row`), read again once that changes + // (`first`: {at, line}; null for none; undefined while it is read) + const firsts = new Map(); + let reading = 0; let parts = null; function build() { @@ -178,6 +201,26 @@ export function createHistoryPage(page, h) { }, SEARCH_WAIT_MS); } + // The first records of the chats that need them, a few at a time; each + // answer draws the page again. One that failed is read again only once + // its row changes, or the page opens again. + function readFirsts(list) { + for (const f of list) { + if (reading >= FIRSTS_AT_ONCE) return; + const row = JSON.stringify(f); + if (firsts.get(f.name)?.row === row) continue; + firsts.set(f.name, { row, first: undefined }); + reading++; + h.api("GET", `/api/f/${h.seg(f.name)}/channels/chat?after=0&limit=1`) + .then((r) => { if (firsts.get(f.name)?.row === row) firsts.set(f.name, { row, first: firstOf(r.records?.[0]) }); }) + .catch(() => { if (firsts.get(f.name)?.row === row) firsts.set(f.name, { row, first: null, failed: true }); }) + .finally(() => { + reading--; + render(); + }); + } + } + // whom a chat was with: its lead agent's name, when it is one of the // person's own; a chat shared with them says so function withWhom(f) { @@ -252,9 +295,14 @@ export function createHistoryPage(page, h) { function render() { if (!shown) return; if (!parts) build(); - const chats = h.chats().filter((f) => !h.isThread(f)).map((f) => ({ - name: f.name, title: h.chatTitle(f.name), at: f.previewAt, archived: !!f.archived, preview: f.preview ?? "", with: withWhom(f), - })); + const all = h.chats().filter((f) => !h.isThread(f)); + const unsaid = all.filter((f) => !f.previewAt); + readFirsts(unsaid.filter((f) => firsts.get(f.name)?.row !== JSON.stringify(f)).slice(0, FIRSTS_MAX)); + const chats = all.flatMap((f) => { + const first = f.previewAt ? null : firsts.get(f.name)?.first; + if (!f.previewAt && !first?.at) return []; + return [{ name: f.name, title: h.chatTitle(f.name), at: f.previewAt ?? first.at, archived: !!f.archived, preview: f.preview ?? first.line, with: withWhom(f) }]; + }); const entries = h.archive.name() ? h.archive.entries() : []; const searched = hintOf(q) === search.q; const rows = historyRows({ chats, entries, filter, q, ...(searched ? { found: search.found, foundImported: search.foundImported } : {}) }); @@ -276,6 +324,8 @@ export function createHistoryPage(page, h) { return { open() { shown = true; + // what failed to be read is read again + for (const [name, f] of firsts) if (f.failed) firsts.delete(name); render(); parts.input.focus({ preventScroll: true }); }, diff --git a/cell/shell/shell.js b/cell/shell/shell.js index 04041df87..38d265ea5 100644 --- a/cell/shell/shell.js +++ b/cell/shell/shell.js @@ -1505,7 +1505,7 @@ $("apps-link").onclick = () => openApps(); // archived and imported ones too, and its search, in the work area as the // Apps page is ---- const historyPage = createHistoryPage($("history-page"), { - api, el, icon, mark: (name, size) => mark(name, size), chats, isThread: (f) => threads.isPair(f), own, chatTitle: (name) => chatTitle(name), + api, el, icon, seg, mark: (name, size) => mark(name, size), chats, isThread: (f) => threads.isPair(f), own, chatTitle: (name) => chatTitle(name), agentsOf, agents: () => state.agents, titleOf, archive: { name: () => sessionArchive.archive(), entries: () => sessionArchive.entries(), openSession: (key) => sessionArchive.openSession(key) }, openChat, leaveSidebar, From a9cc5b9d282919173b88728532f9406e0c453c3d Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:31:46 -0500 Subject: [PATCH 04/14] docs: History lists what was said, each row with its time Co-Authored-By: Claude Opus 5.5 --- docs/api.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index aaed31dd3..38f774834 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3211,10 +3211,16 @@ may open what is the Apps page's. work area as the Apps page is; cell/shell/history-page.js; Paul, 2026-10-31: every chat goes to a good place, so archiving is cheap and the sidebar stays short) lists every chat the person has had, the newest -first (a chat with nothing said yet first, as in the sidebar): their -chats, archived ones too, those shared with them (a delegation thread's -chat is its thread's line, not listed), and their Session Archive's -conversations (its `index/chat.json`: templates/sessions/README.md). +first: their chats, archived ones too, those shared with them (a +delegation thread's chat is its thread's line, not listed), and their +Session Archive's conversations (its `index/chat.json`: +templates/sessions/README.md). A chat whose row says nothing was said +there yet (no `previewAt`) has its first record read (`GET +/api/f/{name}/channels/chat?after=0&limit=1`), once while its row is the +same: a continued chat's history card gives its time and newest words, a +message its time and first line, and a chat with none, never used, is +not listed. No row shows without its time, nor sorts as the newest for +want of one. Each row is the chat's face, its title, whom it was with (its lead agent, when it is one of theirs; "Shared" on one someone else owns; "Imported" on the archive's), its first words, when it was last said From 8bba0a2d0e82e63898a998ebff62c192ac007b29 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:34:09 -0500 Subject: [PATCH 05/14] shell: an import opened again leaves Back to the view before Opening one of the archive's conversations in its frame, already shown, set the frame's src, which adds to the tab's history: Back then went to the conversation before, in the frame, not to History. The frame's page is replaced in place instead (a cross-origin frame's location.replace), so one Back is the view before, as it is for a frame made new. Co-Authored-By: Claude Opus 5.5 --- cell/shell/shell.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cell/shell/shell.js b/cell/shell/shell.js index 38d265ea5..89bce0dd6 100644 --- a/cell/shell/shell.js +++ b/cell/shell/shell.js @@ -301,8 +301,11 @@ function frameOf(src, title, name = null) { // navigation it makes once it does (frameOf) const unready = new WeakMap(); // A frame sent to `src`: at once, or, its origin not known yet, once it is. -function go(frame, src) { +// `replace`: in place of the page it shows, adding nothing to the tab's +// history (a cross-origin frame's `location.replace` is one it may call). +function go(frame, src, replace = false) { if (unready.has(frame)) unready.set(frame, src); + else if (replace && frame.contentWindow) frame.contentWindow.location.replace(new URL(src, location.href).href); else frame.src = src; } @@ -928,7 +931,9 @@ function openChat(name, push = true, path = null) { hideNotice(); const made = !state.frames.has(name); chatFrame(name, path ?? undefined); - if (path && !made) go(state.frames.get(name), framed(name, path)); + // a frame shown before goes to the page asked in place of the one it + // showed, so Back goes to the view before (History), not to that page + if (path && !made) go(state.frames.get(name), framed(name, path), true); for (const [n, frame] of state.frames) frame.hidden = n !== name; renderHeading(); renderChats(); From 5b6e722e42319f3b36dafc7f35c647fc18e3cb68 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:12:32 -0500 Subject: [PATCH 06/14] shell: a history card holds its summary, then what was said after it Continue's history card put the conversation's last summary first and then the newest messages, among them messages from before the summary: a compressed conversation read "Earlier, summarized" above what it had summarized. The summary covers what came before it, so the card's messages are now drawn from what was said after the last summary alone; `earlier` still counts every other message the conversation holds. Test (fails before): a conversation of two messages, a summary and two more keeps the summary and the last two, `earlier` 2. Co-Authored-By: Claude Opus 5.5 --- cell/shell/session-archive.js | 12 +++++++++--- docs/chat-records.md | 7 ++++--- xtask/src/session_archive_js.rs | 13 +++++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/cell/shell/session-archive.js b/cell/shell/session-archive.js index 74d107cb3..acce8aef1 100644 --- a/cell/shell/session-archive.js +++ b/cell/shell/session-archive.js @@ -23,8 +23,14 @@ const bytesOf = (v) => new TextEncoder().encode(JSON.stringify(v)).length; const cutTo = (text, max) => ([...text].length > max ? `${[...text].slice(0, max - 1).join("")}…` : text); export function historyOf(c, key, archive) { - const said = (c.messages ?? []).filter((m) => (m.role === "user" || m.role === "assistant") && typeof m.text === "string" && m.text.trim() && !m.summary && !m.tool_calls?.length); - const summary = [...(c.messages ?? [])].reverse().find((m) => m.summary && typeof m.text === "string" && m.text.trim())?.text ?? ""; + const all = c.messages ?? []; + const isSaid = (m) => (m.role === "user" || m.role === "assistant") && typeof m.text === "string" && m.text.trim() && !m.summary && !m.tool_calls?.length; + const said = all.filter(isSaid); + // the last summary covers what was said before it: the card holds it, + // then the newest of what was said after it (the rest counted `earlier`) + const last = all.findLastIndex((m) => m.summary && typeof m.text === "string" && m.text.trim()); + const summary = last >= 0 ? all[last].text : ""; + const after = all.slice(last + 1).filter(isSaid); const base = { kind: "history", title: cutTo(c.title || "Untitled", HISTORY_LIMITS.title), archive, key, started_at: c.started_at, ended_at: c.ended_at, ...(summary ? { summary: cutTo(summary, HISTORY_LIMITS.summary) } : {}), messages: [], earlier: said.length, @@ -34,7 +40,7 @@ export function historyOf(c, key, archive) { // the newest that fit, oldest first: each adds its JSON and a comma let size = bytesOf(base); const tail = []; - for (const m of [...said].reverse()) { + for (const m of [...after].reverse()) { if (tail.length >= HISTORY_LIMITS.messages) break; const one = { role: m.role, at: m.at, text: cutTo(m.text.trim(), HISTORY_LIMITS.text) }; const more = bytesOf(one) + (tail.length ? 1 : 0); diff --git a/docs/chat-records.md b/docs/chat-records.md index 19171b255..4ba82240b 100644 --- a/docs/chat-records.md +++ b/docs/chat-records.md @@ -353,9 +353,10 @@ templates/sessions/README.md): - Posted by the chat's owner, the shell's Continue, with the id `history`, before the agent joins: the chat's first record. It holds the conversation's last summary (`summary`, at most 8,000 characters) - and the newest of what the person and the agent said, oldest first - (`messages`, at most 40, each at most 4,000 characters), with how many - came before them (`earlier`), bounded as a whole by its body's bytes + and the newest of what the person and the agent said after it (the + summary covers what came before), oldest first (`messages`, at most 40, + each at most 4,000 characters), with how many others the conversation + holds (`earlier`), bounded as a whole by its body's bytes (63 KiB of JSON, under a record's 64 KiB, whatever the text: the newest that fit; a summary too large for it alone is cut) before the chat is made (cell/shell/session-archive.js, `historyOf`). `archive` and `key` diff --git a/xtask/src/session_archive_js.rs b/xtask/src/session_archive_js.rs index d4718c3c3..2dc7278a5 100644 --- a/xtask/src/session_archive_js.rs +++ b/xtask/src/session_archive_js.rs @@ -133,6 +133,19 @@ for (const [name, c] of [ await t.archive.continueSession("hermes-c1"); check(`${name}: Continue makes one chat, its history posted, its agent in it`, w.creates === 1 && w.posts === 1 && w.members.size === 1 && t.opened.length === 1 && !t.notices.length, { creates: w.creates, posts: w.posts, notices: t.notices }); } +{ + // a summary covers what was said before it: the card holds it, then what + // was said after it, never what it summarized + const c = { id: "c1", title: "Beans", messages: [ + { role: "user", at: "2026-10-01T09:00:00.000Z", text: "before one" }, + { role: "assistant", at: "2026-10-01T09:00:01.000Z", text: "before two" }, + { role: "user", at: "2026-10-02T09:00:00.000Z", text: "what we said before", summary: true }, + { role: "user", at: "2026-10-02T09:00:01.000Z", text: "after one" }, + { role: "assistant", at: "2026-10-02T09:00:02.000Z", text: "after two" }, + ] }; + const h = historyOf(c, "hermes-c1", ARCHIVE); + check("a summary is followed by what was said after it alone, the rest counted earlier", h.summary === "what we said before" && JSON.stringify(h.messages.map((m) => m.text)) === JSON.stringify(["after one", "after two"]) && h.earlier === 2, h); +} { const c = conversation(said(3, "short"), "earlier, summarized"); const h = historyOf(c, "hermes-c1", ARCHIVE); From f0c2a31df62f4b69a8f43d172368726e2857f614 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:22:35 -0500 Subject: [PATCH 07/14] shell: Continue with an agent of your choosing, from an import or an archived chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paul (FIN-223, after the Session Archive screenshots): a History view lists every chat, and "an archived chat can be resumed with the agent of your choosing". Continue moves out of session-archive.js into continue.js, the seam the History page (shell agent) calls: - `createContinue(...)` → `{ agents, defaultAgent, continued, continueInto, continueControl, ask }`, for a source `{ archive, key }` (an import) or `{ chat }` (an archived chat), and an agent's identity. - One chat a (source, agent): its name is made from both (the agent's chat label and a hash), so Continue asked again in any tab opens it, and each step can be taken again (409 on its own name holding this source's history goes on; the same history id; the membership a PUT). A name another fragment holds gives way to one at random, kept until done. A chat an archive marker names (Continue before this) still opens for its agent. - The agent offered first: an import's, the person's agent of its own agent's name; an archived chat's, its lead agent when theirs; else their main one. - An archived chat's history is made from its `chat` records; `agent` says whose the agent's words were when another agent continues it, and the chat's page and the bridge's note say so (add-only: an older page or bridge reads them as before). - `continueControl(source)`: "Continue with [agent ▾]" on one line, its CSS its own block in shell.css; the archive's page asks the shell, which shows it in a dialog. The page no longer reads its marker. Tests: xtask/src/continue_js.rs (29 cases: bounds, summary order, the agent offered, one chat a source and agent across tabs, every step cut short or its answer lost, a refused create, a taken name, an old marker, an archived chat's history); images bridge `note` tests for `agent` and `chat`; the e2e sessions-ui section continues through the shell's dialog and reopens the same chat. Co-Authored-By: Claude Opus 5.5 --- SPECIAL-CASE-INVENTORY.md | 2 +- cell/shell/continue.js | 316 ++++++++++++++++++++++++++++ cell/shell/history-page.js | 4 +- cell/shell/session-archive.js | 135 +----------- cell/shell/shell.css | 14 ++ cell/shell/shell.js | 20 +- crates/e2e/src/lanes/sessions.rs | 21 +- crates/templates/src/lib.rs | 1 + docs/chat-records.md | 50 +++-- images/bridge/src/note.rs | 36 +++- templates/chat/site/chat.js | 7 +- templates/sessions/site/sessions.js | 12 +- xtask/src/continue_js.rs | 305 +++++++++++++++++++++++++++ xtask/src/main.rs | 2 +- xtask/src/session_archive_js.rs | 245 --------------------- 15 files changed, 749 insertions(+), 421 deletions(-) create mode 100644 cell/shell/continue.js create mode 100644 xtask/src/continue_js.rs delete mode 100644 xtask/src/session_archive_js.rs diff --git a/SPECIAL-CASE-INVENTORY.md b/SPECIAL-CASE-INVENTORY.md index b7dfb0bfd..03dc0048c 100644 --- a/SPECIAL-CASE-INVENTORY.md +++ b/SPECIAL-CASE-INVENTORY.md @@ -15,7 +15,7 @@ a vendor's product beyond the credential it holds. | Surface | Why it is platform | Built on | |---|---|---| | Sign-in, sessions, CLI approval (`/auth/*`, `/cli`; the shell's tabs sign in through `/auth/frame`; a stranger's fragment asks first, at `/auth/fragment`) | Security: it mints the credentials every other surface trusts. Which of a browser's cookies count on a fragment's origin is the router's (Fetch Metadata), so another fragment's page reaches it as a stranger (docs/api.md, Sign-in). | WorkOS AuthKit; the registry | -| The shell (`/`, `/settings`, `/apps`, `/welcome`): sidebar, desktop (its dock and windows), settings, the Apps page (each of your fragments but chats and agents: your dock, its card, its icon, who has access and its share sheet; one page, as it acts as you over all of them, which no fragment may), the launcher (search), first run (the welcome); your Session Archive (your one fragment of kind `sessions`: its chats' index under a heading below your chats, each opened read-only in the middle column, its own page framed as a chat is; its Continue, asked by that page and done by the shell as you, makes a chat with your agent whose first record is the conversation's history: templates/sessions/README.md, docs/chat-records.md); to a page of a fragment you own that asks, your agents, and adding one of them to that fragment; to your own chat's page, a way to ask for an app put in its composer (`compose`; docs/api.md, The shell); to a chat's page of yours, its alerts, which no frame may ask the browser for (the shell's alerts worker, one subscription a chat, made with the chat's own key); what your agent is doing, which it reads from your chats' rows' listed records by the chat's turn kinds (docs/chat-records.md): the shell is that contract's third reader, beside the bridge and the chat's page, so a change to it changes the shell too | Security: it holds your session and frames your fragments and computers. The thin page users can't break. A page asks it, never grants: the shell checks the page is your own fragment's, at its own origin, offers only your own agents, and adds one only once you confirm it in the shell's own dialog, every time. A chat's page takes `compose` only from the platform's origin (its hello's `platform`) and sends nothing: you do. | The public fragment, computer, identity and ledger APIs | +| The shell (`/`, `/settings`, `/apps`, `/welcome`): sidebar, desktop (its dock and windows), settings, the Apps page (each of your fragments but chats and agents: your dock, its card, its icon, who has access and its share sheet; one page, as it acts as you over all of them, which no fragment may), the launcher (search), first run (the welcome); your Session Archive (your one fragment of kind `sessions`: its chats' index under a heading below your chats, each opened read-only in the middle column, its own page framed as a chat is; its Continue, asked by that page and done by the shell as you, makes a chat with the agent of yours you pick (one a conversation and agent) whose first record is the conversation's history, and does the same for an archived chat of yours: templates/sessions/README.md, docs/chat-records.md); to a page of a fragment you own that asks, your agents, and adding one of them to that fragment; to your own chat's page, a way to ask for an app put in its composer (`compose`; docs/api.md, The shell); to a chat's page of yours, its alerts, which no frame may ask the browser for (the shell's alerts worker, one subscription a chat, made with the chat's own key); what your agent is doing, which it reads from your chats' rows' listed records by the chat's turn kinds (docs/chat-records.md): the shell is that contract's third reader, beside the bridge and the chat's page, so a change to it changes the shell too | Security: it holds your session and frames your fragments and computers. The thin page users can't break. A page asks it, never grants: the shell checks the page is your own fragment's, at its own origin, offers only your own agents, and adds one only once you confirm it in the shell's own dialog, every time. A chat's page takes `compose` only from the platform's origin (its hello's `platform`) and sends nothing: you do. | The public fragment, computer, identity and ledger APIs | | Identity and delegation | Security: who an agent acts for, and at what role. | The registry | | Optional public addresses (`GET`/`PUT /api/f/{name}/address`) | Security: first-come reservations coordinate across fragments and permanently bind an origin to one app identity, so a delete never gives another app its cookies or storage. | The registry and each fragment's own identity and owner | | Connections and the egress swap | Security: it holds the route to your accounts' tokens and the operator's keys. | WorkOS Pipes; the computer's intercepts | diff --git a/cell/shell/continue.js b/cell/shell/continue.js new file mode 100644 index 000000000..d94d7cf25 --- /dev/null +++ b/cell/shell/continue.js @@ -0,0 +1,316 @@ +// Continue (docs/chat-records.md, "History"): a conversation the person +// had, picked up as a new chat with one of their agents, of their +// choosing. Its source is a conversation of their Session Archive +// (`{ archive, key }`, an import: templates/sessions/README.md) or an +// archived chat of theirs (`{ chat }`). The new chat's first record is its +// `history`: where it came from, the last summary and the newest of what +// was said after it, bounded (`historyOf`, `historyOfChat`), which the +// chat's page shows above the chat and the agent is handed at its first +// turn there. The agent joins after it. +// +// One chat a (source, agent): its name is made from both (`nameFor`: the +// agent's chat label and a hash of the two), so a Continue asked again, in +// any tab or on any device, opens that chat, and finishes there whatever a +// try before it left undone. Each step can be taken again: a create that +// finds its name taken by the person's own chat of this source goes on in +// it, the history is posted with the same id, the agent added by a PUT. +// A name some other fragment holds (a hash's collision) gives way to one +// made at random, kept in the browser until its Continue is done. + +// A history record's body, at most, as the cell measures a record's (its +// JSON: `limits::RECORD_BODY_MAX_BYTES`, 64 KiB), whatever its text is. +export const HISTORY_BODY_MAX_BYTES = 63 * 1024; +const LIMITS = { messages: 40, text: 4_000, summary: 8_000, title: 120 }; +// a chat's records read for its history, at most (a page each) +const CHAT_RECORDS_READ = 500; +const bytesOf = (v) => new TextEncoder().encode(JSON.stringify(v)).length; +const cutTo = (text, max) => ([...text].length > max ? `${[...text].slice(0, max - 1).join("")}…` : text); +const iso = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString() : undefined); + +// `base` with the newest of `said` that fit beside it, oldest first, each +// adding its JSON and a comma; a summary too large for the body alone cut +// until it fits; `earlier` how many others `all` messages hold. +function fill(base, said, all) { + while (base.summary && bytesOf(base) > HISTORY_BODY_MAX_BYTES) base.summary = cutTo(base.summary, Math.floor([...base.summary].length / 2)); + let size = bytesOf({ ...base, messages: [], earlier: all }); + const tail = []; + for (const m of [...said].reverse()) { + if (tail.length >= LIMITS.messages) break; + const one = { role: m.role, at: m.at, text: cutTo(m.text.trim(), LIMITS.text) }; + const more = bytesOf(one) + (tail.length ? 1 : 0); + if (size + more > HISTORY_BODY_MAX_BYTES) break; + size += more; + tail.unshift(one); + } + return { ...base, messages: tail, earlier: all - tail.length }; +} + +// An imported conversation's history (`c`, its file): its last summary +// covers what was said before it, so the card holds it, then the newest of +// what was said after it. `agent`: who the conversation was with, when not +// the agent continuing it. +export function historyOf(c, key, archive, agent) { + const all = c.messages ?? []; + const isSaid = (m) => (m.role === "user" || m.role === "assistant") && typeof m.text === "string" && m.text.trim() && !m.summary && !m.tool_calls?.length; + const last = all.findLastIndex((m) => m.summary && typeof m.text === "string" && m.text.trim()); + const summary = last >= 0 ? all[last].text : ""; + const base = { + kind: "history", title: cutTo(c.title || "Untitled", LIMITS.title), archive, key, started_at: c.started_at, ended_at: c.ended_at, + ...(agent ? { agent: cutTo(agent, LIMITS.title) } : {}), ...(summary ? { summary: cutTo(summary, LIMITS.summary) } : {}), + }; + return fill(base, all.slice(last + 1).filter(isSaid), all.filter(isSaid).length); +} + +// An archived chat's history, from its `chat` records (docs/chat-records.md: +// a body with text and no kind but "message"), each an agent's (`isAgent` +// its principal) or a person's. +export function historyOfChat(records, chat, title, isAgent, agent) { + const said = records + .filter((r) => r.body && typeof r.body === "object" && (r.body.kind ?? "message") === "message" && typeof r.body.text === "string" && r.body.text.trim()) + .map((r) => ({ role: isAgent(r.principal) ? "assistant" : "user", at: iso(r.at), text: r.body.text })); + const base = { + kind: "history", title: cutTo(title || "Untitled", LIMITS.title), chat, started_at: iso(records[0]?.at), ended_at: iso(records.at(-1)?.at), + ...(agent ? { agent: cutTo(agent, LIMITS.title) } : {}), + }; + return fill(base, said, said.length); +} + +// A chat's name from (source, agent): the agent's chat label and four +// characters of a hash of both, then four more as its suffix (decision +// 47's alphabet: `fragment_proto::NAME_SUFFIX_ALPHABET`). +const SUFFIX = "abcdefghijkmnpqrstuvwxyz23456789"; +export async function nameFor(source, agent, base) { + const id = source.chat ? `chat:${source.chat}` : `archive:${source.archive}/${source.key}`; + const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${id}\n${agent}`))); + const four = (at) => [0, 1, 2, 3].map((i) => SUFFIX[hash[at + i] & 31]).join(""); + return `${base}-${four(0)}--${four(4)}`; +} +const random = () => [...crypto.getRandomValues(new Uint8Array(4))].map((b) => SUFFIX[b & 31]).join(""); + +export function createContinue({ + api, ui: { el, notice }, data: { allFragments, agents: computerAgents, primaryAgent, titleOf, seg, chatBase }, + navigation: { openChat, leaveSidebar }, load: loadList, store, +}) { + // a chat's title, as the sidebar's room has it + const TITLE_MAX = 48; + // the chat a Continue not yet finished is making, by source and agent, + // when its own name was another's (`nameFor`) + const PENDING = "shell.continue.pending.v1"; + const status = (name) => api("GET", `/api/f/${seg(name)}/status`).catch((e) => (e.status === 404 ? null : Promise.reject(e))); + const fileOf = (archive, path) => api("GET", `/api/f/${seg(archive)}/file?path=${encodeURIComponent(path)}`); + const slotOf = (source, agent) => `${source.chat ? `chat ${source.chat}` : `${source.archive} ${source.key}`} ${agent}`; + + // the person's own agents, as their computer runs them + function agents() { + return [...computerAgents().values()].filter((a) => a.fragment).map((a) => ({ identity: a.identity, fragment: a.fragment, title: titleOf(a.fragment) })); + } + const titled = (identity) => agents().find((a) => a.identity === identity)?.title ?? ""; + const same = (a, b) => typeof a === "string" && typeof b === "string" && a.trim().toLocaleLowerCase() === b.trim().toLocaleLowerCase(); + + // the agent to offer first: an import's, the person's agent of its name + // (`source.agent`, as its archive's index names it; read from its file + // when the source does not say); an archived chat's, its lead agent when + // it is theirs; else their main one + const conversations = new Map(); + const conversation = (source) => { + const at = `${source.archive} ${source.key}`; + if (!conversations.has(at)) conversations.set(at, fileOf(source.archive, `sessions/${source.key}.json`).catch(() => null)); + return conversations.get(at); + }; + async function defaultAgent(source) { + const mine = agents(); + if (source.chat) { + const lead = allFragments().find((f) => f.name === source.chat)?.agents?.[0]; + if (mine.some((a) => a.identity === lead)) return lead; + } else { + const named = "agent" in source ? source.agent : (await conversation(source))?.agent; + const match = mine.find((a) => same(a.title, named)); + if (match) return match.identity; + } + return primaryAgent()?.identity ?? mine[0]?.identity ?? null; + } + + // the chat a Continue of `source` with `agent` made, as the person's list + // has it, or null + async function continued(source, agent) { + const fragment = computerAgents().get(agent)?.fragment; + if (!fragment) return null; + const pending = store.get(PENDING, {})[slotOf(source, agent)]; + const names = [await nameFor(source, agent, chatBase(fragment)), pending].filter(Boolean); + return names.find((n) => allFragments().some((f) => f.name === n && f.kind === "chat" && f.role === "owner")) ?? null; + } + + // whether the chat named `name` is this Continue's: the person's own, its + // first record (when it has one) this source's history + async function ours(name, source) { + if ((await status(name))?.role !== "owner") return false; + const first = (await api("GET", `/api/f/${seg(name)}/channels/chat?after=0&limit=1`)).records?.[0]; + const b = first?.body; + return !first || (b?.kind === "history" && (source.chat ? b.chat === source.chat : b.archive === source.archive && b.key === source.key)); + } + + // a Continue from before chats were named by their source (one archive + // marker, `continued/.json`): its chat, when it is with `agent` + async function marked(source, agent) { + if (source.chat) return null; + const m = await fileOf(source.archive, `continued/${source.key}.json`).catch(() => null); + const chat = typeof m?.chat === "string" ? allFragments().find((f) => f.name === m.chat && f.kind === "chat") : null; + return chat?.agents?.includes(agent) ? chat.name : null; + } + + // the source's history and the new chat's title, read now + async function historyFor(source, agent) { + if (source.chat) { + const row = allFragments().find((f) => f.name === source.chat); + const page = await api("GET", `/api/f/${seg(source.chat)}/channels/chat?before=${Number.MAX_SAFE_INTEGER}&limit=${CHAT_RECORDS_READ}`); + const theirs = new Set(row?.agents ?? []); + const lead = row?.agents?.[0]; + const other = lead && lead !== agent ? titled(lead) || "another agent" : ""; + const title = row?.title || titleOf(source.chat); + return { title, body: historyOfChat(page.records ?? [], source.chat, title, (p) => theirs.has(p) || computerAgents().has(p), other) }; + } + const c = await conversation(source); + if (!c) throw new Error("its conversation could not be read"); + const other = c.agent && !same(c.agent, titled(agent)) ? c.agent : ""; + return { title: c.title || "Untitled", body: historyOf(c, source.key, source.archive, other) }; + } + + let busy = false; + // makes (or finishes, or opens) the chat of `source` with `agent`: its + // name once it is open, else null (said in a notice) + async function continueInto(source, agent) { + const fragment = computerAgents().get(agent)?.fragment; + if (busy) return null; + if (!fragment) { + notice("No agent yet", "Continue needs one of your agents."); + return null; + } + busy = true; + const slot = slotOf(source, agent); + const remember = (name) => { + const all = store.get(PENDING, {}); + if (name) all[slot] = name; + else delete all[slot]; + store.set(PENDING, all); + }; + let made = false; + try { + const before = await marked(source, agent); + if (before) { + await loadList(); + openChat(before); + leaveSidebar(); + return before; + } + // bounded before anything is made + const { title, body } = await historyFor(source, agent); + let name = store.get(PENDING, {})[slot] ?? (await nameFor(source, agent, chatBase(fragment))); + try { + await api("POST", "/api/fragments", { name, template: "chat", title: cutTo(title, TITLE_MAX) }); + } catch (e) { + // its answer lost, it may be made: the next try finds it by its name + if (!(e.status >= 400)) made = true; + if (e.status !== 409) throw e; + // this source's chat with this agent, made before (or by a try whose + // answer was lost); else a name another holds: one at random, kept + if (!(await ours(name, source))) { + name = `${chatBase(fragment)}-${random()}--${random()}`; + remember(name); + await api("POST", "/api/fragments", { name, template: "chat", title: cutTo(title, TITLE_MAX) }); + } + } + made = true; + // posted by a try before (the same id; another body when the source changed since) + await api("POST", `/api/f/${seg(name)}/channels/chat`, { id: "history", body }).catch((e) => (e.status === 409 ? null : Promise.reject(e))); + // adding it wakes its computer (the platform's `joined`) + await api("PUT", `/api/f/${seg(name)}/members/${seg(agent)}`, { role: "editor" }); + remember(null); + await loadList(); + openChat(name); + leaveSidebar(); + return name; + } catch (e) { + if (made) notice("The chat is not ready yet", `${e.message} Continue again to finish it.`); + else notice("The chat was not made", e.message); + return null; + } finally { + busy = false; + } + } + + // "Continue with [agent ▾]": one line, for a History row or the archive's + // ask; "Continue" alone when the person has one agent, "Open" once the + // chosen agent's chat of this source is made. `done(name)` once its chat + // is open. + function continueControl(source, done = () => {}) { + const box = el("div", "continue-control"); + const go = el("button", "continue-go", "Continue"); + go.type = "button"; + const mine = agents(); + if (!mine.length) { + go.disabled = true; + go.title = "Continue needs one of your agents"; + box.append(go); + return box; + } + const pick = el("select", "continue-agent"); + pick.setAttribute("aria-label", "The agent to continue with"); + for (const a of mine) { + const o = el("option", null, a.title); + o.value = a.identity; + pick.append(o); + } + const said = () => { + const open = !!go.dataset.chat; + go.textContent = box.classList.contains("busy") ? "Continuing…" : open ? "Open" : mine.length > 1 ? "Continue with" : "Continue"; + }; + const refresh = async () => { + go.dataset.chat = (await continued(source, pick.value).catch(() => null)) ?? ""; + said(); + }; + go.onclick = async () => { + if (box.classList.contains("busy")) return; + if (go.dataset.chat) { + openChat(go.dataset.chat); + leaveSidebar(); + return done(go.dataset.chat); + } + box.classList.add("busy"); + go.disabled = pick.disabled = true; + said(); + let name = null; + try { + name = await continueInto(source, pick.value); + } finally { + box.classList.remove("busy"); + go.disabled = pick.disabled = false; + await refresh(); + } + if (name) done(name); + }; + pick.onchange = () => void refresh(); + box.append(go); + if (mine.length > 1) box.append(pick); + void defaultAgent(source).then((identity) => { + if (identity) pick.value = identity; + return refresh(); + }); + return box; + } + + // The archive's page asks to continue one of its conversations: the same + // control, in a dialog of its own. + function ask(source, title) { + const dialog = el("dialog", "continue-ask"); + const close = el("button", "continue-close", "Close"); + close.type = "button"; + close.onclick = () => dialog.close(); + dialog.append(el("p", "continue-title", `Continue “${title || "this conversation"}”`), continueControl(source, () => dialog.close()), close); + dialog.addEventListener("close", () => dialog.remove()); + document.body.append(dialog); + dialog.showModal(); + return dialog; + } + + return { agents, defaultAgent, continued, continueInto, continueControl, ask }; +} diff --git a/cell/shell/history-page.js b/cell/shell/history-page.js index ad6f7120c..e6942176c 100644 --- a/cell/shell/history-page.js +++ b/cell/shell/history-page.js @@ -267,7 +267,9 @@ export function createHistoryPage(page, h) { // Continue with an agent of theirs: an archived chat's, or an imported // one's, made once and kept function controlOf(r) { - const source = r.kind === "imported" ? { archive: h.archive.name(), key: r.entry.key } : r.archived ? { chat: r.name } : null; + // an import names its agent as its index does (null: unknown, so its + // file is not read for it: continue.js `defaultAgent`) + const source = r.kind === "imported" ? { archive: h.archive.name(), key: r.entry.key, agent: r.entry.agent ?? null } : r.archived ? { chat: r.name } : null; if (!source || !h.continueControl) return null; if (!controls.has(r.key)) controls.set(r.key, h.continueControl(source)); return controls.get(r.key); diff --git a/cell/shell/session-archive.js b/cell/shell/session-archive.js index acce8aef1..b9f8182cd 100644 --- a/cell/shell/session-archive.js +++ b/cell/shell/session-archive.js @@ -12,49 +12,10 @@ // tells this page of) at most once a minute, at once when the archive is // new to this page, and as the page comes back into view: an import moves // the archive's files and not their list. -// A Continue's history record (docs/chat-records.md, "History"): the -// conversation's title, its summary and the newest of what the person and -// the agent said, oldest first, bounded so its body (its JSON, as the cell -// measures a record's: `limits::RECORD_BODY_MAX_BYTES`, 64 KiB) fits, -// whatever its text is (CJK, escaped). -export const HISTORY_BODY_MAX_BYTES = 63 * 1024; -const HISTORY_LIMITS = { messages: 40, text: 4_000, summary: 8_000, title: 120 }; -const bytesOf = (v) => new TextEncoder().encode(JSON.stringify(v)).length; -const cutTo = (text, max) => ([...text].length > max ? `${[...text].slice(0, max - 1).join("")}…` : text); - -export function historyOf(c, key, archive) { - const all = c.messages ?? []; - const isSaid = (m) => (m.role === "user" || m.role === "assistant") && typeof m.text === "string" && m.text.trim() && !m.summary && !m.tool_calls?.length; - const said = all.filter(isSaid); - // the last summary covers what was said before it: the card holds it, - // then the newest of what was said after it (the rest counted `earlier`) - const last = all.findLastIndex((m) => m.summary && typeof m.text === "string" && m.text.trim()); - const summary = last >= 0 ? all[last].text : ""; - const after = all.slice(last + 1).filter(isSaid); - const base = { - kind: "history", title: cutTo(c.title || "Untitled", HISTORY_LIMITS.title), archive, key, started_at: c.started_at, ended_at: c.ended_at, - ...(summary ? { summary: cutTo(summary, HISTORY_LIMITS.summary) } : {}), messages: [], earlier: said.length, - }; - // a summary too large for the body alone is cut until it fits - while (base.summary && bytesOf(base) > HISTORY_BODY_MAX_BYTES) base.summary = cutTo(base.summary, Math.floor([...base.summary].length / 2)); - // the newest that fit, oldest first: each adds its JSON and a comma - let size = bytesOf(base); - const tail = []; - for (const m of [...after].reverse()) { - if (tail.length >= HISTORY_LIMITS.messages) break; - const one = { role: m.role, at: m.at, text: cutTo(m.text.trim(), HISTORY_LIMITS.text) }; - const more = bytesOf(one) + (tail.length ? 1 : 0); - if (size + more > HISTORY_BODY_MAX_BYTES) break; - size += more; - tail.unshift(one); - } - return { ...base, messages: tail, earlier: said.length - tail.length }; -} - // `changed`: the index read again, or the conversation open in it moved. export function createSessionArchive({ - api, ui: { renderHeading, notice }, data: { allFragments, own, seg, primaryAgent, agents, chatLabel }, - selection: { getFrame }, navigation: { openChat, leaveSidebar }, load: loadList, store, changed, + api, ui: { renderHeading }, data: { allFragments, own, seg }, + selection: { getFrame }, navigation: { openChat, leaveSidebar }, continuing, store, changed, }) { const READ_EVERY_MS = 60_000; const read = { name: null, at: 0, asked: 0, entries: [] }; @@ -86,101 +47,17 @@ export function createSessionArchive({ // The archive's page says which conversation it shows (its `?s=`, or none // for its list) as it loads, the person moving in it too, answering the // theme this page sends each frame: the heading follows. - // It asks to continue one (`continue`), or to open the chat it was - // continued in (`chat`): the shell does either, as the person. + // It asks to continue one (`continue`): the shell asks with which agent + // (continue.js), as the person. addEventListener("message", (e) => { const d = e.data; if (d?.fragment !== "session-archive" || !read.name || e.source !== getFrame(read.name)?.contentWindow) return; - if (typeof d.continue === "string") return void continueSession(d.continue); - if (typeof d.chat === "string") { - if (allFragments().some((f) => f.name === d.chat && f.kind === "chat")) { openChat(d.chat); leaveSidebar(); } - return; - } + if (typeof d.continue === "string") return void continuing.ask({ archive: read.name, key: d.continue }, read.entries.find((x) => x.key === d.continue)?.title); open = typeof d.key === "string" ? d.key : null; renderHeading(); }); - // ---- Continue: a conversation of the archive, picked up as a chat ---- - // A new chat with the person's main agent, titled as the conversation was, - // whose first record is its `history` (docs/chat-records.md, "History"): - // a summary and the newest of what was said, bounded, which the chat's - // page shows above the chat and the agent is handed at its first turn - // there (`historyOf`, bounded before anything is made). The agent joins - // after it, so it is the chat's first record. The archive keeps the - // chat's name (`continued/.json`), so the conversation offers that - // chat again rather than another, and a Continue asked again (a stale - // tab) opens it. Each step can be taken again: the chat's name is made - // here and kept before the chat is (`PENDING`), so a try cut short, or - // whose answer was lost, is finished by the next in the same chat. - // a chat's title, as the sidebar's room has it - const TITLE_MAX = 48; - // a chat's name, made here so a try whose answer was lost is found again - // by it (decision 47's suffix: `fragment_proto::NAME_SUFFIX_ALPHABET`) - const SUFFIX = "abcdefghijkmnpqrstuvwxyz23456789"; - const suffix = () => [...crypto.getRandomValues(new Uint8Array(4))].map((b) => SUFFIX[b & 31]).join(""); - // the chat a Continue not yet finished is making, by archive and key - const PENDING = "shell.archive.continuing.v1"; - const status = (name) => api("GET", `/api/f/${seg(name)}/status`).catch((e) => (e.status === 404 ? null : Promise.reject(e))); - let continuing = false; - async function continueSession(key) { - const agent = primaryAgent(); - const fragment = agents().get(agent?.identity)?.fragment; - if (continuing || !read.name) return; - if (!fragment) return notice("No agent yet", "Continue needs your agent."); - continuing = true; - const archive = read.name; - const slot = `${archive} ${key}`; - const remember = (name) => { - const all = store.get(PENDING, {}); - if (name) all[slot] = name; - else delete all[slot]; - store.set(PENDING, all); - }; - const go = async (name) => { - remember(null); - await loadList(); - openChat(name); - leaveSidebar(); - }; - let made = false; - try { - // continued already (in another tab, or by a try whose last answer was - // lost): its chat, while it is there - const marked = await api("GET", `/api/f/${seg(archive)}/file?path=${encodeURIComponent(`continued/${key}.json`)}`).catch((e) => (e.status === 404 ? null : Promise.reject(e))); - if (typeof marked?.chat === "string" && (await status(marked.chat))) return void (await go(marked.chat)); - const c = await api("GET", `/api/f/${seg(archive)}/file?path=${encodeURIComponent(`sessions/${key}.json`)}`); - // bounded before anything is made - const body = historyOf(c, key, archive); - // the chat a try before this one began, or a new one, kept before it is made - const name = store.get(PENDING, {})[slot] ?? `${chatLabel(fragment)}--${suffix()}`; - remember(name); - try { - await api("POST", "/api/fragments", { name, template: "chat", title: cutTo(c.title || "Untitled", TITLE_MAX) }); - } catch (e) { - // its answer lost, it may be made: the next try finds it by its name - if (!(e.status >= 400)) made = true; - // made by a try whose answer was lost: the person's own - const mine = e.status === 409 ? (await status(name))?.role === "owner" : false; - if (!mine) { - if (e.status === 409) remember(null); - throw e; - } - } - made = true; - // posted by a try before (the same id; another body when the conversation was imported again since) - await api("POST", `/api/f/${seg(name)}/channels/chat`, { id: "history", body }).catch((e) => (e.status === 409 ? null : Promise.reject(e))); - // adding it wakes its computer (the platform's `joined`) - await api("PUT", `/api/f/${seg(name)}/members/${seg(agent.identity)}`, { role: "editor" }); - await api("POST", `/api/f/${seg(archive)}/files`, { message: `continued in ${name}`, files: [{ path: `continued/${key}.json`, text: `${JSON.stringify({ chat: name })}\n` }] }); - await go(name); - } catch (e) { - if (made) notice("The chat is not ready yet", `${e.message} Continue again to finish it.`); - else notice("The chat was not made", e.message); - } finally { - continuing = false; - } - } // one conversation (its key), or with none the archive's list function openSession(key) { @@ -192,5 +69,5 @@ export function createSessionArchive({ // what the middle column's heading says while the archive is open there const title = () => read.entries.find((e) => e.key === open)?.title || "Session Archive"; - return { load, title, continueSession, archive: () => read.name, entries: () => read.entries, openSession }; + return { load, title, archive: () => read.name, entries: () => read.entries, openSession }; } diff --git a/cell/shell/shell.css b/cell/shell/shell.css index b40ac8758..804a30228 100644 --- a/cell/shell/shell.css +++ b/cell/shell/shell.css @@ -957,3 +957,17 @@ iframe[data-blocked] { display: none; } .icon-pick[aria-busy=true] { outline: 2px dashed var(--border-strong); outline-offset: 2px; } .icon-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; } .icon-actions button:first-child { margin-right: auto; } + +/* ---- Continue (continue.js): "Continue with [agent ▾]", one line, compact + enough for the end of a 40px History row; and the dialog the archive's + page asks for it in. */ +.continue-control { display: inline-flex; align-items: center; gap: 0; height: 28px; max-width: 100%; flex: none; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--bg-card); overflow: hidden; font: 13px/1 var(--font-sans); } +.continue-control button.continue-go { height: 100%; padding: 0 12px; border: 0; background: none; color: var(--text-primary); white-space: nowrap; cursor: pointer; } +.continue-control button.continue-go:hover:not(:disabled) { background: var(--bg-hover); } +.continue-control select.continue-agent { height: 100%; min-width: 0; max-width: 160px; padding: 0 26px 0 10px; border: 0; border-left: 1px solid var(--border); background: none; color: var(--text-secondary); font: inherit; text-overflow: ellipsis; cursor: pointer; } +.continue-control.busy { opacity: .6; } +.continue-control button.continue-go:disabled { cursor: default; } +.continue-ask { padding: 20px 22px; width: min(380px, calc(100vw - 32px)); border: 1px solid var(--border-strong); border-radius: 16px; background: var(--bg-card); color: var(--text-primary); } +.continue-ask::backdrop { background: rgba(0,0,0,.5); } +.continue-ask .continue-title { margin: 0 0 14px; font-size: 15px; overflow-wrap: anywhere; } +.continue-ask .continue-close { display: block; margin: 16px 0 0 auto; padding: 6px 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-muted); color: var(--text-secondary); font-size: 13px; } diff --git a/cell/shell/shell.js b/cell/shell/shell.js index 89bce0dd6..d2d0cc507 100644 --- a/cell/shell/shell.js +++ b/cell/shell/shell.js @@ -22,6 +22,7 @@ import { createViewer } from "./viewer.js"; import { seatGives } from "./billing.js"; import { createChatList, newestChat } from "./chat-list.js"; import { createSessionArchive } from "./session-archive.js"; +import { createContinue } from "./continue.js"; import { createSessionRefresh } from "./session-refresh.js"; import { createOnboarding } from "./onboarding.js"; import { createComputer } from "./computer.js"; @@ -868,11 +869,18 @@ const { renderHeading, renderChats, loadInvites, primaryAgent, newChatAgent, own navigation: { newChat, rename: (name) => rename(name), openChat, openApp, openScreen, leaveSidebar, seg }, listLive, load, invites: () => state.invites, setInvites: (v) => { state.invites = v; }, }); +// Continue: a conversation (an import, an archived chat) as a new chat with +// an agent of the person's choosing (continue.js) +const continuing = createContinue({ + api, ui: { el, notice }, + data: { allFragments: () => state.fragments, agents: () => state.agents, primaryAgent: () => primaryAgent(), titleOf, seg, chatBase: (agent) => chatBase(agent) }, + navigation: { openChat, leaveSidebar }, load: () => load(), store, +}); const sessionArchive = createSessionArchive({ - api, ui: { renderHeading: () => renderHeading(), notice }, - data: { allFragments: () => state.fragments, own, seg, primaryAgent: () => primaryAgent(), agents: () => state.agents, chatLabel: (agent) => chatLabel(agent) }, + api, ui: { renderHeading: () => renderHeading() }, + data: { allFragments: () => state.fragments, own, seg }, selection: { getFrame: (name) => state.frames.get(name) }, - navigation: { openChat, leaveSidebar }, load: () => load(), store, changed: () => historyPage.render(), + navigation: { openChat, leaveSidebar }, continuing, store, changed: () => historyPage.render(), }); // Delegation threads: a line each over the chat an agent asked from, its @@ -1255,7 +1263,10 @@ const UNTITLED = "New chat"; const TITLE_MAX_CHARS = 48; // A chat's label: its agent's, `-chat` and a number (`maple-chat-2`), short // enough for any deployment's hosts with four digits' room. -const chatLabel = (agent) => freeLabel(`${labelOf(agent).slice(0, LABEL_ROOM - "-chat-9999".length).replace(/-+$/, "")}-chat`); +// an agent's chats' label, before freeLabel numbers it (Continue's names +// add a hash to it: continue.js) +const chatBase = (agent) => `${labelOf(agent).slice(0, LABEL_ROOM - "-chat-9999".length).replace(/-+$/, "")}-chat`; +const chatLabel = (agent) => freeLabel(chatBase(agent)); // An untitled chat with only this agent that no one has said anything in // yet is the one to open again, not another. const blank = (identity) => chats().find((f) => own(f) && !f.archived && f.title === UNTITLED && !f.preview && agentsOf(f.name).length === 1 && agentsOf(f.name)[0] === identity); @@ -1513,6 +1524,7 @@ const historyPage = createHistoryPage($("history-page"), { api, el, icon, seg, mark: (name, size) => mark(name, size), chats, isThread: (f) => threads.isPair(f), own, chatTitle: (name) => chatTitle(name), agentsOf, agents: () => state.agents, titleOf, archive: { name: () => sessionArchive.archive(), entries: () => sessionArchive.entries(), openSession: (key) => sessionArchive.openSession(key) }, + continueControl: (source) => continuing.continueControl(source), openChat, leaveSidebar, }); function openHistory(push = true) { diff --git a/crates/e2e/src/lanes/sessions.rs b/crates/e2e/src/lanes/sessions.rs index 9029c41e1..4067b4fbd 100644 --- a/crates/e2e/src/lanes/sessions.rs +++ b/crates/e2e/src/lanes/sessions.rs @@ -145,10 +145,14 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { ); b.screenshot(&page, &shots.join("list.png"))?; - // Continue: a chat with their agent, its history first + // Continue: the shell asks with which agent (one here: "Continue"), and + // makes a chat with it, its history first b.click(&page, "#archive-rows [data-key=\"archived:hermes-e2e_beans\"]")?; let offered = s.eventually(wait, || b.eval_in_frame(&page, frame, "document.querySelector('.archive-continue:not([hidden])')?.textContent === 'Continue'").is_ok_and(|v| v == true)); b.eval_in_frame(&page, frame, "(document.querySelector('.archive-continue').click(), true)")?; + let asked = b.until(&page, "document.querySelector('dialog.continue-ask[open] .continue-go')?.textContent === 'Continue'", wait); + b.screenshot(&page, &shots.join("continue-ask.png"))?; + b.eval(&page, "(document.querySelector('dialog.continue-ask .continue-go').click(), true)")?; let me = api.identity(&keys)?; let mut chat = String::new(); let made = s.eventually(wait, || { @@ -165,7 +169,7 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { let tail: Vec<&str> = first["body"]["messages"].as_array().into_iter().flatten().filter_map(|m| m["text"].as_str()).collect(); s.ok( "Continue makes a chat with the person's agent, titled as the conversation, whose first record is its history, theirs", - offered && made && first["body"]["kind"] == "history" && first["principal"] == me.as_str() && tail == ["plant beans?", "in spring"] && first["body"]["key"] == "hermes-e2e_beans", + offered && asked && made && first["body"]["kind"] == "history" && first["principal"] == me.as_str() && tail == ["plant beans?", "in spring"] && first["body"]["key"] == "hermes-e2e_beans", &first, ); let shown = s.eventually(wait, || b.eval_in_frame(&page, &chat, "document.querySelector('.history-block .history-head')?.textContent?.startsWith('From “Beans”') ?? false").is_ok_and(|v| v == true)); @@ -186,11 +190,16 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { &reply, ); - // the archive offers that chat now - let linked = api.signed(&keys, "GET", &format!("/api/f/{name}/file?path=continued/hermes-e2e_beans.json"), None)?; + // asked again, it offers that chat (one a conversation and agent), and opens it b.click(&page, "#archive-rows [data-key=\"archived:hermes-e2e_beans\"]")?; - let again = s.eventually(wait, || b.eval_in_frame(&page, frame, "document.querySelector('.archive-continue:not([hidden])')?.textContent === 'Open the chat'").is_ok_and(|v| v == true)); - s.ok("the archive keeps the chat it was continued in, and offers it", linked.body["chat"] == chat.as_str() && again, &linked.body); + s.eventually(wait, || b.eval_in_frame(&page, frame, "!!document.querySelector('.archive-continue:not([hidden])')").is_ok_and(|v| v == true)); + b.eval_in_frame(&page, frame, "(document.querySelector('.archive-continue').click(), true)")?; + let open = b.until(&page, "document.querySelector('dialog.continue-ask[open] .continue-go')?.textContent === 'Open'", wait); + b.eval(&page, "(document.querySelector('dialog.continue-ask[open] .continue-go').click(), true)")?; + let reopened = b.until(&page, &format!("!document.querySelector('dialog.continue-ask[open]') && [...document.querySelectorAll('#frames iframe')].find((f) => !f.hidden)?.dataset.fragment === {chat:?}"), wait); + let listed = api.signed(&keys, "GET", "/api/fragments", None)?; + let beans = listed.body["fragments"].as_array().into_iter().flatten().filter(|f| f["kind"] == "chat" && f["title"] == "Beans").count(); + s.ok("Continue asked again offers the chat it made with that agent, and opens it, making none other", open && reopened && beans == 1, beans); Ok(()) } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index f140cb09b..df8fbbc56 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -155,6 +155,7 @@ const SHELL_PUBLISHED: [(&str, &str); 48] = [ ("chat-list.js", "text/javascript; charset=utf-8"), // the Session Archive under the chats (templates/sessions/README.md) ("session-archive.js", "text/javascript; charset=utf-8"), + ("continue.js", "text/javascript; charset=utf-8"), // delegation threads: a line each over the chat an agent asked from (docs/api.md, The shell) ("threads.js", "text/javascript; charset=utf-8"), ("session-refresh.js", "text/javascript; charset=utf-8"), diff --git a/docs/chat-records.md b/docs/chat-records.md index 4ba82240b..9ac0edc98 100644 --- a/docs/chat-records.md +++ b/docs/chat-records.md @@ -341,39 +341,45 @@ for them). after a restart starts nothing twice; one waiting its turn waits again in the next life, still a command. -**History** (a chat continued from its person's Session Archive: -templates/sessions/README.md): +**History** (a chat continued from a conversation the person had: one of +their Session Archive, templates/sessions/README.md, or an archived chat +of theirs; with an agent of their choosing): ```json { "kind": "history", "title": "Beans", "archive": "sessions--k3x9", "key": "hermes-20261001_090000_ab12cd", - "started_at": "2026-10-01T09:00:00.000Z", "ended_at": "2026-10-04T09:00:00.000Z", + "agent": "Juniper", "started_at": "2026-10-01T09:00:00.000Z", "ended_at": "2026-10-04T09:00:00.000Z", "summary": "…", "messages": [{ "role": "user", "at": "…", "text": "…" }], "earlier": 12 } ``` -- Posted by the chat's owner, the shell's Continue, with the id - `history`, before the agent joins: the chat's first record. It holds - the conversation's last summary (`summary`, at most 8,000 characters) - and the newest of what the person and the agent said after it (the - summary covers what came before), oldest first (`messages`, at most 40, - each at most 4,000 characters), with how many others the conversation - holds (`earlier`), bounded as a whole by its body's bytes +- Posted by the chat's owner, the shell's Continue (cell/shell/continue.js), + with the id `history`, before the agent joins: the chat's first record. + It holds the conversation's last summary (`summary`, at most 8,000 + characters) and the newest of what the person and the agent said after + it (the summary covers what came before), oldest first (`messages`, at + most 40, each at most 4,000 characters), with how many others the + conversation holds (`earlier`), bounded as a whole by its body's bytes (63 KiB of JSON, under a record's 64 KiB, whatever the text: the newest that fit; a summary too large for it alone is cut) before the chat is - made (cell/shell/session-archive.js, `historyOf`). `archive` and `key` - name the whole conversation's file. -- Continue's steps (the chat, made by a name the shell chooses and keeps - first; this record; the agent's membership; the archive's - `continued/.json`) can each be taken again: a Continue cut short, - or whose answer was lost, is finished by the next one in the same chat, - and one asked of a conversation already continued opens that chat. + made (`historyOf`, `historyOfChat`). Where it came from: `archive` and + `key` name an import's file, or `chat` the archived chat (whose `chat` + records, a message's text by a person or an agent, it was made of). + `agent`: whose the agent's words were, when not the agent continuing it + (absent, they were its own). +- One chat a (source, agent): its name is made from both (the agent's + chat label and a hash of the two), so Continue asked again, in any tab, + opens that chat; and its steps (the chat, this record, the agent's + membership) can each be taken again, so a Continue cut short, or whose + answer was lost, is finished by the next in the same chat. A name some + other fragment holds gives way to one made at random, kept in the + browser until its Continue is done. - The page shows its owner's first one above the chat, read-only: where - it came from, the summary folded, the messages as the chat's own look. - Anyone else's is shown by nothing. + it came from (and with whom, `agent`), the summary folded, the messages + as the chat's own look. Anyone else's is shown by nothing. - The bridge hands its owner's to the agent once, at its first turn in the chat: as the turn's read-only note (`TurnStart::note`, Relay's - `context`), the agent's own words said as "you", and where the rest is - (images/bridge `note::history`). It starts no turn, and a bridge or a - page from before it reads it as nothing. + `context`), the agent's own words said as "you" (another agent's by its + name), and where the rest is (images/bridge `note::history`). It starts + no turn, and a bridge or a page from before it reads it as nothing. Any other `kind` on `chat` is the page's own and is never a message. diff --git a/images/bridge/src/note.rs b/images/bridge/src/note.rs index de616ae36..ee9f3975a 100644 --- a/images/bridge/src/note.rs +++ b/images/bridge/src/note.rs @@ -353,10 +353,18 @@ pub fn history(r: &Record, owner: &str) -> Option { let named = |k: &str, ok: fn(u8) -> bool| Some(field(b, k)).filter(|v| !v.is_empty() && v.len() <= 160 && v.bytes().all(ok)); let archive = named("archive", |c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-'); let key = named("key", |c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-')); + // an archived chat of theirs (`chat`), or an import's; with another + // agent (`agent`, its name), or with this one + let chat = named("chat", |c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-'); + let other = Some(short("agent", 120)).filter(|a| !a.trim().is_empty()); + let with = other.as_deref().unwrap_or("you"); + let moved = if chat.is_some() { "" } else { " before they moved here" }; let mut head = vec![format!( - "This chat goes on from a conversation your owner had with you before they moved here, “{title}”{when}. It is read-only history: what was said there, not new messages." + "This chat goes on from a conversation your owner had with {with}{moved}, “{title}”{when}. It is read-only history: what was said there, not new messages." )]; - if let (Some(a), Some(k)) = (archive, key) { + if let Some(c) = chat { + head.push(format!("All of it is in their chat {c}.")); + } else if let (Some(a), Some(k)) = (archive, key) { head.push(format!("All of it is in their Session Archive: `fragment sync {a} --dir --mode pull`, then sessions/{k}.json.")); } let summary = field(b, "summary").trim(); @@ -371,7 +379,7 @@ pub fn history(r: &Record, owner: &str) -> Option { let text = m["text"].as_str().map(str::trim).filter(|t| !t.is_empty())?; let who = match m["role"].as_str() { Some("user") => "Your owner", - Some("assistant") => "You", + Some("assistant") => other.as_deref().unwrap_or("You"), _ => return None, }; Some(format!("{who}: {}", records::cut_bytes(text, limits::HISTORY_NOTE_MAX_BYTES / 4))) @@ -393,7 +401,8 @@ pub fn history(r: &Record, owner: &str) -> Option { let dropped = earlier + (said.len() - kept.len()) as u64; let mut text = head; if !kept.is_empty() { - let before = if dropped > 0 { format!(" ({dropped} earlier ones are in the archive)") } else { String::new() }; + let place = if chat.is_some() { "that chat" } else { "the archive" }; + let before = if dropped > 0 { format!(" ({dropped} earlier ones are in {place})") } else { String::new() }; text.push_str(&format!("\n\nIts last messages, oldest first{before}:\n")); text.push_str(&kept.iter().map(|l| l.as_str()).collect::>().join("\n")); } @@ -471,6 +480,25 @@ mod tests { assert!(big.contains("Your owner: 39 ") && !big.contains("Your owner: 0 ") && big.contains("earlier ones are in the archive"), "the newest kept"); } + /// Goal: a history continued with another agent than the one the + /// conversation was with (`agent`) tells this one whose words they + /// were, never "you"; one from an archived chat (`chat`) says that chat + /// is where the rest is. Valid: both; a history saying neither reads as + /// before. + #[test] + fn a_history_says_whose_words_and_where_the_rest_is() { + let body = json!({ "kind": "history", "title": "Beans", "chat": "fred-chat--k3x9", "agent": "Fred", "started_at": "2026-10-01T09:00:00.000Z", "earlier": 2, + "messages": [{ "role": "user", "text": "plant beans?" }, { "role": "assistant", "text": "in spring" }] }); + let text = history(&history_record("npub1paul", body), "npub1paul").expect("its owner's history"); + assert!(text.starts_with("This chat goes on from a conversation your owner had with Fred, “Beans” (2026-10-01)."), "{text}"); + assert!(text.contains("All of it is in their chat fred-chat--k3x9."), "{text}"); + assert!(text.ends_with("(2 earlier ones are in that chat):\nYour owner: plant beans?\nFred: in spring"), "{text}"); + assert!(!text.contains("You:") && !text.contains("moved here") && !text.contains("fragment sync"), "{text}"); + let imported = json!({ "kind": "history", "title": "Beans", "archive": "sessions--k3x9", "key": "hermes-ab", "agent": "Juniper", "messages": [{ "role": "assistant", "text": "hi" }] }); + let text = history(&history_record("npub1paul", imported), "npub1paul").unwrap(); + assert!(text.contains("had with Juniper before they moved here") && text.ends_with("Juniper: hi") && text.contains("sessions/hermes-ab.json"), "{text}"); + } + fn step(tool: &str, args: &str, ok: bool) -> Step { Step { tool: tool.into(), args: args.into(), ok, excerpt: String::new(), category: crate::records::Category::Shell, evidence: None } } diff --git a/templates/chat/site/chat.js b/templates/chat/site/chat.js index b8ef4375b..5b7183f4e 100644 --- a/templates/chat/site/chat.js +++ b/templates/chat/site/chat.js @@ -1165,7 +1165,10 @@ export function mount(root) { const box = el("section", "history-block"); const day = (at) => (typeof at === "string" && at ? new Date(at).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : ""); const when = [day(b.started_at), day(b.ended_at)].filter((d, i, all) => d && all.indexOf(d) === i).join(" – "); - box.append(el("p", "history-head", [`From “${typeof b.title === "string" ? b.title : "an earlier chat"}”`, when].filter(Boolean).join(" · "))); + // from an import (`archive`, `key`) or an archived chat (`chat`); `agent`: + // who it was with, when not this chat's agent + const from = typeof b.title === "string" ? b.title : "an earlier chat"; + box.append(el("p", "history-head", [`From “${from}”`, typeof b.agent === "string" && b.agent ? `with ${b.agent}` : "", when].filter(Boolean).join(" · "))); if (typeof b.summary === "string" && b.summary.trim()) { const d = el("details", "tools"); const s = el("summary"); @@ -1175,7 +1178,7 @@ export function mount(root) { d.append(s, detail); box.append(d); } - if (Number.isInteger(b.earlier) && b.earlier > 0) box.append(el("p", "history-head", `${b.earlier} earlier ${b.earlier === 1 ? "message" : "messages"} in your Session Archive`)); + if (Number.isInteger(b.earlier) && b.earlier > 0) box.append(el("p", "history-head", `${b.earlier} earlier ${b.earlier === 1 ? "message" : "messages"} ${typeof b.chat === "string" ? `in “${from}”` : "in your Session Archive"}`)); for (const m of Array.isArray(b.messages) ? b.messages : []) { if (typeof m?.text !== "string" || !m.text.trim()) continue; const msg = el("div", `msg ${m.role === "user" ? "user mine" : "agent"}`); diff --git a/templates/sessions/site/sessions.js b/templates/sessions/site/sessions.js index 223b2cda7..02f5fcdbc 100644 --- a/templates/sessions/site/sessions.js +++ b/templates/sessions/site/sessions.js @@ -4,8 +4,9 @@ // chat's look: what the person and the agent said, and the agent's tool // calls, their results and its reasoning folded into steps, as a chat // folds them. Everything is read from the archive's own files (`__file`). -// Framed by the shell, a conversation offers Continue (the shell makes a -// chat of it with the person's agent), or the chat it was continued in. +// Framed by the shell, a conversation offers Continue: the shell asks with +// which of the person's agents, and makes a chat of it with that one (or +// opens the chat it made before). import { renderMarkdown } from "./markdown.js"; import { icon } from "./icons.js"; @@ -196,12 +197,11 @@ async function reader(page, key) { head.append(el("h1", null, c.title || "Untitled")); const about = [c.kind !== "chat" ? KIND_ONE[c.kind] : "", when(c.started_at, c.ended_at), c.with ? `with ${c.with}` : ""].filter(Boolean).join(" · "); if (about) head.append(el("p", null, about)); - // Continue, or the chat it was continued in: the shell's to do - const continued = await file(`continued/${key}.json`); - const go = el("button", "archive-continue", continued?.chat ? "Open the chat" : "Continue"); + // Continue: the shell asks with which agent (or opens the chat one made) + const go = el("button", "archive-continue", "Continue"); go.type = "button"; go.hidden = !shell; - go.onclick = () => toShell(continued?.chat ? { chat: continued.chat } : { continue: key }); + go.onclick = () => toShell({ continue: key }); head.append(go); page.replaceChildren(head, ...conversation(c)); } diff --git a/xtask/src/continue_js.rs b/xtask/src/continue_js.rs new file mode 100644 index 000000000..0d2ec160c --- /dev/null +++ b/xtask/src/continue_js.rs @@ -0,0 +1,305 @@ +//! The shell's Continue (cell/shell/continue.js) on the pinned Node, against +//! a scripted platform: the history record it posts is bounded by its +//! body's bytes as the cell measures a record's (`limits::RECORD_BODY_MAX_BYTES`), +//! whatever its text, before anything is made, and holds a summary then +//! what was said after it; one chat a (source, agent), named from both, so +//! a Continue cut short at any step, or whose answer was lost, or asked +//! again from another tab, is finished in that chat, never another; the +//! agent offered first is the import's own by name, an archived chat's lead +//! one, else the person's main one; and an archived chat's history is made +//! from its records. The e2e's sessions-ui section runs the same file in +//! Chrome on a real platform. + +use std::io::Write; +use std::process::Stdio; + +use fragment_devstack as devstack; +use serde_json::Value; + +/// The platform as Continue meets it: the person's fragments (each with its +/// agents, as their list has them), files, records and members, each step +/// able to fail once before it acts (`fail`) or after it acted with its +/// answer lost (`lose`). A post checks its body's size as the cell does, and +/// ids as the cell does: the same id and body answer the record again, +/// another body is 409. +const WORLD: &str = r##" +const RECORD_MAX = 64 * 1024; +const bytes = (v) => new TextEncoder().encode(JSON.stringify(v)).length; +const results = []; +const check = (name, ok, detail) => results.push({ name, ok: !!ok, detail: detail === undefined ? null : detail }); +const ARCHIVE = "sessions--aaaa"; +const KEY = "hermes-c1"; +const err = (status, message) => Object.assign(new Error(message), { status }); +// the person's agents: identity → their computer's agent, and titles +const AGENTS = [["npub1datastar", "datastar-pro--k3x9", "Datastar Pro"], ["npub1juniper", "juniper--m4n5", "Juniper"]]; + +function world(conversation, extra = []) { + const w = { fragments: new Map([[ARCHIVE, { role: "owner", kind: "sessions", agents: [] }]]), files: new Map(), records: new Map(), creates: 0, posts: 0, fail: {}, lose: {} }; + for (const [identity, fragment, title] of AGENTS) w.fragments.set(fragment, { role: "owner", kind: "agent", title, agents: [identity] }); + for (const [name, f] of extra) w.fragments.set(name, f); + w.files.set(`${ARCHIVE}:sessions/${KEY}.json`, JSON.stringify(conversation)); + const step = (name, act) => { + if (w.fail[name]) { + const status = w.fail[name]; + delete w.fail[name]; + throw err(status, `${name} failed`); + } + const answer = act(); + if (w.lose[name]) { + delete w.lose[name]; + throw new TypeError(`${name}: the answer was lost`); + } + return answer; + }; + w.api = async (method, path, body) => { + let m; + if (method === "GET" && (m = path.match(/^\/api\/f\/([^/]+)\/file\?path=(.*)$/))) { + const f = w.files.get(`${decodeURIComponent(m[1])}:${decodeURIComponent(m[2])}`); + if (f === undefined) throw err(404, "no such file"); + return JSON.parse(f); + } + if (method === "GET" && (m = path.match(/^\/api\/f\/([^/]+)\/status$/))) { + const f = w.fragments.get(decodeURIComponent(m[1])); + if (!f) throw err(404, "no such fragment"); + return { name: decodeURIComponent(m[1]), role: f.role }; + } + if (method === "GET" && (m = path.match(/^\/api\/f\/([^/]+)\/channels\/chat\?(after|before)=(\d+)&limit=(\d+)$/))) { + const all = w.records.get(decodeURIComponent(m[1])) ?? []; + const n = Number(m[4]); + return { records: m[2] === "after" ? all.slice(0, n) : all.slice(-n) }; + } + if (method === "POST" && path === "/api/fragments") + return step("create", () => { + if (!/^[a-z0-9-]+--[a-km-np-z2-9]{4}$/.test(body.name ?? "")) throw err(400, "no name in full"); + if (w.fragments.has(body.name)) throw err(409, "a name taken"); + w.creates++; + w.fragments.set(body.name, { role: "owner", kind: "chat", title: body.title, agents: [] }); + return { name: body.name }; + }); + if (method === "POST" && (m = path.match(/^\/api\/f\/([^/]+)\/channels\/chat$/))) + return step("history", () => { + if (bytes(body.body) > RECORD_MAX) throw err(413, "a record's body is past 64 KiB"); + const name = decodeURIComponent(m[1]); + const all = w.records.get(name) ?? []; + const had = all.find((r) => r.id === body.id); + if (had && JSON.stringify(had.body) !== JSON.stringify(body.body)) throw err(409, "the same id with another body"); + if (!had) { + w.posts++; + all.push({ id: body.id, seq: all.length + 1, at: Date.now(), principal: "npub1paul", kind: "message", body: body.body }); + w.records.set(name, all); + } + return { replayed: !!had }; + }); + if (method === "PUT" && (m = path.match(/^\/api\/f\/([^/]+)\/members\/(.+)$/))) + return step("member", () => { + const f = w.fragments.get(decodeURIComponent(m[1])); + const who = decodeURIComponent(m[2]); + if (!f.agents.includes(who)) f.agents.push(who); + return {}; + }); + throw err(404, `no route ${method} ${path}`); + }; + return w; +} + +// a tab of the shell: its own module, a store of its own (another browser, +// unless shared) +function tab(w, store = storeOf(), main = "npub1datastar") { + const t = { notices: [], opened: [] }; + t.c = createContinue({ + api: w.api, + ui: { el: () => ({}), notice: (title, words) => t.notices.push([title, words]) }, + data: { + allFragments: () => [...w.fragments].map(([name, f]) => ({ name, ...f })), + agents: () => new Map(AGENTS.map(([identity, fragment]) => [identity, { identity, fragment }])), + primaryAgent: () => ({ identity: main }), + titleOf: (name) => w.fragments.get(name)?.title ?? name, + seg: encodeURIComponent, + chatBase: (agent) => `${agent.replace(/--[a-km-np-z2-9]{4}$/, "")}-chat`, + }, + navigation: { openChat: (name) => t.opened.push(name), leaveSidebar() {} }, + load: async () => {}, + store, + }); + return t; +} + +function storeOf() { + const kept = new Map(); + return { get: (k, fallback) => (kept.has(k) ? JSON.parse(kept.get(k)) : fallback), set: (k, v) => kept.set(k, JSON.stringify(v)) }; +} + +const said = (n, text) => Array.from({ length: n }, (_, i) => ({ role: i % 2 ? "assistant" : "user", at: `2026-10-01T09:00:${String(i % 60).padStart(2, "0")}.000Z`, text: `${i} ${text}` })); +const conversation = (messages, summary, agent = "Juniper") => ({ id: "c1", title: "Beans", agent, started_at: "2026-10-01T09:00:00.000Z", ended_at: "2026-10-01T10:00:00.000Z", messages: [...(summary ? [{ role: "user", summary: true, text: summary }] : []), ...messages] }); +const SOURCE = { archive: ARCHIVE, key: KEY }; +const chats = (w) => [...w.fragments].filter(([, f]) => f.kind === "chat").map(([n]) => n); +"##; + +const CASES: &str = r##" +// ---- the history: bounded by its body's bytes, before anything is made +const cjk = "漢".repeat(4000); +for (const [name, c] of [ + ["six messages of 4,000 CJK characters", conversation(said(6, cjk))], + ["a summary of 8,000 CJK characters and 40 messages of 4,000", conversation(said(40, cjk), "要".repeat(8000))], + ["text that JSON escapes (quotes, backslashes, control characters)", conversation(said(40, '"\\\u0001'.repeat(1300)), '"\\\u0002'.repeat(2600))], +]) { + const h = historyOf(c, KEY, ARCHIVE); + const last = c.messages[c.messages.length - 1]; + check(`${name}: its history fits a record's body, the newest kept`, bytes(h) <= HISTORY_BODY_MAX_BYTES && HISTORY_BODY_MAX_BYTES < RECORD_MAX && h.messages.length > 0 && h.messages.at(-1).text.startsWith(last.text.slice(0, 20)), { bytes: bytes(h), kept: h.messages.length }); + const w = world(c); + const t = tab(w); + const made = await t.c.continueInto(SOURCE, "npub1juniper"); + check(`${name}: Continue makes one chat, its history posted, its agent in it`, made && w.creates === 1 && w.posts === 1 && w.fragments.get(made).agents.includes("npub1juniper") && t.opened[0] === made && !t.notices.length, { made, notices: t.notices }); +} +{ + // a summary covers what was said before it: the card holds it, then what + // was said after it, never what it summarized + const c = { id: "c1", title: "Beans", messages: [ + { role: "user", at: "2026-10-01T09:00:00.000Z", text: "before one" }, + { role: "assistant", at: "2026-10-01T09:00:01.000Z", text: "before two" }, + { role: "user", at: "2026-10-02T09:00:00.000Z", text: "what we said before", summary: true }, + { role: "user", at: "2026-10-02T09:00:01.000Z", text: "after one" }, + { role: "assistant", at: "2026-10-02T09:00:02.000Z", text: "after two" }, + ] }; + const h = historyOf(c, KEY, ARCHIVE); + check("a summary is followed by what was said after it alone, the rest counted earlier", h.summary === "what we said before" && JSON.stringify(h.messages.map((m) => m.text)) === JSON.stringify(["after one", "after two"]) && h.earlier === 2, h); +} + +// ---- the agent offered first +{ + const w = world(conversation(said(2, "hi")), [["fred-chat--k3x9", { role: "owner", kind: "chat", title: "Fred's", agents: ["npub1juniper"], archived: true }], ["other-chat--k3x9", { role: "owner", kind: "chat", agents: ["npub1someone"] }]]); + const t = tab(w); + check("an import is offered to the person's agent of its own agent's name", (await t.c.defaultAgent(SOURCE)) === "npub1juniper"); + check("an import whose agent has a name none of theirs has goes to their main one", (await tab(world(conversation(said(2, "hi"), null, "Hal"))).c.defaultAgent(SOURCE)) === "npub1datastar"); + check("an import says its agent's name itself, as its index has it, read from nothing", (await t.c.defaultAgent({ ...SOURCE, agent: "Datastar Pro" })) === "npub1datastar"); + check("an archived chat is offered to its lead agent, theirs", (await t.c.defaultAgent({ chat: "fred-chat--k3x9" })) === "npub1juniper"); + check("an archived chat whose lead agent is none of theirs goes to their main one", (await t.c.defaultAgent({ chat: "other-chat--k3x9" })) === "npub1datastar"); + check("the agents offered are the person's own, titled", JSON.stringify(t.c.agents().map((a) => a.title)) === JSON.stringify(["Datastar Pro", "Juniper"])); +} + +// ---- one chat a (source, agent) +{ + const w = world(conversation(said(4, "hello"))); + const [a, b] = [tab(w), tab(w)]; + const first = await a.c.continueInto(SOURCE, "npub1juniper"); + const again = await b.c.continueInto(SOURCE, "npub1juniper"); + check("Continue asked again from another tab (another store) opens the same chat, making none", first && again === first && w.creates === 1 && w.posts === 1, { first, again, creates: w.creates }); + check("its name says its agent and is the person's to find", /^juniper-chat-[a-km-np-z2-9]{4}--[a-km-np-z2-9]{4}$/.test(first) && (await a.c.continued(SOURCE, "npub1juniper")) === first, first); + check("another agent's has none yet", (await a.c.continued(SOURCE, "npub1datastar")) === null); + const other = await a.c.continueInto(SOURCE, "npub1datastar"); + check("Continue with another agent makes that agent's own chat", other && other !== first && w.creates === 2 && chats(w).length === 2, { other, first }); + w.fragments.delete(first); + const remade = await b.c.continueInto(SOURCE, "npub1juniper"); + check("a chat deleted since is made again, by the same name", remade === first && w.creates === 3, remade); +} +for (const [name, set] of [ + ["the history's post fails after the chat", (w) => (w.fail.history = 503)], + ["the agent's add fails after the chat and its history", (w) => (w.fail.member = 503)], + ["the create's answer is lost (the chat was made)", (w) => (w.lose.create = true)], + ["the history's answer is lost (it was posted)", (w) => (w.lose.history = true)], + ["the agent's add's answer is lost", (w) => (w.lose.member = true)], +]) { + const w = world(conversation(said(4, "hello"))); + set(w); + const t = tab(w); + const cut = await t.c.continueInto(SOURCE, "npub1juniper"); + const first = t.notices.at(-1)?.[0] ?? null; + const done = await t.c.continueInto(SOURCE, "npub1juniper"); + check( + `${name}: it says the chat is not ready, and Continue again finishes that chat, making none other`, + cut === null && first === "The chat is not ready yet" && done && chats(w).length === 1 && w.creates === 1 && w.posts === 1 && w.fragments.get(done).agents.includes("npub1juniper") && t.opened.at(-1) === done, + { first, done, chats: chats(w), creates: w.creates, posts: w.posts, notices: t.notices }, + ); +} +{ + const w = world(conversation(said(2, "hello"))); + w.fail.create = 402; + const t = tab(w); + await t.c.continueInto(SOURCE, "npub1juniper"); + check("a create refused says the chat was not made", t.notices.at(-1)?.[0] === "The chat was not made" && w.creates === 0, t.notices); +} +{ + // a name the hash gave that some other fragment holds: one at random, kept + // in the browser until its Continue is done + const w = world(conversation(said(2, "hello"))); + const probe = tab(world(conversation(said(2, "hello")))); + const taken = await probe.c.continueInto(SOURCE, "npub1juniper"); + w.fragments.set(taken, { role: "viewer", kind: "chat", agents: [] }); + const store = storeOf(); + const t = tab(w, store); + w.fail.member = 503; + await t.c.continueInto(SOURCE, "npub1juniper"); + const made = await t.c.continueInto(SOURCE, "npub1juniper"); + check("a name another's fragment holds gives way to one at random, the same on a try again, then let go", made && made !== taken && w.creates === 1 && (await t.c.continued(SOURCE, "npub1juniper")) === null && JSON.stringify(store.get("shell.continue.pending.v1", {})) === "{}", { made, taken, creates: w.creates }); +} +{ + // a Continue from before chats were named by their source: its marker's chat + const w = world(conversation(said(2, "hello")), [["datastar-pro-chat-13--ke3c", { role: "owner", kind: "chat", agents: ["npub1datastar"] }]]); + w.files.set(`${ARCHIVE}:continued/${KEY}.json`, JSON.stringify({ chat: "datastar-pro-chat-13--ke3c" })); + const t = tab(w); + const opened = await t.c.continueInto(SOURCE, "npub1datastar"); + const fresh = await t.c.continueInto(SOURCE, "npub1juniper"); + check("an archive's marker from before opens its chat for its agent, and another agent's is made", opened === "datastar-pro-chat-13--ke3c" && fresh && fresh !== opened && w.creates === 1, { opened, fresh }); +} + +// ---- who it was with, and an archived chat's history +{ + const w = world(conversation(said(2, "hello"), null, "Juniper")); + const t = tab(w); + const own = await t.c.continueInto(SOURCE, "npub1juniper"); + const other = await t.c.continueInto(SOURCE, "npub1datastar"); + const body = (name) => w.records.get(name)[0].body; + check("an import continued with its own agent says no other; with another, whose it was", !("agent" in body(own)) && body(other).agent === "Juniper", { own: body(own), other: body(other) }); +} +{ + const records = [ + { seq: 1, at: Date.parse("2026-10-20T09:00:00Z"), principal: "npub1paul", kind: "message", body: { text: "what's for dinner?" } }, + { seq: 2, at: Date.parse("2026-10-20T09:00:05Z"), principal: "npub1juniper", kind: "message", body: { text: "soup" } }, + { seq: 3, at: Date.parse("2026-10-20T09:00:06Z"), principal: "npub1juniper", kind: "turn.start", body: { kind: "turn.start" } }, + { seq: 4, at: Date.parse("2026-10-20T09:01:00Z"), principal: "npub1paul", kind: "message", body: { text: "which?" } }, + ]; + const w = world(conversation(said(2, "x")), [["juniper-chat-7--k3x9", { role: "owner", kind: "chat", title: "Dinner", agents: ["npub1juniper"], archived: true }]]); + w.records.set("juniper-chat-7--k3x9", records); + const t = tab(w); + const made = await t.c.continueInto({ chat: "juniper-chat-7--k3x9" }, "npub1datastar"); + const h = w.records.get(made)[0].body; + check( + "an archived chat continued with another agent: its history from its records, whose words whose, and where it was", + h.kind === "history" && h.chat === "juniper-chat-7--k3x9" && h.title === "Dinner" && h.agent === "Juniper" && JSON.stringify(h.messages.map((m) => [m.role, m.text])) === JSON.stringify([["user", "what's for dinner?"], ["assistant", "soup"], ["user", "which?"]]) && h.started_at === "2026-10-20T09:00:00.000Z" && !("archive" in h), + h, + ); + const again = await tab(w).c.continueInto({ chat: "juniper-chat-7--k3x9" }, "npub1datastar"); + check("and continued again, the same chat", again === made && w.creates === 1, again); +} +console.log(JSON.stringify(results)); +"##; + +/// Goal: Continue's history fits a record whatever its text and holds a +/// summary then what came after; one chat a (source, agent), finished by +/// the next Continue whatever cut the last short; the agent offered first +/// the right one; an archived chat's history from its records. Method: the +/// module imported by Node into a scripted platform (`WORLD`), every case +/// checked there, the results read back as JSON. +#[test] +fn continue_makes_one_chat_a_source_and_agent() { + let root = devstack::repo_root(); + let node = devstack::node::locate(&root.join(devstack::TOOLS_DIR)).expect("the pinned Node"); + let url = format!("file://{}", root.join("cell/shell/continue.js").display()); + let script = format!("const {{ createContinue, historyOf, HISTORY_BODY_MAX_BYTES }} = await import({});\n{WORLD}\n{CASES}", serde_json::to_string(&url).unwrap()); + let mut child = node + .command(&root.join(devstack::CACHE_DIR)) + .expect("a Node command") + .args(["--input-type=module", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("node runs"); + child.stdin.take().expect("stdin is piped").write_all(script.as_bytes()).expect("the script is written"); + let out = child.wait_with_output().expect("node finishes"); + assert!(out.status.success(), "node failed:\n{}", String::from_utf8_lossy(&out.stderr)); + let results: Vec = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&out.stdout))); + assert!(results.len() >= 24, "the cases ran: {}", results.len()); + let failed: Vec = results.iter().filter(|r| r["ok"] != true).map(|r| format!("{}: {}", r["name"], r["detail"])).collect(); + assert!(failed.is_empty(), "{} of {} failed:\n{}", failed.len(), results.len(), failed.join("\n")); +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 4fd2f38b4..f925e15e9 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -117,7 +117,7 @@ mod prepare; #[cfg(test)] mod markdown_dom; #[cfg(test)] -mod session_archive_js; +mod continue_js; mod rollout; mod schema_lock; // CI's freshness of its cached builds, compiled by itself with rustc diff --git a/xtask/src/session_archive_js.rs b/xtask/src/session_archive_js.rs deleted file mode 100644 index 2dc7278a5..000000000 --- a/xtask/src/session_archive_js.rs +++ /dev/null @@ -1,245 +0,0 @@ -//! The shell's Session Archive (cell/shell/session-archive.js) on the pinned -//! Node, its Continue against a scripted platform: the history record it -//! posts is bounded by its body's bytes as the cell measures a record's -//! (`limits::RECORD_BODY_MAX_BYTES`), whatever its text, before anything is -//! made; and a Continue cut short at any step, or whose answer was lost, is -//! finished by the next in the same chat, never another. The e2e's -//! sessions-ui section runs the same file in Chrome on a real platform. - -use std::io::Write; -use std::process::Stdio; - -use fragment_devstack as devstack; -use serde_json::Value; - -/// The platform as Continue meets it: the person's fragments, files, -/// records and members, each step able to fail once before it acts -/// (`fail`) or after it acted with its answer lost (`lose`). A post checks -/// its body's size as the cell does, and ids as the cell does: the same id -/// and body answer the record again, another body is 409. -const WORLD: &str = r##" -globalThis.document = { addEventListener() {}, hidden: false }; -globalThis.addEventListener = () => {}; -const RECORD_MAX = 64 * 1024; -const bytes = (v) => new TextEncoder().encode(JSON.stringify(v)).length; -const results = []; -const check = (name, ok, detail) => results.push({ name, ok: !!ok, detail: detail === undefined ? null : detail }); -const ARCHIVE = "archive--aaaa"; -const err = (status, message) => Object.assign(new Error(message), { status }); -const element = () => ({ hidden: false, setAttribute() {}, append() {}, dataset: {}, onclick: null }); - -function world(conversation) { - const w = { fragments: new Map([[ARCHIVE, { role: "owner", kind: "sessions" }]]), files: new Map(), records: new Map(), members: new Map(), creates: 0, posts: 0, fail: {}, lose: {} }; - w.files.set(`${ARCHIVE}:index/chat.json`, JSON.stringify({ sessions: [{ key: "hermes-c1", title: conversation.title }] })); - w.files.set(`${ARCHIVE}:sessions/hermes-c1.json`, JSON.stringify(conversation)); - const step = (name, act) => { - if (w.fail[name]) { - const status = w.fail[name]; - delete w.fail[name]; - throw err(status, `${name} failed`); - } - const answer = act(); - if (w.lose[name]) { - delete w.lose[name]; - throw new TypeError(`${name}: the answer was lost`); - } - return answer; - }; - w.api = async (method, path, body) => { - let m; - if (method === "GET" && (m = path.match(/^\/api\/f\/([^/]+)\/file\?path=(.*)$/))) { - const f = w.files.get(`${decodeURIComponent(m[1])}:${decodeURIComponent(m[2])}`); - if (f === undefined) throw err(404, "no such file"); - return JSON.parse(f); - } - if (method === "GET" && (m = path.match(/^\/api\/f\/([^/]+)\/status$/))) { - const f = w.fragments.get(decodeURIComponent(m[1])); - if (!f) throw err(404, "no such fragment"); - return { name: decodeURIComponent(m[1]), role: f.role }; - } - if (method === "POST" && path === "/api/fragments") - return step("create", () => { - if (!/^[a-z0-9-]+--[a-km-np-z2-9]{4}$/.test(body.name ?? "")) throw err(400, "no name in full"); - if (w.fragments.has(body.name)) throw err(409, "a name taken"); - w.creates++; - w.fragments.set(body.name, { role: "owner", kind: "chat", title: body.title }); - return { name: body.name }; - }); - if (method === "POST" && (m = path.match(/^\/api\/f\/([^/]+)\/channels\/chat$/))) - return step("history", () => { - if (bytes(body.body) > RECORD_MAX) throw err(413, "a record's body is past 64 KiB"); - const k = `${decodeURIComponent(m[1])}:${body.id}`; - const had = w.records.get(k); - if (had !== undefined && JSON.stringify(had) !== JSON.stringify(body.body)) throw err(409, "the same id with another body"); - if (had === undefined) { - w.posts++; - w.records.set(k, body.body); - } - return { replayed: had !== undefined }; - }); - if (method === "PUT" && (m = path.match(/^\/api\/f\/([^/]+)\/members\/(.+)$/))) - return step("member", () => (w.members.set(`${decodeURIComponent(m[1])}:${decodeURIComponent(m[2])}`, body.role), {})); - if (method === "POST" && (m = path.match(/^\/api\/f\/([^/]+)\/files$/))) - return step("marker", () => { - for (const f of body.files) w.files.set(`${decodeURIComponent(m[1])}:${f.path}`, f.text); - return {}; - }); - throw err(404, `no route ${method} ${path}`); - }; - return w; -} - -// a tab of the shell: its own module, the browser's store shared -function tab(w, store) { - const t = { notices: [], opened: [] }; - t.archive = createSessionArchive({ - api: w.api, - ui: { $: () => element(), el: () => element(), patch() {}, renderHeading() {}, notice: (title, words) => t.notices.push([title, words]) }, - data: { - allFragments: () => [...w.fragments].map(([name, f]) => ({ name, ...f })), own: (f) => f.role === "owner", seg: encodeURIComponent, - primaryAgent: () => ({ identity: "npub1agent" }), agents: () => new Map([["npub1agent", { fragment: "juniper--k3x9" }]]), chatLabel: () => "juniper-chat", - }, - selection: { current: () => null, page: () => null, getFrame: () => null }, - navigation: { openChat: (name) => t.opened.push(name), leaveSidebar() {} }, - load: async () => {}, - store, - }); - return t; -} - -function storeOf() { - const kept = new Map(); - return { get: (k, fallback) => (kept.has(k) ? JSON.parse(kept.get(k)) : fallback), set: (k, v) => kept.set(k, JSON.stringify(v)) }; -} - -const said = (n, text) => Array.from({ length: n }, (_, i) => ({ role: i % 2 ? "assistant" : "user", at: `2026-10-01T09:00:${String(i % 60).padStart(2, "0")}.000Z`, text: `${i} ${text}` })); -const conversation = (messages, summary) => ({ id: "c1", title: "Beans", started_at: "2026-10-01T09:00:00.000Z", ended_at: "2026-10-01T10:00:00.000Z", messages: [...(summary ? [{ role: "user", summary: true, text: summary }] : []), ...messages] }); -"##; - -const CASES: &str = r##" -// ---- the history is bounded by its body's bytes, before anything is made -const cjk = "漢".repeat(4000); -for (const [name, c] of [ - ["six messages of 4,000 CJK characters", conversation(said(6, cjk))], - ["a summary of 8,000 CJK characters and 40 messages of 4,000", conversation(said(40, cjk), "要".repeat(8000))], - ["text that JSON escapes (quotes, backslashes, control characters)", conversation(said(40, '"\\\u0001'.repeat(1300)), '"\\\u0002'.repeat(2600))], -]) { - const h = historyOf(c, "hermes-c1", ARCHIVE); - const last = c.messages[c.messages.length - 1]; - check(`${name}: its history fits a record's body, the newest kept`, bytes(h) <= HISTORY_BODY_MAX_BYTES && HISTORY_BODY_MAX_BYTES < RECORD_MAX && h.messages.length > 0 && h.messages[h.messages.length - 1].text.startsWith(last.text.slice(0, 20)) && h.earlier === c.messages.filter((m) => !m.summary).length - h.messages.length, { bytes: bytes(h), kept: h.messages.length, earlier: h.earlier, summary: !!h.summary }); - const w = world(c); - const t = tab(w, storeOf()); - await t.archive.load(); - await t.archive.continueSession("hermes-c1"); - check(`${name}: Continue makes one chat, its history posted, its agent in it`, w.creates === 1 && w.posts === 1 && w.members.size === 1 && t.opened.length === 1 && !t.notices.length, { creates: w.creates, posts: w.posts, notices: t.notices }); -} -{ - // a summary covers what was said before it: the card holds it, then what - // was said after it, never what it summarized - const c = { id: "c1", title: "Beans", messages: [ - { role: "user", at: "2026-10-01T09:00:00.000Z", text: "before one" }, - { role: "assistant", at: "2026-10-01T09:00:01.000Z", text: "before two" }, - { role: "user", at: "2026-10-02T09:00:00.000Z", text: "what we said before", summary: true }, - { role: "user", at: "2026-10-02T09:00:01.000Z", text: "after one" }, - { role: "assistant", at: "2026-10-02T09:00:02.000Z", text: "after two" }, - ] }; - const h = historyOf(c, "hermes-c1", ARCHIVE); - check("a summary is followed by what was said after it alone, the rest counted earlier", h.summary === "what we said before" && JSON.stringify(h.messages.map((m) => m.text)) === JSON.stringify(["after one", "after two"]) && h.earlier === 2, h); -} -{ - const c = conversation(said(3, "short"), "earlier, summarized"); - const h = historyOf(c, "hermes-c1", ARCHIVE); - check("a short conversation is kept whole, with its summary", h.messages.length === 3 && h.earlier === 0 && h.summary === "earlier, summarized", h); -} - -// ---- a Continue cut short, or whose answer was lost, is finished in the same chat -for (const [name, set] of [ - ["the marker's write fails after the chat, history and agent", (w) => (w.fail.marker = 503)], - ["the agent's add fails after the chat and its history", (w) => (w.fail.member = 503)], - ["the history's post fails after the chat", (w) => (w.fail.history = 503)], - ["the create's answer is lost (the chat was made)", (w) => (w.lose.create = true)], - ["the history's answer is lost (it was posted)", (w) => (w.lose.history = true)], - ["the marker's answer is lost (it was written)", (w) => (w.lose.marker = true)], -]) { - const w = world(conversation(said(4, "hello"))); - set(w); - const store = storeOf(); - const t = tab(w, store); - await t.archive.load(); - await t.archive.continueSession("hermes-c1"); - const first = t.notices.at(-1)?.[0] ?? null; - await t.archive.continueSession("hermes-c1"); - const chats = [...w.fragments].filter(([, f]) => f.kind === "chat").map(([n]) => n); - const marker = w.files.get(`${ARCHIVE}:continued/hermes-c1.json`); - check( - `${name}: it says the chat is not ready, and Continue again finishes that chat, making none other`, - first === "The chat is not ready yet" && chats.length === 1 && w.creates === 1 && w.posts === 1 && w.members.size === 1 && JSON.parse(marker).chat === chats[0] && t.opened.at(-1) === chats[0] && store.get("shell.archive.continuing.v1", {})[`${ARCHIVE} hermes-c1`] === undefined, - { first, chats, creates: w.creates, posts: w.posts, members: w.members.size, opened: t.opened, notices: t.notices }, - ); -} -{ - // a refusal before anything is made says so - const w = world(conversation(said(2, "hello"))); - w.fail.create = 402; - const t = tab(w, storeOf()); - await t.archive.load(); - await t.archive.continueSession("hermes-c1"); - check("a create refused says the chat was not made", t.notices.at(-1)?.[0] === "The chat was not made" && w.creates === 0, t.notices); -} -{ - // a stale tab: another tab continued it; this one, asked again, opens that chat - const w = world(conversation(said(2, "hello"))); - const [a, b] = [tab(w, storeOf()), tab(w, storeOf())]; - await a.archive.load(); - await b.archive.load(); - await a.archive.continueSession("hermes-c1"); - await b.archive.continueSession("hermes-c1"); - check("a stale tab's Continue opens the chat another made, making none", w.creates === 1 && b.opened[0] === a.opened[0] && !b.notices.length, { creates: w.creates, a: a.opened, b: b.opened }); - // its chat deleted since, a Continue makes another - w.fragments.delete(a.opened[0]); - await b.archive.continueSession("hermes-c1"); - check("a continued conversation whose chat is gone makes a new one", w.creates === 2 && b.opened.at(-1) !== a.opened[0] && JSON.parse(w.files.get(`${ARCHIVE}:continued/hermes-c1.json`)).chat === b.opened.at(-1), { creates: w.creates, b: b.opened }); -} -{ - // a name kept, then taken by someone else's fragment: a new one next time - const w = world(conversation(said(2, "hello"))); - const store = storeOf(); - store.set("shell.archive.continuing.v1", { [`${ARCHIVE} hermes-c1`]: "juniper-chat--zzzz" }); - w.fragments.set("juniper-chat--zzzz", { role: "viewer", kind: "chat" }); - const t = tab(w, store); - await t.archive.load(); - await t.archive.continueSession("hermes-c1"); - await t.archive.continueSession("hermes-c1"); - check("a kept name another's fragment holds is let go, and the next Continue makes the chat", t.notices[0]?.[0] === "The chat was not made" && w.creates === 1 && t.opened.length === 1 && t.opened[0] !== "juniper-chat--zzzz", { notices: t.notices, opened: t.opened }); -} -console.log(JSON.stringify(results)); -"##; - -/// Goal: Continue's history fits a record whatever its text, and a -/// Continue cut short at any step, or whose answer was lost, is finished -/// in the same chat, never another; a stale tab opens the chat another -/// made. Method: the module imported by Node into a scripted platform -/// (`WORLD`), every case checked there, the results read back as JSON. -#[test] -fn continue_fits_its_history_and_finishes_what_it_began() { - let root = devstack::repo_root(); - let node = devstack::node::locate(&root.join(devstack::TOOLS_DIR)).expect("the pinned Node"); - let url = format!("file://{}", root.join("cell/shell/session-archive.js").display()); - let script = format!("const {{ createSessionArchive, historyOf, HISTORY_BODY_MAX_BYTES }} = await import({});\n{WORLD}\n{CASES}", serde_json::to_string(&url).unwrap()); - let mut child = node - .command(&root.join(devstack::CACHE_DIR)) - .expect("a Node command") - .args(["--input-type=module", "-"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("node runs"); - child.stdin.take().expect("stdin is piped").write_all(script.as_bytes()).expect("the script is written"); - let out = child.wait_with_output().expect("node finishes"); - assert!(out.status.success(), "node failed:\n{}", String::from_utf8_lossy(&out.stderr)); - let results: Vec = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&out.stdout))); - assert!(results.len() >= 16, "the cases ran: {}", results.len()); - let failed: Vec = results.iter().filter(|r| r["ok"] != true).map(|r| format!("{}: {}", r["name"], r["detail"])).collect(); - assert!(failed.is_empty(), "{} of {} failed:\n{}", failed.len(), results.len(), failed.join("\n")); -} From db8fae5c17f149167e16304791b2d2fd618a4b21 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:35:07 -0500 Subject: [PATCH 08/14] e2e: a continued chat's note says whose words they were This section's agent is not named as the conversation's was (Juniper), so Continue's history names Juniper, and the bridge's note says so. Co-Authored-By: Claude Opus 5.5 --- crates/e2e/src/lanes/sessions.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/e2e/src/lanes/sessions.rs b/crates/e2e/src/lanes/sessions.rs index 4067b4fbd..cf77ece60 100644 --- a/crates/e2e/src/lanes/sessions.rs +++ b/crates/e2e/src/lanes/sessions.rs @@ -185,8 +185,10 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { !reply.is_empty() }); s.ok( - "the agent is told what was said there at its first turn in the chat, as a note", - said.status == 200 && answered && reply.contains("(told: This chat goes on from a conversation your owner had with you before they moved here, “Beans”") && reply.contains("Your owner: plant beans?\nYou: in spring"), + "the agent is told what was said there at its first turn in the chat, as a note, whose words they were", + // this agent is not named as the conversation's was (Juniper): the + // note says whose words they were, never "you" + said.status == 200 && answered && reply.contains("(told: This chat goes on from a conversation your owner had with Juniper before they moved here, “Beans”") && reply.contains("Your owner: plant beans?\nJuniper: in spring"), &reply, ); From 909502e6a6b1553538f2c08b8ef4cafaadb3c9ca Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:38:46 -0500 Subject: [PATCH 09/14] xtask: the archive's Continue test makes it as the shell does now Its sidebar rows went to History: the module takes `changed` in their place, and no longer `$`, `el`, `patch`, `current` or `page`. Co-Authored-By: Claude Opus 5.5 --- xtask/src/session_archive_js.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/xtask/src/session_archive_js.rs b/xtask/src/session_archive_js.rs index d4718c3c3..8e20cfbe7 100644 --- a/xtask/src/session_archive_js.rs +++ b/xtask/src/session_archive_js.rs @@ -94,15 +94,16 @@ function tab(w, store) { const t = { notices: [], opened: [] }; t.archive = createSessionArchive({ api: w.api, - ui: { $: () => element(), el: () => element(), patch() {}, renderHeading() {}, notice: (title, words) => t.notices.push([title, words]) }, + ui: { renderHeading() {}, notice: (title, words) => t.notices.push([title, words]) }, data: { allFragments: () => [...w.fragments].map(([name, f]) => ({ name, ...f })), own: (f) => f.role === "owner", seg: encodeURIComponent, primaryAgent: () => ({ identity: "npub1agent" }), agents: () => new Map([["npub1agent", { fragment: "juniper--k3x9" }]]), chatLabel: () => "juniper-chat", }, - selection: { current: () => null, page: () => null, getFrame: () => null }, + selection: { getFrame: () => null }, navigation: { openChat: (name) => t.opened.push(name), leaveSidebar() {} }, load: async () => {}, store, + changed() {}, }); return t; } From 86ebebaced97f9a17a01602a6171e67dfc424ace Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:38:46 -0500 Subject: [PATCH 10/14] e2e: History, as the person uses it A continued chat listed with its card's time and words, a chat nothing was said in left out; archived, under Archived with its chip and gone from the sidebar; its title filtering at once, words nothing holds said so; opened and one Back to History; a message sent in it bringing it back to the sidebar; a word only its messages hold finding it with its snippet; and a reload at /history staying there. Co-Authored-By: Claude Opus 5.5 --- crates/e2e/src/lanes/shell.rs | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/crates/e2e/src/lanes/shell.rs b/crates/e2e/src/lanes/shell.rs index 4f2d32acc..f0741b2e1 100644 --- a/crates/e2e/src/lanes/shell.rs +++ b/crates/e2e/src/lanes/shell.rs @@ -1281,6 +1281,8 @@ pub fn shell_ui(s: &mut Suite, api: &Api) -> Result<()> { // both agents in one chat, search, and archiving, as the person uses them groups_ui(s, api, &mut b, &page, &me, &shots)?; + // every chat they have had, in History + history_ui(s, api, &mut b, &page, &me, &shots)?; // the person's agents in any chat's @, and one asking another roster_ui(s, api, &mut b, &page, &me, &shots)?; ask_cli(s, api, &me)?; @@ -5298,6 +5300,87 @@ fn say_in(s: &Suite, b: &mut Browser, page: &Page, host: &str, text: &str) -> bo s.eventually(std::time::Duration::from_secs(30), || b.eval_in_frame(page, host, &typed).ok() == Some(Value::Bool(true))) } +/// History (docs/api.md, The shell; Paul, 2026-10-31): every chat the +/// person has had, the newest first, with its search. Valid: a continued +/// chat (its history card alone) is listed with the card's time and newest +/// words; archived, it is under Archived with its chip, and gone from the +/// sidebar; its title filters it at once, and a word only its messages +/// hold finds it through the person's search, its snippet shown; it opens +/// in the middle column and one Back is History again. Invalid: a chat +/// nothing was said in is not listed, and words nothing holds say so. +/// Replay: a message the person sends in the archived chat brings it back +/// to their sidebar. Restart: a reload at `/history` stays there. +fn history_ui(s: &mut Suite, api: &Api, b: &mut Browser, page: &Page, me: &Person, shots: &std::path::Path) -> Result<()> { + let wait = std::time::Duration::from_secs(30); + let first_id = agent_ids(api, me.session, &[me.first])?.remove(0); + let made = |label: &str, title: &str| -> Result { + let r = shell(api, me.session, "POST", "/api/fragments", Some(&json!({ "label": label, "template": "chat", "title": title })), &[])?; + let name = r.body["name"].as_str().unwrap_or_default().to_string(); + shell(api, me.session, "PUT", &format!("/api/f/{name}/members/{first_id}"), Some(&json!({ "role": "editor" })), &[])?; + Ok(name) + }; + // a chat continued (Continue's, as continue.js makes one): its history card alone + let bread = made(&format!("{}-chat-bread", me.first), "Bread notes")?; + let card = json!({ "kind": "history", "title": "Bread notes", "archive": "e2e", "key": "e2e-bread", "messages": [{ "role": "user", "text": "When do I bake?" }, { "role": "assistant", "text": "Once the starter doubles within six hours." }], "earlier": 0 }); + let posted = shell(api, me.session, "POST", &format!("/api/f/{bread}/channels/chat"), Some(&json!({ "id": "history", "body": card })), &[])?; + // a chat nothing was said in + let unused = made(&format!("{}-chat-unused", me.first), "Never used")?; + b.click(page, "#history-link")?; + let row = |name: &str| format!("document.querySelector({})", js(&format!(".history-row[data-key={}]", js(&format!("chat:{name}"))))); + let listed = b.until(page, &format!("location.pathname === '/history' && !document.getElementById('history-page').hidden && {}?.innerText.includes('Once the starter doubles') && !!{}?.querySelector('time')?.dateTime", row(&bread), row(&bread)), wait); + let unlisted = b.eval(page, &format!("!{}", row(&unused)))?; + s.ok( + "History lists a continued chat with its card's time and newest words, and not a chat nothing was said in", + posted.status == 200 && listed && unlisted == true, + json!({ "row": b.eval(page, &format!("{}?.innerText ?? null", row(&bread)))?, "unused": unlisted }), + ); + // archived: under Archived, with its chip, and gone from the sidebar + shell(api, me.session, "PUT", &format!("/api/fragments/{bread}/archived"), Some(&json!({ "archived": true })), &[])?; + b.click(page, ".history-filter[data-filter=\"archived\"]")?; + let archived = b.until(page, &format!("{}?.querySelector('.history-chip')?.textContent === 'Archived' && [...document.querySelectorAll('.history-row')].every((r) => r.querySelector('.history-chip')) && !document.querySelector({})", row(&bread), js(&format!("#chats [data-key={}]", js(&format!("chat:{bread}"))))), wait); + let _ = b.screenshot(page, &shots.join("desktop-history-archived.png")); + s.ok("archived, it is under Archived with its chip, and gone from the sidebar", archived, json!({ "rows": b.eval(page, "[...document.querySelectorAll('.history-row')].map((r) => r.innerText)")? })); + b.click(page, ".history-filter[data-filter=\"all\"]")?; + // its title filters it at once; words nothing holds say so + b.eval(page, &fill(".history-search input", "bread no"))?; + let titled = b.until(page, &format!("document.querySelectorAll('.history-row').length === 1 && !!{}", row(&bread)), wait); + b.eval(page, &fill(".history-search input", "zqxjv nothing"))?; + let none = b.until(page, "document.querySelector('.history-empty')?.textContent === 'Nothing found for “zqxjv nothing”'", wait); + b.eval(page, &fill(".history-search input", ""))?; + s.ok("its title filters History as it is typed, and words nothing holds say so", titled && none, json!({ "list": b.eval(page, "document.querySelector('.history-list').innerText")? })); + // opened from History, then one Back: History again + b.click(page, &format!(".history-row[data-key={}] .history-open", js(&format!("chat:{bread}"))))?; + let opened = b.until(page, &format!("location.pathname === '/' && document.getElementById('history-page').hidden && document.getElementById('chat-title').textContent === 'Bread notes' && [...document.querySelectorAll('#frames iframe')].find((f) => !f.hidden)?.dataset.fragment === {}", js(&bread)), wait); + b.eval(page, "(history.back(), true)")?; + let back = b.until(page, "location.pathname === '/history' && !document.getElementById('history-page').hidden", wait); + s.ok("a chat opens from History in the middle column, and one Back is History again", opened && back, json!({ "at": b.eval(page, "location.pathname")? })); + // a message sent in the archived chat brings it back to the sidebar + b.click(page, &format!(".history-row[data-key={}] .history-open", js(&format!("chat:{bread}"))))?; + let said = "the rye wants a warmer spot"; + let sent = say_in(s, b, page, &bread, said); + let back_in_list = s.eventually(wait, || { + shell(api, me.session, "GET", "/api/fragments", None, &[]).ok().and_then(|r| r.body["fragments"].as_array().and_then(|l| l.iter().find(|f| f["name"] == bread.as_str()).map(|f| f["archived"] != true))).unwrap_or(false) + }); + let in_sidebar = b.until(page, &format!("!!document.querySelector({})", js(&format!("#chats [data-key={}]", js(&format!("chat:{bread}"))))), wait); + s.ok("a message the person sends in a chat they archived brings it back to their sidebar", sent && back_in_list && in_sidebar, json!({ "sent": sent, "unarchived": back_in_list, "sidebar": in_sidebar })); + // a word only its messages hold: found by the person's search, its snippet shown + b.click(page, "#history-link")?; + b.eval(page, &fill(".history-search input", "warmer"))?; + let found = b.until(page, &format!("{}?.querySelector('.history-preview.found')?.textContent.includes('warmer')", row(&bread)), wait); + let _ = b.screenshot(page, &shots.join("desktop-history-search.png")); + b.eval(page, &fill(".history-search input", ""))?; + s.ok("a word only a chat's messages hold finds it in History, its snippet shown", found, json!({ "list": b.eval(page, "document.querySelector('.history-list').innerText")? })); + // a reload at /history stays there + b.eval(page, "(window.__before_reload = true, true)")?; + b.reload(page)?; + let stays = b.until(page, &format!("!window.__before_reload && location.pathname === '/history' && !document.getElementById('history-page').hidden && !!{}", row(&bread)), wait); + s.ok("a reload at /history opens History again", stays, json!({ "at": b.eval(page, "location.pathname")? })); + // the rest of the lane goes on from a chat + b.click(page, &format!(".history-row[data-key={}] .history-open", js(&format!("chat:{bread}"))))?; + b.until(page, "location.pathname === '/'", wait); + Ok(()) +} + /// Unread (docs/api.md, The shell): a chat not open counts on its row what /// came since its person saw it, as their list does; opening it marks it /// seen, here and in their list; and the open chat counts nothing as its From b79a2eeaa142e518e18392afbe56a109cd96901a Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 14:45:47 -0500 Subject: [PATCH 11/14] shell: Continue on History: its rows' controls, and the e2e through History - History lists 49 published shell files (its page and continue.js). - A Continue that makes its chat tells every control on the page, so a History row's control made before says Open for it at once. - The e2e's sessions-ui section goes in through History (its link, the Imported filter, the row's open), continues through the archive's dialog, and finds the row's control an Open that opens that chat. Co-Authored-By: Claude Opus 5.5 --- cell/shell/continue.js | 9 ++++++++ crates/e2e/src/lanes/sessions.rs | 37 +++++++++++++++++++++----------- crates/templates/src/lib.rs | 2 +- xtask/src/continue_js.rs | 1 + 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/cell/shell/continue.js b/cell/shell/continue.js index d94d7cf25..5185d0f7a 100644 --- a/cell/shell/continue.js +++ b/cell/shell/continue.js @@ -93,6 +93,8 @@ export function createContinue({ }) { // a chat's title, as the sidebar's room has it const TITLE_MAX = 48; + // what a Continue that made its chat tells the controls on the page + const CONTINUED = "fragment-continued"; // the chat a Continue not yet finished is making, by source and agent, // when its own name was another's (`nameFor`) const PENDING = "shell.continue.pending.v1"; @@ -228,6 +230,8 @@ export function createContinue({ await loadList(); openChat(name); leaveSidebar(); + // every control on the page says Open for it now (`continueControl`) + document.dispatchEvent(new CustomEvent(CONTINUED)); return name; } catch (e) { if (made) notice("The chat is not ready yet", `${e.message} Continue again to finish it.`); @@ -289,6 +293,11 @@ export function createContinue({ if (name) done(name); }; pick.onchange = () => void refresh(); + // a Continue elsewhere (the archive's dialog, another row) may have made + // this one's chat: asked again while it is on the page + document.addEventListener(CONTINUED, () => { + if (box.isConnected) void refresh(); + }); box.append(go); if (mine.length > 1) box.append(pick); void defaultAgent(source).then((identity) => { diff --git a/crates/e2e/src/lanes/sessions.rs b/crates/e2e/src/lanes/sessions.rs index cf77ece60..3d76e692a 100644 --- a/crates/e2e/src/lanes/sessions.rs +++ b/crates/e2e/src/lanes/sessions.rs @@ -110,11 +110,19 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { b.set_cookie(&format!("{}/", api.base), "fragment_session", &session)?; let page = b.open(&format!("{}/settings", api.base))?; b.viewport(&page, 1400, 900, false)?; - let listed = b.until(&page, "document.querySelectorAll('#archive:not([hidden]) #archive-rows .row').length === 2", wait); - let rows = b.eval(&page, "[...document.querySelectorAll('#archive-rows .row')].map((r) => r.textContent)")?; - s.ok("the shell lists the archive's chats under Session Archive, newest first", listed && rows == json!(["A long letter", "Beans"]), &rows); + // History lists them, imported, newest first + let imported = "document.querySelectorAll('.history-row[data-kind=\"imported\"]')"; + // the shell started (its layout shown, the link with it) + b.until(&page, "!document.getElementById('layout').hidden && !!document.getElementById('history-link')?.offsetParent", wait); + b.click(&page, "#history-link")?; + b.until(&page, "!!document.querySelector('.history-filter[data-filter=\"imported\"]')?.offsetParent", wait); + b.click(&page, ".history-filter[data-filter=\"imported\"]")?; + let listed = b.until(&page, &format!("{imported}.length === 2"), wait); + let rows = b.eval(&page, &format!("[...{imported}].map((r) => r.querySelector('.history-line .label').textContent)"))?; + s.ok("History lists the archive's chats as imported, newest first", listed && rows == json!(["A long letter", "Beans"]), &rows); + let beans = ".history-row[data-key=\"imported:hermes-e2e_beans\"]"; - b.click(&page, "#archive-rows [data-key=\"archived:hermes-e2e_beans\"]")?; + b.click(&page, &format!("{beans} .history-open"))?; let opened = b.until(&page, "document.getElementById('chat-title').textContent === 'Beans' && document.getElementById('chat-options').hidden", wait); let frame = "?s=hermes-e2e_beans"; let drawn = s.eventually(wait, || b.eval_in_frame(&page, frame, "document.querySelectorAll('.msg').length === 2").is_ok_and(|v| v == true)); @@ -137,7 +145,7 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { let folded = s.eventually(wait, || { b.eval_in_frame(&page, &name, "[...document.querySelectorAll('details.kind')].map((d) => [d.open, d.querySelector('summary').textContent]).join('|') === 'false,Scheduled runs (1)'").is_ok_and(|v| v == true) }); - let headed = b.until(&page, "document.getElementById('chat-title').textContent === 'Session Archive' && !document.querySelector('#archive-rows .row.active')", wait); + let headed = b.until(&page, "document.getElementById('chat-title').textContent === 'Session Archive'", wait); s.ok( "the archive's own list shows its chats and folds its scheduled runs away, and the shell's heading follows it there", folded && headed, @@ -147,7 +155,9 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { // Continue: the shell asks with which agent (one here: "Continue"), and // makes a chat with it, its history first - b.click(&page, "#archive-rows [data-key=\"archived:hermes-e2e_beans\"]")?; + b.click(&page, "#history-link")?; + b.until(&page, &format!("!!document.querySelector('{beans} .history-open')"), wait); + b.click(&page, &format!("{beans} .history-open"))?; let offered = s.eventually(wait, || b.eval_in_frame(&page, frame, "document.querySelector('.archive-continue:not([hidden])')?.textContent === 'Continue'").is_ok_and(|v| v == true)); b.eval_in_frame(&page, frame, "(document.querySelector('.archive-continue').click(), true)")?; let asked = b.until(&page, "document.querySelector('dialog.continue-ask[open] .continue-go')?.textContent === 'Continue'", wait); @@ -192,13 +202,14 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { &reply, ); - // asked again, it offers that chat (one a conversation and agent), and opens it - b.click(&page, "#archive-rows [data-key=\"archived:hermes-e2e_beans\"]")?; - s.eventually(wait, || b.eval_in_frame(&page, frame, "!!document.querySelector('.archive-continue:not([hidden])')").is_ok_and(|v| v == true)); - b.eval_in_frame(&page, frame, "(document.querySelector('.archive-continue').click(), true)")?; - let open = b.until(&page, "document.querySelector('dialog.continue-ask[open] .continue-go')?.textContent === 'Open'", wait); - b.eval(&page, "(document.querySelector('dialog.continue-ask[open] .continue-go').click(), true)")?; - let reopened = b.until(&page, &format!("!document.querySelector('dialog.continue-ask[open]') && [...document.querySelectorAll('#frames iframe')].find((f) => !f.hidden)?.dataset.fragment === {chat:?}"), wait); + // History offers that chat now (one a conversation and agent), its row's + // Continue an Open, and opens it + b.click(&page, "#history-link")?; + let control = format!("{beans} .continue-control .continue-go"); + let open = b.until(&page, &format!("document.querySelector({control:?})?.textContent === 'Open'"), wait); + b.screenshot(&page, &shots.join("history-open.png"))?; + b.click(&page, &control)?; + let reopened = b.until(&page, &format!("[...document.querySelectorAll('#frames iframe')].find((f) => !f.hidden)?.dataset.fragment === {chat:?}"), wait); let listed = api.signed(&keys, "GET", "/api/fragments", None)?; let beans = listed.body["fragments"].as_array().into_iter().flatten().filter(|f| f["kind"] == "chat" && f["title"] == "Beans").count(); s.ok("Continue asked again offers the chat it made with that agent, and opens it, making none other", open && reopened && beans == 1, beans); diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index df8fbbc56..e7abfae15 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -112,7 +112,7 @@ pub fn shell_page(text: &str, release: &str, deployed: Option) -> String { /// each with its content type: of the rest of cell/shell/, its pages are /// served at their own paths, and its credits and licenses not at all. #[cfg(feature = "platform")] -const SHELL_PUBLISHED: [(&str, &str); 48] = [ +const SHELL_PUBLISHED: [(&str, &str); 49] = [ // the cell's server-rendered pages' base look (share.rs `sheet_html`), // on the platform's origin and, as /__sheet.css, on a fragment's ("sheet.css", "text/css; charset=utf-8"), diff --git a/xtask/src/continue_js.rs b/xtask/src/continue_js.rs index 0d2ec160c..4c22dfde8 100644 --- a/xtask/src/continue_js.rs +++ b/xtask/src/continue_js.rs @@ -23,6 +23,7 @@ use serde_json::Value; /// ids as the cell does: the same id and body answer the record again, /// another body is 409. const WORLD: &str = r##" +globalThis.document = { dispatchEvent() {}, addEventListener() {} }; const RECORD_MAX = 64 * 1024; const bytes = (v) => new TextEncoder().encode(JSON.stringify(v)).length; const results = []; From 0e68307552a2a5b90251b77355eb8367fc4e348f Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 15:07:10 -0500 Subject: [PATCH 12/14] shell: Continue drops the archive's old continued/.json markers Marker-based Continue never reached production; the chats it made on dev and preview stay, their rows offering "Continue with" until continued again. Co-Authored-By: Claude Opus 5.5 --- cell/shell/continue.js | 16 ---------------- xtask/src/continue_js.rs | 9 --------- 2 files changed, 25 deletions(-) diff --git a/cell/shell/continue.js b/cell/shell/continue.js index 5185d0f7a..77ad00f7b 100644 --- a/cell/shell/continue.js +++ b/cell/shell/continue.js @@ -151,15 +151,6 @@ export function createContinue({ return !first || (b?.kind === "history" && (source.chat ? b.chat === source.chat : b.archive === source.archive && b.key === source.key)); } - // a Continue from before chats were named by their source (one archive - // marker, `continued/.json`): its chat, when it is with `agent` - async function marked(source, agent) { - if (source.chat) return null; - const m = await fileOf(source.archive, `continued/${source.key}.json`).catch(() => null); - const chat = typeof m?.chat === "string" ? allFragments().find((f) => f.name === m.chat && f.kind === "chat") : null; - return chat?.agents?.includes(agent) ? chat.name : null; - } - // the source's history and the new chat's title, read now async function historyFor(source, agent) { if (source.chat) { @@ -197,13 +188,6 @@ export function createContinue({ }; let made = false; try { - const before = await marked(source, agent); - if (before) { - await loadList(); - openChat(before); - leaveSidebar(); - return before; - } // bounded before anything is made const { title, body } = await historyFor(source, agent); let name = store.get(PENDING, {})[slot] ?? (await nameFor(source, agent, chatBase(fragment))); diff --git a/xtask/src/continue_js.rs b/xtask/src/continue_js.rs index 4c22dfde8..76f711349 100644 --- a/xtask/src/continue_js.rs +++ b/xtask/src/continue_js.rs @@ -233,15 +233,6 @@ for (const [name, set] of [ const made = await t.c.continueInto(SOURCE, "npub1juniper"); check("a name another's fragment holds gives way to one at random, the same on a try again, then let go", made && made !== taken && w.creates === 1 && (await t.c.continued(SOURCE, "npub1juniper")) === null && JSON.stringify(store.get("shell.continue.pending.v1", {})) === "{}", { made, taken, creates: w.creates }); } -{ - // a Continue from before chats were named by their source: its marker's chat - const w = world(conversation(said(2, "hello")), [["datastar-pro-chat-13--ke3c", { role: "owner", kind: "chat", agents: ["npub1datastar"] }]]); - w.files.set(`${ARCHIVE}:continued/${KEY}.json`, JSON.stringify({ chat: "datastar-pro-chat-13--ke3c" })); - const t = tab(w); - const opened = await t.c.continueInto(SOURCE, "npub1datastar"); - const fresh = await t.c.continueInto(SOURCE, "npub1juniper"); - check("an archive's marker from before opens its chat for its agent, and another agent's is made", opened === "datastar-pro-chat-13--ke3c" && fresh && fresh !== opened && w.creates === 1, { opened, fresh }); -} // ---- who it was with, and an archived chat's history { From 9fcd7fdb765667d493b16d68fb3e1924231f4fca Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 15:59:29 -0500 Subject: [PATCH 13/14] docs: the shell's row names History, where the Session Archive's chats are listed Co-Authored-By: Claude Opus 5.5 --- SPECIAL-CASE-INVENTORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPECIAL-CASE-INVENTORY.md b/SPECIAL-CASE-INVENTORY.md index 64144254d..dbd8c41ad 100644 --- a/SPECIAL-CASE-INVENTORY.md +++ b/SPECIAL-CASE-INVENTORY.md @@ -15,7 +15,7 @@ a vendor's product beyond the credential it holds. | Surface | Why it is platform | Built on | |---|---|---| | Sign-in, sessions, CLI approval (`/auth/*`, `/cli`; the shell's tabs sign in through `/auth/frame`; a stranger's fragment asks first, at `/auth/fragment`) | Security: it mints the credentials every other surface trusts. Which of a browser's cookies count on a fragment's origin is the router's (Fetch Metadata), so another fragment's page reaches it as a stranger (docs/api.md, Sign-in). | WorkOS AuthKit; the registry | -| The shell (`/`, `/settings`, `/apps`, `/welcome`): sidebar, desktop (its dock and windows), settings, the Apps page (each of your fragments but chats and agents: your dock, its card, its icon, who has access and its share sheet; one page, as it acts as you over all of them, which no fragment may), the launcher (search), first run (the welcome); your Session Archive (your one fragment of kind `sessions`: its chats' index under a heading below your chats, each opened read-only in the middle column, its own page framed as a chat is; its Continue, asked by that page and done by the shell as you, makes a chat with the agent of yours you pick (one a conversation and agent) whose first record is the conversation's history, and does the same for an archived chat of yours: templates/sessions/README.md, docs/chat-records.md); to a page of a fragment you own that asks, your agents, and adding one of them to that fragment; to your own chat's page, a way to ask for an app put in its composer (`compose`; docs/api.md, The shell); to a chat's page of yours, its alerts, which no frame may ask the browser for (the shell's alerts worker, one subscription a chat, made with the chat's own key); what your agent is doing, which it reads from your chats' rows' listed records by the chat's turn kinds (docs/chat-records.md): the shell is that contract's third reader, beside the bridge and the chat's page, so a change to it changes the shell too | Security: it holds your session and frames your fragments and computers. The thin page users can't break. A page asks it, never grants: the shell checks the page is your own fragment's, at its own origin, offers only your own agents, and adds one only once you confirm it in the shell's own dialog, every time. A chat's page takes `compose` only from the platform's origin (its hello's `platform`) and sends nothing: you do. | The public fragment, computer, identity and ledger APIs | +| The shell (`/`, `/settings`, `/apps`, `/history`, `/welcome`): sidebar, desktop (its dock and windows), settings, History (every chat you have had, archived and imported ones too, and its search), the Apps page (each of your fragments but chats and agents: your dock, its card, its icon, who has access and its share sheet; one page, as it acts as you over all of them, which no fragment may), the launcher (search), first run (the welcome); your Session Archive (your one fragment of kind `sessions`: its chats' index listed in History, each opened read-only in the middle column, its own page framed as a chat is; its Continue, asked by that page and done by the shell as you, makes a chat with the agent of yours you pick (one a conversation and agent) whose first record is the conversation's history, and does the same for an archived chat of yours: templates/sessions/README.md, docs/chat-records.md); to a page of a fragment you own that asks, your agents, and adding one of them to that fragment; to your own chat's page, a way to ask for an app put in its composer (`compose`; docs/api.md, The shell); to a chat's page of yours, its alerts, which no frame may ask the browser for (the shell's alerts worker, one subscription a chat, made with the chat's own key); what your agent is doing, which it reads from your chats' rows' listed records by the chat's turn kinds (docs/chat-records.md): the shell is that contract's third reader, beside the bridge and the chat's page, so a change to it changes the shell too | Security: it holds your session and frames your fragments and computers. The thin page users can't break. A page asks it, never grants: the shell checks the page is your own fragment's, at its own origin, offers only your own agents, and adds one only once you confirm it in the shell's own dialog, every time. A chat's page takes `compose` only from the platform's origin (its hello's `platform`) and sends nothing: you do. | The public fragment, computer, identity and ledger APIs | | Identity and delegation | Security: who an agent acts for, and at what role. | The registry | | Optional public addresses (`GET`/`PUT /api/f/{name}/address`) | Security: first-come reservations coordinate across fragments and permanently bind an origin to one app identity, so a delete never gives another app its cookies or storage. | The registry and each fragment's own identity and owner | | Connections and the egress swap | Security: it holds the route to your accounts' tokens and the operator's keys. | WorkOS Pipes; the computer's intercepts | From 486c39b36902eee6e25088ee69d05032068a7a99 Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Sat, 31 Oct 2026 16:19:46 -0500 Subject: [PATCH 14/14] shell: Continue's Open is a chat finished, never one cut short The control said Open once a Continue's chat was in the person's list, and pressing Open only opened it: a Continue whose history or agent's add failed, or whose answer was lost, left a chat without its agent (or its history) that no press finished (Alex on #866). `continued` now names a chat only once it is finished: the person's own, with the agent in it as their list has it (adding it is the last step, after the history), or finished by this page (the list names an agent a moment after it joins). One cut short says Continue, and pressed, here or in another tab, is finished in that chat. A chat found finished lets go of the name kept for it. The control's own cases drive its press after a failure or a lost answer at each step, in the same tab and after a reload, and with the list lagging; they fail on the code before. Co-Authored-By: Claude Opus 5.5 --- cell/shell/continue.js | 31 ++++++++++--- xtask/src/continue_js.rs | 97 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 113 insertions(+), 15 deletions(-) diff --git a/cell/shell/continue.js b/cell/shell/continue.js index 77ad00f7b..ad10011e1 100644 --- a/cell/shell/continue.js +++ b/cell/shell/continue.js @@ -132,14 +132,30 @@ export function createContinue({ return primaryAgent()?.identity ?? mine[0]?.identity ?? null; } - // the chat a Continue of `source` with `agent` made, as the person's list - // has it, or null + // the chats a Continue on this page finished, by source and agent: the + // list names a chat's agent a moment after it joins + const finished = new Map(); + // The chat a Continue of `source` with `agent` finished, or null: the + // person's own chat of its name with the agent in it, as their list has + // it (adding the agent is its last step, after its history), or one this + // page finished. A chat made by a Continue cut short is none (Alex on + // #866): Continue again finishes it (`continueInto`), never just opens it; + // so is one the person later took the agent out of, which Continue puts + // back, as they asked of that agent. A chat found finished lets go of the + // name kept for it (`PENDING`). async function continued(source, agent) { const fragment = computerAgents().get(agent)?.fragment; if (!fragment) return null; - const pending = store.get(PENDING, {})[slotOf(source, agent)]; - const names = [await nameFor(source, agent, chatBase(fragment)), pending].filter(Boolean); - return names.find((n) => allFragments().some((f) => f.name === n && f.kind === "chat" && f.role === "owner")) ?? null; + const slot = slotOf(source, agent); + const pending = store.get(PENDING, {}); + const names = [await nameFor(source, agent, chatBase(fragment)), pending[slot], finished.get(slot)].filter(Boolean); + const done = (f) => f.kind === "chat" && f.role === "owner" && (f.agents?.includes(agent) || finished.get(slot) === f.name); + const found = names.find((n) => allFragments().some((f) => f.name === n && done(f))) ?? null; + if (found && pending[slot] === found) { + delete pending[slot]; + store.set(PENDING, pending); + } + return found; } // whether the chat named `name` is this Continue's: the person's own, its @@ -210,6 +226,7 @@ export function createContinue({ await api("POST", `/api/f/${seg(name)}/channels/chat`, { id: "history", body }).catch((e) => (e.status === 409 ? null : Promise.reject(e))); // adding it wakes its computer (the platform's `joined`) await api("PUT", `/api/f/${seg(name)}/members/${seg(agent)}`, { role: "editor" }); + finished.set(slot, name); remember(null); await loadList(); openChat(name); @@ -228,8 +245,8 @@ export function createContinue({ // "Continue with [agent ▾]": one line, for a History row or the archive's // ask; "Continue" alone when the person has one agent, "Open" once the - // chosen agent's chat of this source is made. `done(name)` once its chat - // is open. + // chosen agent's chat of this source is finished (`continued`). `done(name)` + // once its chat is open. function continueControl(source, done = () => {}) { const box = el("div", "continue-control"); const go = el("button", "continue-go", "Continue"); diff --git a/xtask/src/continue_js.rs b/xtask/src/continue_js.rs index 76f711349..a780b3136 100644 --- a/xtask/src/continue_js.rs +++ b/xtask/src/continue_js.rs @@ -4,7 +4,9 @@ //! whatever its text, before anything is made, and holds a summary then //! what was said after it; one chat a (source, agent), named from both, so //! a Continue cut short at any step, or whose answer was lost, or asked -//! again from another tab, is finished in that chat, never another; the +//! again from another tab, is finished in that chat, never another, and its +//! control says Open only for a chat finished (one cut short, pressed +//! again, here or in another tab, is finished, not just opened); the //! agent offered first is the import's own by name, an archived chat's lead //! one, else the person's main one; and an archived chat's history is made //! from its records. The e2e's sessions-ui section runs the same file in @@ -17,9 +19,9 @@ use fragment_devstack as devstack; use serde_json::Value; /// The platform as Continue meets it: the person's fragments (each with its -/// agents, as their list has them), files, records and members, each step -/// able to fail once before it acts (`fail`) or after it acted with its -/// answer lost (`lose`). A post checks its body's size as the cell does, and +/// agents, as their list has them, or none yet while the list lags: +/// `lagging`), files, records and members, each step able to fail once +/// before it acts (`fail`) or after it acted with its answer lost (`lose`). A post checks its body's size as the cell does, and /// ids as the cell does: the same id and body answer the record again, /// another body is 409. const WORLD: &str = r##" @@ -35,7 +37,7 @@ const err = (status, message) => Object.assign(new Error(message), { status }); const AGENTS = [["npub1datastar", "datastar-pro--k3x9", "Datastar Pro"], ["npub1juniper", "juniper--m4n5", "Juniper"]]; function world(conversation, extra = []) { - const w = { fragments: new Map([[ARCHIVE, { role: "owner", kind: "sessions", agents: [] }]]), files: new Map(), records: new Map(), creates: 0, posts: 0, fail: {}, lose: {} }; + const w = { fragments: new Map([[ARCHIVE, { role: "owner", kind: "sessions", agents: [] }]]), files: new Map(), records: new Map(), creates: 0, posts: 0, fail: {}, lose: {}, lagging: false }; for (const [identity, fragment, title] of AGENTS) w.fragments.set(fragment, { role: "owner", kind: "agent", title, agents: [identity] }); for (const [name, f] of extra) w.fragments.set(name, f); w.files.set(`${ARCHIVE}:sessions/${KEY}.json`, JSON.stringify(conversation)); @@ -103,15 +105,31 @@ function world(conversation, extra = []) { return w; } +// An element as the control uses one: its text, classes, data, children +// and handlers; a select's value its first option's until set. +function element(tag, cls, text) { + const classes = new Set(cls ? cls.split(" ") : []); + const e = { + tag, children: [], dataset: {}, textContent: text ?? "", value: "", disabled: false, title: "", isConnected: true, + classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) }, + setAttribute() {}, + append(...kids) { + e.children.push(...kids); + if (tag === "select" && !e.value) e.value = kids[0]?.value ?? ""; + }, + }; + return e; +} + // a tab of the shell: its own module, a store of its own (another browser, // unless shared) function tab(w, store = storeOf(), main = "npub1datastar") { const t = { notices: [], opened: [] }; t.c = createContinue({ api: w.api, - ui: { el: () => ({}), notice: (title, words) => t.notices.push([title, words]) }, + ui: { el: element, notice: (title, words) => t.notices.push([title, words]) }, data: { - allFragments: () => [...w.fragments].map(([name, f]) => ({ name, ...f })), + allFragments: () => [...w.fragments].map(([name, f]) => ({ name, ...f, agents: w.lagging && f.kind === "chat" ? [] : f.agents })), agents: () => new Map(AGENTS.map(([identity, fragment]) => [identity, { identity, fragment }])), primaryAgent: () => ({ identity: main }), titleOf: (name) => w.fragments.get(name)?.title ?? name, @@ -134,6 +152,20 @@ const said = (n, text) => Array.from({ length: n }, (_, i) => ({ role: i % 2 ? " const conversation = (messages, summary, agent = "Juniper") => ({ id: "c1", title: "Beans", agent, started_at: "2026-10-01T09:00:00.000Z", ended_at: "2026-10-01T10:00:00.000Z", messages: [...(summary ? [{ role: "user", summary: true, text: summary }] : []), ...messages] }); const SOURCE = { archive: ARCHIVE, key: KEY }; const chats = (w) => [...w.fragments].filter(([, f]) => f.kind === "chat").map(([n]) => n); +// a tab's control for `source`, its agent picked, settled: what it says, +// and a press of it, waited for +const settle = () => new Promise((r) => setTimeout(r, 5)); +async function control(t, source, agent) { + const box = t.c.continueControl(source); + await settle(); + const [go, pick] = box.children; + if (pick && agent) { + pick.value = agent; + pick.onchange(); + await settle(); + } + return { says: () => go.textContent, press: async () => { await go.onclick(); await settle(); } }; +} "##; const CASES: &str = r##" @@ -231,7 +263,56 @@ for (const [name, set] of [ w.fail.member = 503; await t.c.continueInto(SOURCE, "npub1juniper"); const made = await t.c.continueInto(SOURCE, "npub1juniper"); - check("a name another's fragment holds gives way to one at random, the same on a try again, then let go", made && made !== taken && w.creates === 1 && (await t.c.continued(SOURCE, "npub1juniper")) === null && JSON.stringify(store.get("shell.continue.pending.v1", {})) === "{}", { made, taken, creates: w.creates }); + check("a name another's fragment holds gives way to one at random, the same on a try again, then let go (this page finished it, so finds it)", made && made !== taken && w.creates === 1 && (await t.c.continued(SOURCE, "npub1juniper")) === made && JSON.stringify(store.get("shell.continue.pending.v1", {})) === "{}", { made, taken, creates: w.creates }); +} + +// ---- the control: Open only for a chat finished (Alex on #866); one cut +// short says Continue, here and in another tab (a reload), and pressed +// again, there, is finished in that chat, never only opened +for (const [name, set, complete] of [ + ["the history's post fails after the chat", (w) => (w.fail.history = 503), false], + ["the agent's add fails after the chat and its history", (w) => (w.fail.member = 503), false], + ["the create's answer is lost (the chat was made)", (w) => (w.lose.create = true), false], + ["the history's answer is lost (it was posted)", (w) => (w.lose.history = true), false], + ["the agent's add's answer is lost (it was added: every step done)", (w) => (w.lose.member = true), true], +]) { + const w = world(conversation(said(4, "hello"))); + set(w); + const here = tab(w); + const c = await control(here, SOURCE, "npub1juniper"); + const before = c.says(); + await c.press(); + const cut = c.says(); + // the agent is added last, after the history: in the chat only once every step is done + const cutChat = chats(w)[0]; + const agentAtCut = !!w.fragments.get(cutChat)?.agents.includes("npub1juniper"); + const reloaded = tab(w); + const r = await control(reloaded, SOURCE, "npub1juniper"); + const there = r.says(); + await r.press(); + const chat = chats(w)[0]; + const ready = w.records.get(chat)?.length === 1 && w.records.get(chat)[0].body.kind === "history" && w.fragments.get(chat).agents.includes("npub1juniper"); + check( + `the control, when ${name}: it says ${complete ? "Open" : "Continue"} here and after a reload, and pressed there, the chat is finished and opened, none other made`, + before === "Continue with" && cut === (complete ? "Open" : "Continue with") && agentAtCut === complete && there === cut && ready && chats(w).length === 1 && w.creates === 1 && w.posts === 1 && reloaded.opened.at(-1) === chat && r.says() === "Open", + { before, cut, agentAtCut, there, after: r.says(), chats: chats(w), creates: w.creates, posts: w.posts, records: w.records.get(chat)?.length ?? 0, agents: w.fragments.get(chat)?.agents }, + ); + // pressed again where it was cut short: that chat, finished, nothing posted twice + await c.press(); + check(`the control, when ${name}: pressed again where it was cut short, the same chat, nothing made or posted again`, here.opened.at(-1) === chat && w.creates === 1 && w.posts === 1 && chats(w).length === 1, { opened: here.opened, creates: w.creates, posts: w.posts }); +} +{ + // the list names a chat's agent a moment after it joins: the page that + // finished it says Open at once; another, Continue, which opens that chat + const w = world(conversation(said(4, "hello"))); + w.lagging = true; + const here = tab(w); + const c = await control(here, SOURCE, "npub1juniper"); + await c.press(); + const other = await control(tab(w), SOURCE, "npub1juniper"); + const there = other.says(); + await other.press(); + check("the list not yet naming its agent: the page that finished it says Open; another says Continue, and pressed, opens that chat, nothing made again", c.says() === "Open" && there === "Continue with" && w.creates === 1 && w.posts === 1 && chats(w).length === 1, { here: c.says(), there, creates: w.creates }); } // ---- who it was with, and an archived chat's history