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 @@
- - + + @@ -95,6 +92,7 @@

+ diff --git a/cell/shell/session-archive.js b/cell/shell/session-archive.js index d6fd5a7d6..b9f8182cd 100644 --- a/cell/shell/session-archive.js +++ b/cell/shell/session-archive.js @@ -1,63 +1,23 @@ -// The person's Session Archive in the sidebar (templates/sessions/README.md): -// a heading below their chats listing the archive's chats, newest first, as -// its chats' index lists them (`index/chat.json`, read with this page's -// session). One opens in the middle column, read-only: the archive's own -// page for it (`/?s=`), framed as a chat is; the archive's page lists -// the rest, its scheduled runs and side tasks folded away. The archive is -// the one fragment of kind `sessions` the person owns; the shell makes -// none (`fragment session import` does). +// The person's Session Archive (templates/sessions/README.md): its chats, +// newest first, as its chats' index lists them (`index/chat.json`, read +// with this page's session), which the History page lists with the +// person's chats (history-page.js; Paul, 2026-10-31: in place of a +// sidebar section of their own). One opens in the middle column, +// read-only: the archive's own page for it (`/?s=`), framed as a chat +// is; the archive's page lists the rest, its scheduled runs and side tasks +// folded away. The archive is the one fragment of kind `sessions` the +// person owns; the shell makes none (`fragment session import` does). // // The index is read again as the person's list is read (each change it // tells this page of) at most once a minute, at once when the archive is // new to this page, and as the page comes back into view: an import moves // the archive's files and not their list. -// A Continue's history record (docs/chat-records.md, "History"): the -// conversation's title, its summary and the newest of what the person and -// the agent said, oldest first, bounded so its body (its JSON, as the cell -// measures a record's: `limits::RECORD_BODY_MAX_BYTES`, 64 KiB) fits, -// whatever its text is (CJK, escaped). -export const HISTORY_BODY_MAX_BYTES = 63 * 1024; -const HISTORY_LIMITS = { messages: 40, text: 4_000, summary: 8_000, title: 120 }; -const bytesOf = (v) => new TextEncoder().encode(JSON.stringify(v)).length; -const cutTo = (text, max) => ([...text].length > max ? `${[...text].slice(0, max - 1).join("")}…` : text); - -export function historyOf(c, key, archive) { - const all = c.messages ?? []; - const isSaid = (m) => (m.role === "user" || m.role === "assistant") && typeof m.text === "string" && m.text.trim() && !m.summary && !m.tool_calls?.length; - const said = all.filter(isSaid); - // the last summary covers what was said before it: the card holds it, - // then the newest of what was said after it (the rest counted `earlier`) - const last = all.findLastIndex((m) => m.summary && typeof m.text === "string" && m.text.trim()); - const summary = last >= 0 ? all[last].text : ""; - const after = all.slice(last + 1).filter(isSaid); - const base = { - kind: "history", title: cutTo(c.title || "Untitled", HISTORY_LIMITS.title), archive, key, started_at: c.started_at, ended_at: c.ended_at, - ...(summary ? { summary: cutTo(summary, HISTORY_LIMITS.summary) } : {}), messages: [], earlier: said.length, - }; - // a summary too large for the body alone is cut until it fits - while (base.summary && bytesOf(base) > HISTORY_BODY_MAX_BYTES) base.summary = cutTo(base.summary, Math.floor([...base.summary].length / 2)); - // the newest that fit, oldest first: each adds its JSON and a comma - let size = bytesOf(base); - const tail = []; - for (const m of [...after].reverse()) { - if (tail.length >= HISTORY_LIMITS.messages) break; - const one = { role: m.role, at: m.at, text: cutTo(m.text.trim(), HISTORY_LIMITS.text) }; - const more = bytesOf(one) + (tail.length ? 1 : 0); - if (size + more > HISTORY_BODY_MAX_BYTES) break; - size += more; - tail.unshift(one); - } - return { ...base, messages: tail, earlier: said.length - tail.length }; -} - +// `changed`: the index read again, or the conversation open in it moved. export function createSessionArchive({ - api, ui: { $, el, patch, renderHeading, notice }, data: { allFragments, own, seg, primaryAgent, agents, chatLabel }, - selection: { current, page, getFrame }, navigation: { openChat, leaveSidebar }, load: loadList, store, + api, ui: { renderHeading }, data: { allFragments, own, seg }, + selection: { getFrame }, navigation: { openChat, leaveSidebar }, continuing, store, changed, }) { - // the rows the sidebar shows; the archive's page lists every one - const SHOWN = 30; const READ_EVERY_MS = 60_000; - const OPEN = "shell.archive.open.v1"; const read = { name: null, at: 0, asked: 0, entries: [] }; let open = null; @@ -68,17 +28,17 @@ export function createSessionArchive({ if (!name) { read.name = null; read.entries = []; - return render(); + return changed(); } - if (!force && name === read.name && Date.now() - read.at < READ_EVERY_MS) return render(); + if (!force && name === read.name && Date.now() - read.at < READ_EVERY_MS) return; const mine = ++read.asked; read.at = Date.now(); // failed, what was read before stays: a failure is not "none" const index = await api("GET", `/api/f/${seg(name)}/file?path=${encodeURIComponent("index/chat.json")}`).catch((e) => (e.status === 404 ? { sessions: [] } : null)); - if (mine !== read.asked || !index) return render(); + if (mine !== read.asked || !index) return; read.name = name; read.entries = Array.isArray(index.sessions) ? index.sessions : []; - render(); + changed(); } document.addEventListener("visibilitychange", () => { if (!document.hidden) load().catch(() => {}); @@ -86,138 +46,18 @@ export function createSessionArchive({ // The archive's page says which conversation it shows (its `?s=`, or none // for its list) as it loads, the person moving in it too, answering the - // theme this page sends each frame: the heading and the row follow. - // It asks to continue one (`continue`), or to open the chat it was - // continued in (`chat`): the shell does either, as the person. + // theme this page sends each frame: the heading follows. + // It asks to continue one (`continue`): the shell asks with which agent + // (continue.js), as the person. addEventListener("message", (e) => { const d = e.data; if (d?.fragment !== "session-archive" || !read.name || e.source !== getFrame(read.name)?.contentWindow) return; - if (typeof d.continue === "string") return void continueSession(d.continue); - if (typeof d.chat === "string") { - if (allFragments().some((f) => f.name === d.chat && f.kind === "chat")) { openChat(d.chat); leaveSidebar(); } - return; - } + if (typeof d.continue === "string") return void continuing.ask({ archive: read.name, key: d.continue }, read.entries.find((x) => x.key === d.continue)?.title); open = typeof d.key === "string" ? d.key : null; renderHeading(); - render(); }); - // ---- Continue: a conversation of the archive, picked up as a chat ---- - // A new chat with the person's main agent, titled as the conversation was, - // whose first record is its `history` (docs/chat-records.md, "History"): - // a summary and the newest of what was said, bounded, which the chat's - // page shows above the chat and the agent is handed at its first turn - // there (`historyOf`, bounded before anything is made). The agent joins - // after it, so it is the chat's first record. The archive keeps the - // chat's name (`continued/.json`), so the conversation offers that - // chat again rather than another, and a Continue asked again (a stale - // tab) opens it. Each step can be taken again: the chat's name is made - // here and kept before the chat is (`PENDING`), so a try cut short, or - // whose answer was lost, is finished by the next in the same chat. - // a chat's title, as the sidebar's room has it - const TITLE_MAX = 48; - // a chat's name, made here so a try whose answer was lost is found again - // by it (decision 47's suffix: `fragment_proto::NAME_SUFFIX_ALPHABET`) - const SUFFIX = "abcdefghijkmnpqrstuvwxyz23456789"; - const suffix = () => [...crypto.getRandomValues(new Uint8Array(4))].map((b) => SUFFIX[b & 31]).join(""); - // the chat a Continue not yet finished is making, by archive and key - const PENDING = "shell.archive.continuing.v1"; - const status = (name) => api("GET", `/api/f/${seg(name)}/status`).catch((e) => (e.status === 404 ? null : Promise.reject(e))); - let continuing = false; - async function continueSession(key) { - const agent = primaryAgent(); - const fragment = agents().get(agent?.identity)?.fragment; - if (continuing || !read.name) return; - if (!fragment) return notice("No agent yet", "Continue needs your agent."); - continuing = true; - const archive = read.name; - const slot = `${archive} ${key}`; - const remember = (name) => { - const all = store.get(PENDING, {}); - if (name) all[slot] = name; - else delete all[slot]; - store.set(PENDING, all); - }; - const go = async (name) => { - remember(null); - await loadList(); - openChat(name); - leaveSidebar(); - }; - let made = false; - try { - // continued already (in another tab, or by a try whose last answer was - // lost): its chat, while it is there - const marked = await api("GET", `/api/f/${seg(archive)}/file?path=${encodeURIComponent(`continued/${key}.json`)}`).catch((e) => (e.status === 404 ? null : Promise.reject(e))); - if (typeof marked?.chat === "string" && (await status(marked.chat))) return void (await go(marked.chat)); - const c = await api("GET", `/api/f/${seg(archive)}/file?path=${encodeURIComponent(`sessions/${key}.json`)}`); - // bounded before anything is made - const body = historyOf(c, key, archive); - // the chat a try before this one began, or a new one, kept before it is made - const name = store.get(PENDING, {})[slot] ?? `${chatLabel(fragment)}--${suffix()}`; - remember(name); - try { - await api("POST", "/api/fragments", { name, template: "chat", title: cutTo(c.title || "Untitled", TITLE_MAX) }); - } catch (e) { - // its answer lost, it may be made: the next try finds it by its name - if (!(e.status >= 400)) made = true; - // made by a try whose answer was lost: the person's own - const mine = e.status === 409 ? (await status(name))?.role === "owner" : false; - if (!mine) { - if (e.status === 409) remember(null); - throw e; - } - } - made = true; - // posted by a try before (the same id; another body when the conversation was imported again since) - await api("POST", `/api/f/${seg(name)}/channels/chat`, { id: "history", body }).catch((e) => (e.status === 409 ? null : Promise.reject(e))); - // adding it wakes its computer (the platform's `joined`) - await api("PUT", `/api/f/${seg(name)}/members/${seg(agent.identity)}`, { role: "editor" }); - await api("POST", `/api/f/${seg(archive)}/files`, { message: `continued in ${name}`, files: [{ path: `continued/${key}.json`, text: `${JSON.stringify({ chat: name })}\n` }] }); - await go(name); - } catch (e) { - if (made) notice("The chat is not ready yet", `${e.message} Continue again to finish it.`); - else notice("The chat was not made", e.message); - } finally { - continuing = false; - } - } - - const shownOpen = () => store.get(OPEN, true) !== false; - - function render() { - const box = $("archive"); - box.hidden = !read.name || !read.entries.length; - if (box.hidden) return; - const toggle = $("archive-toggle"); - toggle.setAttribute("aria-expanded", String(shownOpen())); - toggle.onclick = () => { - store.set(OPEN, !shownOpen()); - render(); - }; - $("archive-rows").hidden = !shownOpen(); - const here = current() === read.name && !page(); - const rows = read.entries.slice(0, SHOWN).map((e) => { - const active = here && open === e.key; - const row = el("button", `row archive-row${active ? " active" : ""}`); - row.type = "button"; - row.dataset.key = `archived:${e.key}`; - row.setAttribute("aria-current", active ? "page" : "false"); - row.append(el("span", "label", e.title || "Untitled")); - row.onclick = () => { openSession(e.key); leaveSidebar(); }; - return row; - }); - if (read.entries.length > SHOWN) { - const all = el("button", "row archive-all"); - all.type = "button"; - all.dataset.key = "archived:all"; - all.append(el("span", "label", `All ${read.entries.length}`)); - all.onclick = () => { openSession(null); leaveSidebar(); }; - rows.push(all); - } - patch($("archive-rows"), rows); - } // one conversation (its key), or with none the archive's list function openSession(key) { @@ -229,5 +69,5 @@ export function createSessionArchive({ // what the middle column's heading says while the archive is open there const title = () => read.entries.find((e) => e.key === open)?.title || "Session Archive"; - return { load, render, title, continueSession }; + return { load, title, archive: () => read.name, entries: () => read.entries, openSession }; } diff --git a/cell/shell/shell.css b/cell/shell/shell.css index e563be7fa..804a30228 100644 --- a/cell/shell/shell.css +++ b/cell/shell/shell.css @@ -148,14 +148,6 @@ body.resizing-row, body.resizing-row * { cursor: row-resize !important; } .sheet iframe { display: block; width: 100%; height: 26rem; max-height: none; border: 0; } .empty-row { font-size: 13px; color: var(--text-tertiary); padding: 6px 8px; } /* the Session Archive (session-archive.js): a quiet heading that folds its rows */ -.archive { display: flex; flex-direction: column; gap: 1px; margin: 8px 0; } -.archive-toggle { display: flex; align-items: center; gap: 4px; width: fit-content; padding: 4px 8px; border: 0; background: none; font: 600 12px/1.4 var(--font-sans); color: var(--text-tertiary); cursor: pointer; } -.archive-toggle:hover { color: var(--text-secondary); } -.archive-toggle svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: transform .14s; } -.archive-toggle[aria-expanded="true"] svg { transform: rotate(90deg); } -.archive .rows { max-height: 24vh; } -.archive-row .label { color: var(--text-secondary); } -.archive-row.active .label, .archive-all .label { color: var(--text-primary); } .spacer { flex: 1; } /* ---- chat column ---- */ @@ -706,8 +698,46 @@ iframe[data-blocked] { display: none; } .app-dock:disabled { opacity: .6; cursor: default; } .apps-archived { margin-top: 24px; } .apps-archived summary { padding: 6px 2px; color: var(--text-secondary); font-size: 14px; cursor: pointer; } +/* the History page (history-page.js): in the work area as the Apps page + is; a search box, All · Archived · Imported, then a row a chat: its face, + its title, whom it was with and its first words (or what the search + found), when it was last said, and Archived on the archived ones */ +.history-layout { display: flex; flex-direction: column; max-width: 820px; margin: 0 auto; padding: 34px clamp(20px, 4vw, 56px) 88px; } +.history-intro h1 { margin: 0 0 18px; font-size: clamp(26px, 2.5vw, 32px); font-weight: 600; line-height: 1.2; letter-spacing: -.035em; } +.history-search { display: flex; align-items: center; gap: 10px; height: 44px; padding: 0 14px; border: 1px solid var(--border-strong); border-radius: 12px; color: var(--text-tertiary); cursor: text; } +.history-search:focus-within { border-color: var(--text-tertiary); } +.history-search svg { width: 17px; height: 17px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.history-search input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; background: none; color: var(--text-primary); font: inherit; font-size: 15px; box-shadow: none; } +.history-search input:focus-visible { outline: none; } +.history-search input::placeholder { color: var(--text-tertiary); opacity: 1; } +.history-filters { display: flex; gap: 6px; margin: 12px 0 8px; } +.history-filter { height: 28px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--text-secondary); font: inherit; font-size: 13px; cursor: pointer; } +.history-filter:hover { background: var(--bg-hover); } +.history-filter[aria-pressed="true"] { border-color: transparent; background: var(--bg-active); color: var(--text-primary); } +.history-status { margin: 4px 2px 8px; color: var(--text-tertiary); font-size: 13px; } +.history-list { display: flex; flex-direction: column; } +.history-empty { margin: 16px 2px; font-size: 14px; } +.history-row { display: flex; align-items: center; gap: 8px; min-width: 0; border-radius: 12px; } +.history-row:hover { background: var(--bg-hover); } +.history-open { display: flex; flex: 1; align-items: center; gap: 12px; min-width: 0; padding: 9px 10px; border: 0; border-radius: inherit; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.history-face { display: grid; flex: none; place-items: center; width: 30px; height: 30px; color: var(--text-tertiary); } +.history-face .agent-avatar.small { width: 28px; height: 28px; } +.history-face > svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.history-main { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } +.history-line, .history-sub { display: flex; align-items: center; gap: 8px; min-width: 0; } +.history-line .label { min-width: 0; overflow: hidden; color: var(--text-primary); font-size: 14.5px; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } +.history-chip { flex: none; padding: 0 7px; border-radius: 999px; background: var(--bg-active); color: var(--text-secondary); font-size: 11px; line-height: 18px; } +.history-line time { flex: none; margin-left: auto; color: var(--text-tertiary); font-size: 12px; line-height: 20px; } +.history-sub { color: var(--text-tertiary); font-size: 13px; line-height: 18px; } +.history-with { flex: none; color: var(--text-secondary); } +.history-with + .history-preview::before { content: "·"; margin-right: 8px; color: var(--text-tertiary); } +.history-preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.history-preview.found { color: var(--text-secondary); } +.history-more { align-self: center; margin-top: 14px; height: 32px; padding: 0 16px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--text-secondary); font: inherit; font-size: 13px; cursor: pointer; } +.history-more:hover { background: var(--bg-hover); } @container settings (max-width: 620px) { .apps-layout { padding: 18px 16px 60px; } + .history-layout { padding: 18px 16px 60px; } .app-row-card { width: 72px; height: 45px; } .app-row-controls, .app-row-said, .app-row-progress { padding-left: 0; } .app-row-panel { margin-left: 0; padding: 16px; gap: 20px; } @@ -927,3 +957,17 @@ iframe[data-blocked] { display: none; } .icon-pick[aria-busy=true] { outline: 2px dashed var(--border-strong); outline-offset: 2px; } .icon-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; } .icon-actions button:first-child { margin-right: auto; } + +/* ---- Continue (continue.js): "Continue with [agent ▾]", one line, compact + enough for the end of a 40px History row; and the dialog the archive's + page asks for it in. */ +.continue-control { display: inline-flex; align-items: center; gap: 0; height: 28px; max-width: 100%; flex: none; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--bg-card); overflow: hidden; font: 13px/1 var(--font-sans); } +.continue-control button.continue-go { height: 100%; padding: 0 12px; border: 0; background: none; color: var(--text-primary); white-space: nowrap; cursor: pointer; } +.continue-control button.continue-go:hover:not(:disabled) { background: var(--bg-hover); } +.continue-control select.continue-agent { height: 100%; min-width: 0; max-width: 160px; padding: 0 26px 0 10px; border: 0; border-left: 1px solid var(--border); background: none; color: var(--text-secondary); font: inherit; text-overflow: ellipsis; cursor: pointer; } +.continue-control.busy { opacity: .6; } +.continue-control button.continue-go:disabled { cursor: default; } +.continue-ask { padding: 20px 22px; width: min(380px, calc(100vw - 32px)); border: 1px solid var(--border-strong); border-radius: 16px; background: var(--bg-card); color: var(--text-primary); } +.continue-ask::backdrop { background: rgba(0,0,0,.5); } +.continue-ask .continue-title { margin: 0 0 14px; font-size: 15px; overflow-wrap: anywhere; } +.continue-ask .continue-close { display: block; margin: 16px 0 0 auto; padding: 6px 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-muted); color: var(--text-secondary); font-size: 13px; } diff --git a/cell/shell/shell.js b/cell/shell/shell.js index 8e1fe73de..2aaa518e7 100644 --- a/cell/shell/shell.js +++ b/cell/shell/shell.js @@ -15,12 +15,14 @@ import { colorOf } from "./agent-color.js"; import { icon } from "./icons.js"; import { appArt, openIconSheet } from "./app-icon.js"; import { createAppsPage } from "./apps-page.js"; +import { createHistoryPage } from "./history-page.js"; import { createTitles } from "./fragment-title.js"; import { createLayout, store, reducedMotion, springFrames } from "./layout.js"; import { createViewer } from "./viewer.js"; import { seatGives } from "./billing.js"; import { createChatList, newestChat } from "./chat-list.js"; import { createSessionArchive } from "./session-archive.js"; +import { createContinue } from "./continue.js"; import { createSessionRefresh } from "./session-refresh.js"; import { createOnboarding } from "./onboarding.js"; import { createComputer } from "./computer.js"; @@ -304,8 +306,11 @@ function frameOf(src, title, name = null) { // navigation it makes once it does (frameOf) const unready = new WeakMap(); // A frame sent to `src`: at once, or, its origin not known yet, once it is. -function go(frame, src) { +// `replace`: in place of the page it shows, adding nothing to the tab's +// history (a cross-origin frame's `location.replace` is one it may call). +function go(frame, src, replace = false) { if (unready.has(frame)) unready.set(frame, src); + else if (replace && frame.contentWindow) frame.contentWindow.location.replace(new URL(src, location.href).href); else frame.src = src; } @@ -868,12 +873,19 @@ const { renderHeading, renderChats, loadInvites, primaryAgent, newChatAgent, ask navigation: { newChat, rename: (name) => rename(name), openChat, openApp, openScreen, leaveSidebar, seg }, listLive, load, invites: () => state.invites, setInvites: (v) => { state.invites = v; }, }); -const sessionArchive = createSessionArchive({ - api, ui: { $, el, patch, renderHeading: () => renderHeading(), notice }, - data: { allFragments: () => state.fragments, own, seg, primaryAgent: () => primaryAgent(), agents: () => state.agents, chatLabel: (agent) => chatLabel(agent) }, - selection: { current: () => state.current, page: () => state.page, getFrame: (name) => state.frames.get(name) }, +// Continue: a conversation (an import, an archived chat) as a new chat with +// an agent of the person's choosing (continue.js) +const continuing = createContinue({ + api, ui: { el, notice }, + data: { allFragments: () => state.fragments, agents: () => state.agents, primaryAgent: () => primaryAgent(), titleOf, seg, chatBase: (agent) => chatBase(agent) }, navigation: { openChat, leaveSidebar }, load: () => load(), store, }); +const sessionArchive = createSessionArchive({ + api, ui: { renderHeading: () => renderHeading() }, + data: { allFragments: () => state.fragments, own, seg }, + selection: { getFrame: (name) => state.frames.get(name) }, + navigation: { openChat, leaveSidebar }, continuing, store, changed: () => historyPage.render(), +}); // Delegation threads: a line each over the chat an agent asked from, its // pair chat its transcript (threads.js) @@ -924,20 +936,22 @@ function openChat(name, push = true, path = null) { $("layout").classList.remove("settings-view"); $("settings-page").hidden = true; leaveApps(); + leaveHistory(); $("frames").hidden = false; state.current = name; store.set(CURRENT, name); hideNotice(); const made = !state.frames.has(name); chatFrame(name, path ?? undefined); - if (path && !made) go(state.frames.get(name), framed(name, path)); + // a frame shown before goes to the page asked in place of the one it + // showed, so Back goes to the view before (History), not to that page + if (path && !made) go(state.frames.get(name), framed(name, path), true); for (const [n, frame] of state.frames) frame.hidden = n !== name; renderHeading(); renderChats(); // the ways to ask for an app ask the open chat's agent renderDesktopEmpty(); for (const lede of document.querySelectorAll(".app-prompts-lede")) namePromptsLede(lede); - sessionArchive.render(); markSeen(); prewake(); navigated(); @@ -1259,7 +1273,10 @@ const UNTITLED = "New chat"; const TITLE_MAX_CHARS = 48; // A chat's label: its agent's, `-chat` and a number (`maple-chat-2`), short // enough for any deployment's hosts with four digits' room. -const chatLabel = (agent) => freeLabel(`${labelOf(agent).slice(0, LABEL_ROOM - "-chat-9999".length).replace(/-+$/, "")}-chat`); +// an agent's chats' label, before freeLabel numbers it (Continue's names +// add a hash to it: continue.js) +const chatBase = (agent) => `${labelOf(agent).slice(0, LABEL_ROOM - "-chat-9999".length).replace(/-+$/, "")}-chat`; +const chatLabel = (agent) => freeLabel(chatBase(agent)); // An untitled chat with only this agent that no one has said anything in // yet is the one to open again, not another. const blank = (identity) => chats().find((f) => own(f) && !f.archived && f.title === UNTITLED && !f.preview && agentsOf(f.name).length === 1 && agentsOf(f.name)[0] === identity); @@ -1464,10 +1481,12 @@ function computerIs(v) { } // ---- where the page is: `/` its chats, `/settings` its settings, `/apps` -// its apps, `/welcome` a new person's welcome; each view says so in the -// address, so a reload or a link stays put ---- +// its apps, `/history` every chat they have had, `/welcome` a new person's +// welcome; each view says so in the address, so a reload or a link stays +// put ---- const SETTINGS = "/settings"; const APPS = "/apps"; +const HISTORY = "/history"; const WELCOME = "/welcome"; function at(path) { if (location.pathname !== path) history.pushState(null, "", path); @@ -1476,6 +1495,7 @@ addEventListener("popstate", () => { const shown = !$("layout").hidden; if (shown && location.pathname === SETTINGS) openSettings(false).catch((e) => notice("Settings did not open", e.message)); else if (shown && location.pathname === APPS) openApps(false); + else if (shown && location.pathname === HISTORY) openHistory(false); else if (shown && location.pathname === "/" && byName(state.current)) openChat(state.current, false); else startOrWait(); }); @@ -1505,6 +1525,7 @@ function openApps(push = true) { $("layout").classList.add("settings-view"); $("frames").hidden = true; $("settings-page").hidden = true; + leaveHistory(); $("apps-page").hidden = false; renderHeading(); renderChats(); @@ -1518,6 +1539,38 @@ function leaveApps() { } $("apps-link").onclick = () => openApps(); +// ---- the History page (history-page.js): every chat the person has had, +// archived and imported ones too, and its search, in the work area as the +// Apps page is ---- +const historyPage = createHistoryPage($("history-page"), { + api, el, icon, seg, mark: (name, size) => mark(name, size), chats, isThread: (f) => threads.isPair(f), own, chatTitle: (name) => chatTitle(name), + agentsOf, agents: () => state.agents, titleOf, + archive: { name: () => sessionArchive.archive(), entries: () => sessionArchive.entries(), openSession: (key) => sessionArchive.openSession(key) }, + continueControl: (source) => continuing.continueControl(source), + openChat, leaveSidebar, +}); +function openHistory(push = true) { + if (push) at(HISTORY); + if (state.page !== "History") views++; + hideNotice(); + state.page = "History"; + $("layout").classList.add("settings-view"); + $("frames").hidden = true; + $("settings-page").hidden = true; + leaveApps(); + $("history-page").hidden = false; + renderHeading(); + renderChats(); + leaveSidebar(); + historyPage.open(); +} +// a chat, settings or apps in its place +function leaveHistory() { + $("history-page").hidden = true; + historyPage.close(); +} +$("history-link").onclick = () => openHistory(); + const settings = createSettings({ api, $, seg, account: { person: () => state.me, setPerson: (p) => { state.me = p; }, isGuest: () => state.guest, setGuest, @@ -1530,6 +1583,7 @@ const settings = createSettings({ $("layout").classList.add("settings-view"); $("frames").hidden = true; leaveApps(); + leaveHistory(); }, entered: () => { renderHeading(); renderChats(); leaveSidebar(); } }, notices: { hideNotice, noticeCurrent: (gen) => gen === noticeGen, notice }, @@ -1620,6 +1674,7 @@ async function load(changed = false) { sessionArchive.load().catch(() => {}); renderApps(); appsPage.listed().catch(() => {}); + historyPage.render(); renderHeading(); renderComputer(); rosterChanged(); @@ -1698,6 +1753,15 @@ const refresh = createSessionRefresh({ }); const { heard, navigated, reopenWindows, release, DEPLOYED } = refresh; const frameOfWindow = (win) => [...state.frames.values()].find((f) => f.contentWindow === win); +// A chat's page says the person sent a message there (`said`): a chat they +// archived comes back to their sidebar, as an email thread does once it is +// written in again (the History page; Paul, 2026-10-31). Their own flag, +// of the frame's own chat. +addEventListener("message", (event) => { + if (event.data?.fragment !== "said") return; + const name = frameOfWindow(event.source)?.dataset.fragment; + if (name && byName(name)?.kind === "chat" && byName(name).archived) setArchived(name, false); +}); addEventListener("message", (event) => { const d = event.data; if (d?.fragment !== "agents?" && d?.fragment !== "busy") return; @@ -1736,12 +1800,13 @@ async function start(open) { // of theirs (not the deployment's plan) may reach their ledger a moment // after it is given: its first run waits for it (`seatFirst`) // settings or the Apps page, as the address asks - const settings = !open && (location.pathname === SETTINGS || location.pathname === APPS); + const settings = !open && [SETTINGS, APPS, HISTORY].includes(location.pathname); if (!open && !settings && await owesFirstAgent()) return creatingAgent(mine?.seat ? {} : null); if (!state.fragments.length && !state.invites.length && !open && !settings && state.guest) return welcome(); showLayout(); const pick = open ?? (byName(state.current) ? state.current : (shown(chats())[0] ?? chats()[0])?.name); if (settings && location.pathname === APPS) openApps(false); + else if (settings && location.pathname === HISTORY) openHistory(false); else if (settings) await openSettings(false); else if (pick) openChat(pick); else if (state.guest) guestHome(mine.offer); diff --git a/cell/src/lib.rs b/cell/src/lib.rs index 88c1a6fb8..c0ef1a395 100644 --- a/cell/src/lib.rs +++ b/cell/src/lib.rs @@ -1203,8 +1203,9 @@ async fn platform_answer(mut req: Request, env: &Env, ctx: &Context, cfg: &'stat None => Err(CellError::new(ErrorCode::NotFound, format!("no shell file {}", file.join("/")))), }, // the shell, for everyone: signed out it asks them to sign in; - // `/settings` opens its settings, `/apps` the person's apps, `/welcome` a new person's welcome - (Method::Get, [""] | ["settings"] | ["apps"] | ["welcome"]) => shell::page(&req, env, cfg, &url).await, + // `/settings` opens its settings, `/apps` the person's apps, `/history` + // every chat they have had, `/welcome` a new person's welcome + (Method::Get, [""] | ["settings"] | ["apps"] | ["history"] | ["welcome"]) => shell::page(&req, env, cfg, &url).await, // the operators' admin page: to anyone else, the 404 below (Method::Get, ["admin"]) => shell::admin_page(&req, env, cfg, &url).await, // Finite's about page, privacy policy and terms, for anyone (signed out too) diff --git a/cell/src/shell.rs b/cell/src/shell.rs index f12ff47b6..2e18daef9 100644 --- a/cell/src/shell.rs +++ b/cell/src/shell.rs @@ -1,8 +1,9 @@ //! The shell (docs/cloudflare-v1.md, decisions 6–12): the platform's one -//! page, at `/`, `/settings`, `/apps` and `/welcome`, Finite's about page, privacy -//! policy and terms at `/about`, `/privacy` and `/terms`, and its files at -//! `/__shell/`, the release's (cell/shell/), read from its Static -//! Assets (assets.rs). It holds no key: its script calls the API with +//! page, at `/`, `/settings`, `/apps`, `/history` and `/welcome`, Finite's +//! about page, privacy policy and terms at `/about`, `/privacy` and +//! `/terms`, and its files at `/__shell/`, the release's +//! (cell/shell/), read from its Static Assets (assets.rs). It holds no +//! key: its script calls the API with //! the person's platform session (lib.rs `shell_session`) and frames their //! fragments, each signed in on its own origin by the platform's frame mint. //! @@ -89,12 +90,13 @@ fn frame_src(cfg: &Config, url: &Url) -> String { format!("'self' {platform} {scheme}://*.{}{port}", cfg.host_suffix) } -/// `GET /`, `GET /settings`, `GET /apps` and `GET /welcome`: the shell's page, for -/// anyone (signed out, it asks them to sign in; the shell opens the view -/// its path names), naming the release that answers it and when it was -/// deployed (`fragment_templates::shell_page`): its script reloads at a -/// safe moment once a later answer names another, and Settings ends with -/// both (docs/api.md, The shell). A few KiB, read whole to fill it. +/// `GET /`, `GET /settings`, `GET /apps`, `GET /history` and `GET /welcome`: +/// the shell's page, for anyone (signed out, it asks them to sign in; the +/// shell opens the view its path names), naming the release that answers +/// it and when it was deployed (`fragment_templates::shell_page`): its +/// script reloads at a safe moment once a later answer names another, and +/// Settings ends with both (docs/api.md, The shell). A few KiB, read whole +/// to fill it. pub async fn page(req: &Request, env: &Env, cfg: &Config, url: &Url) -> CellResult { // its bytes are checked against their hash as they are read: UTF-8, as the release's index.html is let bytes = crate::assets::read(env, page_file("index.html")).await?; diff --git a/crates/e2e/src/lanes/sessions.rs b/crates/e2e/src/lanes/sessions.rs index 9029c41e1..3d76e692a 100644 --- a/crates/e2e/src/lanes/sessions.rs +++ b/crates/e2e/src/lanes/sessions.rs @@ -110,11 +110,19 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { b.set_cookie(&format!("{}/", api.base), "fragment_session", &session)?; let page = b.open(&format!("{}/settings", api.base))?; b.viewport(&page, 1400, 900, false)?; - let listed = b.until(&page, "document.querySelectorAll('#archive:not([hidden]) #archive-rows .row').length === 2", wait); - let rows = b.eval(&page, "[...document.querySelectorAll('#archive-rows .row')].map((r) => r.textContent)")?; - s.ok("the shell lists the archive's chats under Session Archive, newest first", listed && rows == json!(["A long letter", "Beans"]), &rows); + // History lists them, imported, newest first + let imported = "document.querySelectorAll('.history-row[data-kind=\"imported\"]')"; + // the shell started (its layout shown, the link with it) + b.until(&page, "!document.getElementById('layout').hidden && !!document.getElementById('history-link')?.offsetParent", wait); + b.click(&page, "#history-link")?; + b.until(&page, "!!document.querySelector('.history-filter[data-filter=\"imported\"]')?.offsetParent", wait); + b.click(&page, ".history-filter[data-filter=\"imported\"]")?; + let listed = b.until(&page, &format!("{imported}.length === 2"), wait); + let rows = b.eval(&page, &format!("[...{imported}].map((r) => r.querySelector('.history-line .label').textContent)"))?; + s.ok("History lists the archive's chats as imported, newest first", listed && rows == json!(["A long letter", "Beans"]), &rows); + let beans = ".history-row[data-key=\"imported:hermes-e2e_beans\"]"; - b.click(&page, "#archive-rows [data-key=\"archived:hermes-e2e_beans\"]")?; + b.click(&page, &format!("{beans} .history-open"))?; let opened = b.until(&page, "document.getElementById('chat-title').textContent === 'Beans' && document.getElementById('chat-options').hidden", wait); let frame = "?s=hermes-e2e_beans"; let drawn = s.eventually(wait, || b.eval_in_frame(&page, frame, "document.querySelectorAll('.msg').length === 2").is_ok_and(|v| v == true)); @@ -137,7 +145,7 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { let folded = s.eventually(wait, || { b.eval_in_frame(&page, &name, "[...document.querySelectorAll('details.kind')].map((d) => [d.open, d.querySelector('summary').textContent]).join('|') === 'false,Scheduled runs (1)'").is_ok_and(|v| v == true) }); - let headed = b.until(&page, "document.getElementById('chat-title').textContent === 'Session Archive' && !document.querySelector('#archive-rows .row.active')", wait); + let headed = b.until(&page, "document.getElementById('chat-title').textContent === 'Session Archive'", wait); s.ok( "the archive's own list shows its chats and folds its scheduled runs away, and the shell's heading follows it there", folded && headed, @@ -145,10 +153,16 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { ); b.screenshot(&page, &shots.join("list.png"))?; - // Continue: a chat with their agent, its history first - b.click(&page, "#archive-rows [data-key=\"archived:hermes-e2e_beans\"]")?; + // Continue: the shell asks with which agent (one here: "Continue"), and + // makes a chat with it, its history first + b.click(&page, "#history-link")?; + b.until(&page, &format!("!!document.querySelector('{beans} .history-open')"), wait); + b.click(&page, &format!("{beans} .history-open"))?; let offered = s.eventually(wait, || b.eval_in_frame(&page, frame, "document.querySelector('.archive-continue:not([hidden])')?.textContent === 'Continue'").is_ok_and(|v| v == true)); b.eval_in_frame(&page, frame, "(document.querySelector('.archive-continue').click(), true)")?; + let asked = b.until(&page, "document.querySelector('dialog.continue-ask[open] .continue-go')?.textContent === 'Continue'", wait); + b.screenshot(&page, &shots.join("continue-ask.png"))?; + b.eval(&page, "(document.querySelector('dialog.continue-ask .continue-go').click(), true)")?; let me = api.identity(&keys)?; let mut chat = String::new(); let made = s.eventually(wait, || { @@ -165,7 +179,7 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { let tail: Vec<&str> = first["body"]["messages"].as_array().into_iter().flatten().filter_map(|m| m["text"].as_str()).collect(); s.ok( "Continue makes a chat with the person's agent, titled as the conversation, whose first record is its history, theirs", - offered && made && first["body"]["kind"] == "history" && first["principal"] == me.as_str() && tail == ["plant beans?", "in spring"] && first["body"]["key"] == "hermes-e2e_beans", + offered && asked && made && first["body"]["kind"] == "history" && first["principal"] == me.as_str() && tail == ["plant beans?", "in spring"] && first["body"]["key"] == "hermes-e2e_beans", &first, ); let shown = s.eventually(wait, || b.eval_in_frame(&page, &chat, "document.querySelector('.history-block .history-head')?.textContent?.startsWith('From “Beans”') ?? false").is_ok_and(|v| v == true)); @@ -181,16 +195,24 @@ pub fn sessions_ui(s: &mut Suite, api: &Api) -> Result<()> { !reply.is_empty() }); s.ok( - "the agent is told what was said there at its first turn in the chat, as a note", - said.status == 200 && answered && reply.contains("(told: This chat goes on from a conversation your owner had with you before they moved here, “Beans”") && reply.contains("Your owner: plant beans?\nYou: in spring"), + "the agent is told what was said there at its first turn in the chat, as a note, whose words they were", + // this agent is not named as the conversation's was (Juniper): the + // note says whose words they were, never "you" + said.status == 200 && answered && reply.contains("(told: This chat goes on from a conversation your owner had with Juniper before they moved here, “Beans”") && reply.contains("Your owner: plant beans?\nJuniper: in spring"), &reply, ); - // the archive offers that chat now - let linked = api.signed(&keys, "GET", &format!("/api/f/{name}/file?path=continued/hermes-e2e_beans.json"), None)?; - b.click(&page, "#archive-rows [data-key=\"archived:hermes-e2e_beans\"]")?; - let again = s.eventually(wait, || b.eval_in_frame(&page, frame, "document.querySelector('.archive-continue:not([hidden])')?.textContent === 'Open the chat'").is_ok_and(|v| v == true)); - s.ok("the archive keeps the chat it was continued in, and offers it", linked.body["chat"] == chat.as_str() && again, &linked.body); + // History offers that chat now (one a conversation and agent), its row's + // Continue an Open, and opens it + b.click(&page, "#history-link")?; + let control = format!("{beans} .continue-control .continue-go"); + let open = b.until(&page, &format!("document.querySelector({control:?})?.textContent === 'Open'"), wait); + b.screenshot(&page, &shots.join("history-open.png"))?; + b.click(&page, &control)?; + let reopened = b.until(&page, &format!("[...document.querySelectorAll('#frames iframe')].find((f) => !f.hidden)?.dataset.fragment === {chat:?}"), wait); + let listed = api.signed(&keys, "GET", "/api/fragments", None)?; + let beans = listed.body["fragments"].as_array().into_iter().flatten().filter(|f| f["kind"] == "chat" && f["title"] == "Beans").count(); + s.ok("Continue asked again offers the chat it made with that agent, and opens it, making none other", open && reopened && beans == 1, beans); Ok(()) } diff --git a/crates/e2e/src/lanes/shell.rs b/crates/e2e/src/lanes/shell.rs index e06d94fb6..e0f896754 100644 --- a/crates/e2e/src/lanes/shell.rs +++ b/crates/e2e/src/lanes/shell.rs @@ -1281,6 +1281,8 @@ pub fn shell_ui(s: &mut Suite, api: &Api) -> Result<()> { // both agents in one chat, search, and archiving, as the person uses them groups_ui(s, api, &mut b, &page, &me, &shots)?; + // every chat they have had, in History + history_ui(s, api, &mut b, &page, &me, &shots)?; // the person's agents in any chat's @, and one asking another roster_ui(s, api, &mut b, &page, &me, &shots)?; ask_cli(s, api, &me)?; @@ -5411,6 +5413,87 @@ fn say_in(s: &Suite, b: &mut Browser, page: &Page, host: &str, text: &str) -> bo s.eventually(std::time::Duration::from_secs(30), || b.eval_in_frame(page, host, &typed).ok() == Some(Value::Bool(true))) } +/// History (cell/shell/history-page.js; Paul, 2026-10-31): every chat the +/// person has had, the newest first, with its search. Valid: a continued +/// chat (its history card alone) is listed with the card's time and newest +/// words; archived, it is under Archived with its chip, and gone from the +/// sidebar; its title filters it at once, and a word only its messages +/// hold finds it through the person's search, its snippet shown; it opens +/// in the middle column and one Back is History again. Invalid: a chat +/// nothing was said in is not listed, and words nothing holds say so. +/// Replay: a message the person sends in the archived chat brings it back +/// to their sidebar. Restart: a reload at `/history` stays there. +fn history_ui(s: &mut Suite, api: &Api, b: &mut Browser, page: &Page, me: &Person, shots: &std::path::Path) -> Result<()> { + let wait = std::time::Duration::from_secs(30); + let first_id = agent_ids(api, me.session, &[me.first])?.remove(0); + let made = |label: &str, title: &str| -> Result { + let r = shell(api, me.session, "POST", "/api/fragments", Some(&json!({ "label": label, "template": "chat", "title": title })), &[])?; + let name = r.body["name"].as_str().unwrap_or_default().to_string(); + shell(api, me.session, "PUT", &format!("/api/f/{name}/members/{first_id}"), Some(&json!({ "role": "editor" })), &[])?; + Ok(name) + }; + // a chat continued (Continue's, as continue.js makes one): its history card alone + let bread = made(&format!("{}-chat-bread", me.first), "Bread notes")?; + let card = json!({ "kind": "history", "title": "Bread notes", "archive": "e2e", "key": "e2e-bread", "messages": [{ "role": "user", "text": "When do I bake?" }, { "role": "assistant", "text": "Once the starter doubles within six hours." }], "earlier": 0 }); + let posted = shell(api, me.session, "POST", &format!("/api/f/{bread}/channels/chat"), Some(&json!({ "id": "history", "body": card })), &[])?; + // a chat nothing was said in + let unused = made(&format!("{}-chat-unused", me.first), "Never used")?; + b.click(page, "#history-link")?; + let row = |name: &str| format!("document.querySelector({})", js(&format!(".history-row[data-key={}]", js(&format!("chat:{name}"))))); + let listed = b.until(page, &format!("location.pathname === '/history' && !document.getElementById('history-page').hidden && {}?.innerText.includes('Once the starter doubles') && !!{}?.querySelector('time')?.dateTime", row(&bread), row(&bread)), wait); + let unlisted = b.eval(page, &format!("!{}", row(&unused)))?; + s.ok( + "History lists a continued chat with its card's time and newest words, and not a chat nothing was said in", + posted.status == 200 && listed && unlisted == true, + json!({ "row": b.eval(page, &format!("{}?.innerText ?? null", row(&bread)))?, "unused": unlisted }), + ); + // archived: under Archived, with its chip, and gone from the sidebar + shell(api, me.session, "PUT", &format!("/api/fragments/{bread}/archived"), Some(&json!({ "archived": true })), &[])?; + b.click(page, ".history-filter[data-filter=\"archived\"]")?; + let archived = b.until(page, &format!("{}?.querySelector('.history-chip')?.textContent === 'Archived' && [...document.querySelectorAll('.history-row')].every((r) => r.querySelector('.history-chip')) && !document.querySelector({})", row(&bread), js(&format!("#chats [data-key={}]", js(&format!("chat:{bread}"))))), wait); + let _ = b.screenshot(page, &shots.join("desktop-history-archived.png")); + s.ok("archived, it is under Archived with its chip, and gone from the sidebar", archived, json!({ "rows": b.eval(page, "[...document.querySelectorAll('.history-row')].map((r) => r.innerText)")? })); + b.click(page, ".history-filter[data-filter=\"all\"]")?; + // its title filters it at once; words nothing holds say so + b.eval(page, &fill(".history-search input", "bread no"))?; + let titled = b.until(page, &format!("document.querySelectorAll('.history-row').length === 1 && !!{}", row(&bread)), wait); + b.eval(page, &fill(".history-search input", "zqxjv nothing"))?; + let none = b.until(page, "document.querySelector('.history-empty')?.textContent === 'Nothing found for “zqxjv nothing”'", wait); + b.eval(page, &fill(".history-search input", ""))?; + s.ok("its title filters History as it is typed, and words nothing holds say so", titled && none, json!({ "list": b.eval(page, "document.querySelector('.history-list').innerText")? })); + // opened from History, then one Back: History again + b.click(page, &format!(".history-row[data-key={}] .history-open", js(&format!("chat:{bread}"))))?; + let opened = b.until(page, &format!("location.pathname === '/' && document.getElementById('history-page').hidden && document.getElementById('chat-title').textContent === 'Bread notes' && [...document.querySelectorAll('#frames iframe')].find((f) => !f.hidden)?.dataset.fragment === {}", js(&bread)), wait); + b.eval(page, "(history.back(), true)")?; + let back = b.until(page, "location.pathname === '/history' && !document.getElementById('history-page').hidden", wait); + s.ok("a chat opens from History in the middle column, and one Back is History again", opened && back, json!({ "at": b.eval(page, "location.pathname")? })); + // a message sent in the archived chat brings it back to the sidebar + b.click(page, &format!(".history-row[data-key={}] .history-open", js(&format!("chat:{bread}"))))?; + let said = "the rye wants a warmer spot"; + let sent = say_in(s, b, page, &bread, said); + let back_in_list = s.eventually(wait, || { + shell(api, me.session, "GET", "/api/fragments", None, &[]).ok().and_then(|r| r.body["fragments"].as_array().and_then(|l| l.iter().find(|f| f["name"] == bread.as_str()).map(|f| f["archived"] != true))).unwrap_or(false) + }); + let in_sidebar = b.until(page, &format!("!!document.querySelector({})", js(&format!("#chats [data-key={}]", js(&format!("chat:{bread}"))))), wait); + s.ok("a message the person sends in a chat they archived brings it back to their sidebar", sent && back_in_list && in_sidebar, json!({ "sent": sent, "unarchived": back_in_list, "sidebar": in_sidebar })); + // a word only its messages hold: found by the person's search, its snippet shown + b.click(page, "#history-link")?; + b.eval(page, &fill(".history-search input", "warmer"))?; + let found = b.until(page, &format!("{}?.querySelector('.history-preview.found')?.textContent.includes('warmer')", row(&bread)), wait); + let _ = b.screenshot(page, &shots.join("desktop-history-search.png")); + b.eval(page, &fill(".history-search input", ""))?; + s.ok("a word only a chat's messages hold finds it in History, its snippet shown", found, json!({ "list": b.eval(page, "document.querySelector('.history-list').innerText")? })); + // a reload at /history stays there + b.eval(page, "(window.__before_reload = true, true)")?; + b.reload(page)?; + let stays = b.until(page, &format!("!window.__before_reload && location.pathname === '/history' && !document.getElementById('history-page').hidden && !!{}", row(&bread)), wait); + s.ok("a reload at /history opens History again", stays, json!({ "at": b.eval(page, "location.pathname")? })); + // the rest of the lane goes on from a chat + b.click(page, &format!(".history-row[data-key={}] .history-open", js(&format!("chat:{bread}"))))?; + b.until(page, "location.pathname === '/'", wait); + Ok(()) +} + /// Unread (docs/api.md, Control API): a chat not open counts on its row what /// came since its person saw it, as their list does; opening it marks it /// seen, here and in their list; and the open chat counts nothing as its diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index d6e5b2063..7e87dae48 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -112,7 +112,7 @@ pub fn shell_page(text: &str, release: &str, deployed: Option) -> String { /// each with its content type: of the rest of cell/shell/, its pages are /// served at their own paths, and its credits and licenses not at all. #[cfg(feature = "platform")] -const SHELL_PUBLISHED: [(&str, &str); 47] = [ +const SHELL_PUBLISHED: [(&str, &str); 49] = [ // the cell's server-rendered pages' base look (share.rs `sheet_html`), // on the platform's origin and, as /__sheet.css, on a fragment's ("sheet.css", "text/css; charset=utf-8"), @@ -143,6 +143,8 @@ const SHELL_PUBLISHED: [(&str, &str); 47] = [ ("app-icon.js", "text/javascript; charset=utf-8"), // the Apps page: each app's dock, card, icon and who has access ("apps-page.js", "text/javascript; charset=utf-8"), + // the History page: every chat they have had, and its search + ("history-page.js", "text/javascript; charset=utf-8"), ("activity.js", "text/javascript; charset=utf-8"), ("fragment-title.js", "text/javascript; charset=utf-8"), ("tooltips.js", "text/javascript; charset=utf-8"), @@ -153,6 +155,7 @@ const SHELL_PUBLISHED: [(&str, &str); 47] = [ ("chat-list.js", "text/javascript; charset=utf-8"), // the Session Archive under the chats (templates/sessions/README.md) ("session-archive.js", "text/javascript; charset=utf-8"), + ("continue.js", "text/javascript; charset=utf-8"), // delegation threads: a line each over the chat an agent asked from ("threads.js", "text/javascript; charset=utf-8"), ("session-refresh.js", "text/javascript; charset=utf-8"), diff --git a/docs/api.md b/docs/api.md index f7d904cd0..ed076deb5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1700,7 +1700,7 @@ is what other parties rely on. | method & path (platform origin) | what | | --- | --- | -| `GET /`, `/settings`, `/apps`, `/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 /`, `/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")); -}