Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SPECIAL-CASE-INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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); to a page of a fragment you own that asks, your agents, and adding one of them to that fragment; to your own chat's page, a way to ask for an app put in its composer (`compose`; docs/api.md, The shell); to a chat's page of yours, its alerts, which no frame may ask the browser for (the shell's alerts worker, one subscription a chat, made with the chat's own key); what your agent is doing, which it reads from your chats' rows' listed records by the chat's turn kinds (docs/chat-records.md): the shell is that contract's third reader, beside the bridge and the chat's page, so a change to it changes the shell too | Security: it holds your session and frames your fragments and computers. The thin page users can't break. A page asks it, never grants: the shell checks the page is your own fragment's, at its own origin, offers only your own agents, and adds one only once you confirm it in the shell's own dialog, every time. A chat's page takes `compose` only from the platform's origin (its hello's `platform`) and sends nothing: you do. | The public fragment, computer, identity and ledger APIs |
| The shell (`/`, `/settings`, `/apps`, `/welcome`): sidebar, desktop (its dock and windows), settings, the Apps page (each of your fragments but chats and agents: your dock, its card, its icon, who has access and its share sheet; one page, as it acts as you over all of them, which no fragment may), the launcher (search), first run (the welcome); your Session Archive (your one fragment of kind `sessions`: its chats' index under a heading below your chats, each opened read-only in the middle column, its own page framed as a chat is: templates/sessions/README.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 |
Expand Down
6 changes: 4 additions & 2 deletions cell/shell/chat-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li
// ---- chats: one flat list, newest first, the open one in the middle column ----
function renderHeading() {
const open = current() && byName(current()) && !page();
// the session archive is read here, never a chat to add to or share
const archive = byName(current())?.kind === "sessions";
$("chat-heading").hidden = !open;
$("page-title").hidden = !page();
$("page-title").textContent = page() ?? "";
$("new-chat").hidden = !open || isGuest() || !primaryAgent()?.identity;
$("chat-options").hidden = !open;
$("new-chat").hidden = !open || archive || isGuest() || !primaryAgent()?.identity;
$("chat-options").hidden = !open || archive;
if (!open) return;
const title = chatTitle(current());
$("chat-title").textContent = title;
Expand Down
5 changes: 5 additions & 0 deletions cell/shell/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@
<section class="invites" id="invites" aria-label="Invites" hidden></section>
<nav class="rows" id="chats" aria-label="Chats"></nav>
<div class="spacer"></div>
<!-- the person's Session Archive: its chats, newest first (session-archive.js) -->
<section class="archive" id="archive" aria-label="Session Archive" hidden>
<button class="archive-toggle" id="archive-toggle" type="button" aria-expanded="true" aria-controls="archive-rows"><span>Session Archive</span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg></button>
<nav class="rows" id="archive-rows" aria-label="Session Archive"></nav>
</section>
<!-- every fragment of theirs but chats and agents: the dock, cards, icons and sharing (apps-page.js) -->
<button class="row settings" id="apps-link" type="button"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg><span class="label">Apps</span></button>
<button class="row settings" id="settings" type="button"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span class="label">Settings</span></button>
Expand Down
101 changes: 101 additions & 0 deletions cell/shell/session-archive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// 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=<key>`), 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.
export function createSessionArchive({ api, ui: { $, el, patch, renderHeading }, data: { allFragments, own, seg }, selection: { current, page, getFrame }, navigation: { openChat, leaveSidebar }, store }) {
// 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;

const archiveOf = () => allFragments().find((f) => f.kind === "sessions" && own(f))?.name ?? null;

async function load(force = false) {
const name = archiveOf();
if (!name) {
read.name = null;
read.entries = [];
return render();
}
if (!force && name === read.name && Date.now() - read.at < READ_EVERY_MS) return render();
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();
read.name = name;
read.entries = Array.isArray(index.sessions) ? index.sessions : [];
render();
}
document.addEventListener("visibilitychange", () => {
if (!document.hidden) load().catch(() => {});
});

// 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.
addEventListener("message", (e) => {
if (e.data?.fragment !== "session-archive" || !read.name || e.source !== getFrame(read.name)?.contentWindow) return;
open = typeof e.data.key === "string" ? e.data.key : null;
renderHeading();
render();
});

const shownOpen = () => store.get(OPEN, true) !== false;

function render() {
const box = $("archive");
box.hidden = !read.name || !read.entries.length;
if (box.hidden) return;
const toggle = $("archive-toggle");
toggle.setAttribute("aria-expanded", String(shownOpen()));
toggle.onclick = () => {
store.set(OPEN, !shownOpen());
render();
};
$("archive-rows").hidden = !shownOpen();
const here = current() === read.name && !page();
const rows = read.entries.slice(0, SHOWN).map((e) => {
const active = here && open === e.key;
const row = el("button", `row archive-row${active ? " active" : ""}`);
row.type = "button";
row.dataset.key = `archived:${e.key}`;
row.setAttribute("aria-current", active ? "page" : "false");
row.append(el("span", "label", e.title || "Untitled"));
row.onclick = () => { openSession(e.key); leaveSidebar(); };
return row;
});
if (read.entries.length > SHOWN) {
const all = el("button", "row archive-all");
all.type = "button";
all.dataset.key = "archived:all";
all.append(el("span", "label", `All ${read.entries.length}`));
all.onclick = () => { openSession(null); leaveSidebar(); };
rows.push(all);
}
patch($("archive-rows"), rows);
}

// one conversation (its key), or with none the archive's list
function openSession(key) {
if (!read.name) return;
open = key;
openChat(read.name, true, key ? `/?s=${encodeURIComponent(key)}` : "/");
}

// 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 };
}
9 changes: 9 additions & 0 deletions cell/shell/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ body.resizing-row, body.resizing-row * { cursor: row-resize !important; }
.sheet { max-height: calc(var(--vh) - 32px - env(safe-area-inset-top, 0px)); overflow-y: auto; overscroll-behavior: contain; }
.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 ---- */
Expand Down
18 changes: 15 additions & 3 deletions cell/shell/shell.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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 { createSessionRefresh } from "./session-refresh.js";
import { createOnboarding } from "./onboarding.js";
import { createComputer } from "./computer.js";
Expand Down Expand Up @@ -834,13 +835,18 @@ const watching = { on: false, socket: null, wait: 1000, timer: null, settle: nul
const listLive = () => watching.socket?.readyState === WebSocket.OPEN;
const { renderHeading, renderChats, loadInvites, primaryAgent, markSeen, updateSidebarEdge } = createChatList({
api, ui: { $, el, patch, agentFace, notice },
data: { byName, own, chats, shown, titleOf, chatTitle: (name) => chatTitle(name), homeMark: (f) => homeMark(f), isGroup, agentOfChat: (name) => agentOfChat(name), agentsOf,
data: { byName, own, chats, shown, titleOf, chatTitle: (name) => (byName(name)?.kind === "sessions" ? sessionArchive.title() : chatTitle(name)), homeMark: (f) => homeMark(f), isGroup, agentOfChat: (name) => agentOfChat(name), agentsOf,
agents: () => state.agents, allFragments: () => state.fragments, computer: () => state.computer, isGuest: () => state.guest, personId: () => state.me?.id },
selection: { current: () => state.current, page: () => state.page, getFrame: (name) => state.frames.get(name) },
menus: { menu, menuEdge, menuOwner: () => menuFor, openMenu, shareItem, archiveItem },
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() }, data: { allFragments: () => state.fragments, own, seg },
selection: { current: () => state.current, page: () => state.page, getFrame: (name) => state.frames.get(name) },
navigation: { openChat, leaveSidebar }, store,
});

// The identity of the person's own agent a direct chat is with (its row's
// one agent, or the agent its label is for while its row names none:
Expand Down Expand Up @@ -872,7 +878,9 @@ const { chatTitle, homeMark, loadHomes, homePicker, paintHomes, setHome } = crea
// launch's late answer changes the screen only if none was given since
// (launcher.js).
let views = 0;
function openChat(name, push = true) {
// `path`: the page of it to show (the session archive's list, or one of
// its conversations: session-archive.js), its frame sent there.
function openChat(name, push = true, path = null) {
if (!byName(name)) return;
views++;
if (push) at("/");
Expand All @@ -884,10 +892,13 @@ function openChat(name, push = true) {
state.current = name;
store.set(CURRENT, name);
hideNotice();
chatFrame(name);
const made = !state.frames.has(name);
chatFrame(name, path ?? undefined);
if (path && !made) go(state.frames.get(name), framed(name, path));
for (const [n, frame] of state.frames) frame.hidden = n !== name;
renderHeading();
renderChats();
sessionArchive.render();
markSeen();
prewake();
navigated();
Expand Down Expand Up @@ -1518,6 +1529,7 @@ async function load(changed = false) {
state.agents = new Map((state.computer?.agents ?? []).map((a) => [a.identity, a]));
}
renderChats();
sessionArchive.load().catch(() => {});
renderApps();
appsPage.listed().catch(() => {});
renderHeading();
Expand Down
4 changes: 3 additions & 1 deletion cli/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,9 @@ fragment export --out ~/me.tar # somewhere else
The conversations you had with an agent somewhere else, kept in a
private fragment of yours (kind `sessions`, titled Session Archive, made
the first time you import): one file a conversation, under `sessions/`,
and one index a kind, under `index/`, newest first. Bring them in from
and one index a kind, under `index/`, newest first. The shell lists its
chats under Session Archive, below your chats, each read there as a
chat. Bring them in from
the old Finite's export (its `chats/`, or the folder holding it), or
from Hermes' own `state.db` (or the Hermes home holding it), read-only:

Expand Down
3 changes: 2 additions & 1 deletion crates/e2e/src/lanes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const LANES: &[Lane] = &[
appfiles::appfiles,
blobs::blobs,
sessions::sessions,
sessions::sessions_ui,
notes::notes,
brain::brain,
doc::doc,
Expand Down Expand Up @@ -165,7 +166,7 @@ const LANES: &[Lane] = &[
pub const SHARDS: [&[&str]; 4] = [
&["shell", "computers", "export", "hermes", "agent-smoke", "agent-ask", "agent-app", "agent-restart", "bot-mode", "checkout"],
&["shell-ui", "wipe", "sync", "migrations", "restart", "connections"],
&["facet-cap", "app-lockdown", "site", "watch", "icons", "schemas", "channels", "live", "routes", "cli", "browser", "jobs", "triggers", "sessions", "finite-private", "chat"],
&["facet-cap", "app-lockdown", "site", "watch", "icons", "schemas", "channels", "live", "routes", "cli", "browser", "jobs", "triggers", "sessions", "sessions-ui", "finite-private", "chat"],
&[
"auth", "create", "lockdown", "keys", "members", "identities", "signin", "levers", "secrets", "delegation", "files", "deploy", "templates", "share", "faces", "isolation", "frames",
"ops", "public", "effects", "appfiles", "blobs", "notes", "brain", "doc", "push", "ai", "ledger", "transcribe", "orgs", "billing", "governance", "billing-page", "trials", "admin", "admin-page",
Expand Down
Loading
Loading