diff --git a/SPECIAL-CASE-INVENTORY.md b/SPECIAL-CASE-INVENTORY.md index 45ca7bfbb..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 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`, `/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 | diff --git a/cell/shell-tests/history-page.test.mjs b/cell/shell-tests/history-page.test.mjs new file mode 100644 index 000000000..3534dfb09 --- /dev/null +++ b/cell/shell-tests/history-page.test.mjs @@ -0,0 +1,62 @@ +// 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 { 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("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; 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", () => { + 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/continue.js b/cell/shell/continue.js new file mode 100644 index 000000000..ad10011e1 --- /dev/null +++ b/cell/shell/continue.js @@ -0,0 +1,326 @@ +// 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; + // 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"; + 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 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 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 + // 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)); + } + + // 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 { + // 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" }); + finished.set(slot, name); + remember(null); + 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.`); + 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 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"); + 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(); + // 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) => { + 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 new file mode 100644 index 000000000..e6942176c --- /dev/null +++ b/cell/shell/history-page.js @@ -0,0 +1,340 @@ +// 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, 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 +// 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; 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). +// +// The shell owns the page's place and its helpers (`h`): `api`, `el`, +// `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`. + +// 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; +// 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); +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; +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()) { + 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 }); + } + } + // 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) { + 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(); + // 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() { + 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); + } + + // 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) { + 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) { + // 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); + } + // 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 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 } : {}) }); + 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; + // 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 }); + }, + 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 @@
- -`, `/welcome?checkout=\|canceled`, `/settings?pack=\|canceled` (Billing) and `/?chat=` |
+| `GET /`, `/settings`, `/apps`, `/history`, `/welcome` | the shell's page, for anyone; signed out it offers Sign Up and Sign In (`/auth/login?screen_hint=…`, back to the same address). It reads `/welcome?trial=`, `/welcome?checkout=\|canceled`, `/settings?pack=\|canceled` (Billing) and `/?chat=` |
| `GET /about`, `/privacy`, `/terms` | static pages, for anyone |
| `GET /admin` | the operators' page (Operators: the admin) |
| `GET /share/` | the share sheet (Sharing) |
@@ -1731,11 +1731,12 @@ platform's origin (the `__live` hello's `platform`).
| `{fragment: "open-launcher"}` | the focused frame of a listed fragment → the shell | opens the launcher |
| `{fragment: "alerts?", nonce}`, `{fragment: "alerts", nonce, on, key?}` | a frame of one of the person's chats → the shell | A chat's alerts are the shell's, below |
| `{fragment: "busy", busy}` | a chat → the shell | whether it holds what no storage keeps (files chosen, a memo, a message on its way or failed, …): the shell does not reload under it |
+| `{fragment: "said"}` | a chat → the shell | the person's message was sent there: a chat they archived is unarchived (`PUT /api/fragments/{name}/archived`), back in their sidebar |
| `{fragment: "forget-draft"}` | the shell → each chat, before signing out | the chat drops its stored draft → `{fragment: "draft-forgotten"}` |
| `{fragment: "theme", mode, mobile}` | the shell → a frame | its light or dark mode, and whether it is laid out for a phone |
| `{fragment: "screen", agent, name}`, `{fragment: "simplex", agent, name}` | the shell → a port's page | the agent's name, as it loads and on a rename |
| `{fragment: "simplex", agent, want: "link" \| "unlink", home?}` | a SimpleX link page Settings framed, for its own agent → the shell | → `{fragment: "simplex", agent, for, ticket?, error?, home?, homeError?}`: a door ticket (An agent's doors); a link's `home` sets its home (An agent's home), an unlink's takes it back |
-| `{fragment: "session-archive", key \| continue \| chat}` | the person's session archive's frame → the shell | `key` the conversation it shows (`null` its list); `continue` a conversation to pick up as a new chat with their main agent; `chat` a chat of theirs to open |
+| `{fragment: "session-archive", key \| continue}` | the person's session archive's frame → the shell | `key` the conversation it shows (`null` its list); `continue` a conversation to pick up as a new chat: the shell asks with which of their agents (its picker), or opens the chat one made of it |
| `{fragment: "signin-blocked", name, why?}` | a fragment's `__signin`, or `/auth/frame`'s note → the parent | Frame sessions |
| `{fragment: "open-chat", name}` | the alerts worker → an open shell | opens the chat |
diff --git a/docs/chat-records.md b/docs/chat-records.md
index 3e083033b..c2245b338 100644
--- a/docs/chat-records.md
+++ b/docs/chat-records.md
@@ -209,25 +209,31 @@ page's id is `pr:
turn is a message of its own. A command the menu refuses for a word of
its `args` still gets both its records, its end an `error` saying why.
-**History**, a chat continued from its person's Session Archive
-(templates/sessions/README.md):
+**History**, a chat continued from a conversation its 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 and the newest messages said after it
- (the summary covers what came before), oldest first, with how many
- others the conversation holds (`earlier`), bounded to fit one record
- (cell/shell/session-archive.js, `historyOf`). `archive` and `key` name
- the whole conversation's file.
-- Only its owner's counts. The page shows it above the chat. The bridge
- hands it to the agent once, as its first turn's note there
- (`note::history`); it starts no turn.
+- 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 and the newest messages said
+ after it (the summary covers what came before), oldest first, with how
+ many others the conversation holds (`earlier`), bounded to fit one
+ record (`historyOf`, `historyOfChat`). `archive` and `key` name an
+ import's file, or `chat` the archived chat it was made of. `agent`:
+ whose the agent's words were, when not the agent continuing it.
+- One chat a (source, agent): its name is made from both, so Continue
+ asked again, in any tab, opens it, and a Continue cut short is finished
+ in the same chat.
+- Only its owner's counts. The page shows it above the chat, with whom it
+ was (`agent`). The bridge hands it to the agent once, as its first
+ turn's note there (`note::history`, another agent's words by its name);
+ it starts no turn.
Any other `kind` on `chat` is a page's own, never a message. Search finds
messages and replies alone, by their `text` (docs/api.md, Search).
diff --git a/images/bridge/src/note.rs b/images/bridge/src/note.rs
index c2c06a2f4..86f0ab1f2 100644
--- a/images/bridge/src/note.rs
+++ b/images/bridge/src/note.rs
@@ -453,10 +453,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();
@@ -471,7 +479,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)))
@@ -493,7 +501,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"));
}
@@ -571,6 +580,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 678317d19..ef4546a04 100644
--- a/templates/chat/site/chat.js
+++ b/templates/chat/site/chat.js
@@ -1217,7 +1217,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");
@@ -1227,7 +1230,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"}`);
@@ -2634,6 +2637,7 @@ export function mount(root) {
await fragment.post("chat", body, { id });
unsent = null;
$("banner").hidden = true;
+ tellSaid();
return;
}
const to = addressed(text);
@@ -2665,6 +2669,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;
@@ -2677,6 +2682,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 messages).
+ 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()) {
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..a780b3136
--- /dev/null
+++ b/xtask/src/continue_js.rs
@@ -0,0 +1,378 @@
+//! 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, 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
+//! 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, 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##"
+globalThis.document = { dispatchEvent() {}, 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 = "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: {}, 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));
+ 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;
+}
+
+// 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: element, notice: (title, words) => t.notices.push([title, words]) },
+ data: {
+ 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,
+ 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);
+// 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##"
+// ---- 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 (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
+{
+ 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/js_syntax.rs b/xtask/src/js_syntax.rs
index a28b20478..9fc812107 100644
--- a/xtask/src/js_syntax.rs
+++ b/xtask/src/js_syntax.rs
@@ -62,8 +62,8 @@ 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 (its sections, and whom the ways to ask for an app ask),
-/// onboarding.js's and threads.js's.
-const SHELL_TESTS: [&str; 5] = ["cell/shell-tests/asked-agent.test.mjs", "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"];
+/// history-page.js's, onboarding.js's and threads.js's.
+const SHELL_TESTS: [&str; 6] = ["cell/shell-tests/asked-agent.test.mjs", "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)]
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index c7d8da503..20672e52e 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -119,7 +119,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"));
-}