From 9b229cb040700793a9379dc5649a0a241233683c Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Fri, 30 Oct 2026 18:16:28 -0500 Subject: [PATCH 01/11] =?UTF-8?q?shell:=20New=20chat=20with=20its=20agent,?= =?UTF-8?q?=20two-line=20chat=20rows,=20=E2=8C=98K=20up=20top?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several agents, one UI for any number (Paul, 2026-10-30): each chat's row is two lines, its agent first (its face, a group's stacked; its name; its role's tag; what it is doing there, else when), then the chat's own name. "New chat with [agent]" names the open chat's agent, else the main one; with more than one agent its chip lists them all, the pick for that chat alone. The launcher's start row follows the same rule, with a pick beside it. The launcher's button is a search and its key, beside the brand. The main agent's pill and its menu go: what it is doing is on its chats' rows, its profile and screen in a chat's ⋯, its name and picture in Settings. Co-Authored-By: Claude Opus 5.5 --- cell/shell/chat-list.js | 244 +++++++++++++++++----------- cell/shell/icons.js | 2 + cell/shell/index.html | 10 +- cell/shell/launcher.js | 45 +++++- cell/shell/shell.css | 84 ++++++---- cell/shell/shell.js | 53 ++----- crates/e2e/src/lanes/shell.rs | 290 ++++++++++++++++++++++++---------- docs/api.md | 50 +++--- 8 files changed, 497 insertions(+), 281 deletions(-) diff --git a/cell/shell/chat-list.js b/cell/shell/chat-list.js index bbe8b9469..0189da78c 100644 --- a/cell/shell/chat-list.js +++ b/cell/shell/chat-list.js @@ -1,13 +1,14 @@ import { avatar } from "./agent-identity.js"; +import { icon } from "./icons.js"; // Newest first, with a newly made chat (no preview yet) before older ones. export const newestChat = (a, b) => (b.previewAt ?? Infinity) - (a.previewAt ?? Infinity) || (a.name < b.name ? -1 : 1); export function createChatList({ api, ui, data, selection, menus, navigation, listLive, load, invites, setInvites }) { - const { $, el, patch, agentFace, notice } = ui; + const { $, el, patch, agentFace, mark, notice } = ui; const { byName, own, chats, shown, titleOf, chatTitle, homeMark, isGroup, agentOfChat, agentsOf, agents, allFragments, computer, isGuest, personId } = data; const { current, page, getFrame } = selection; - const { menu, menuEdge, menuOwner, openMenu, shareItem, archiveItem } = menus; + const { menuOwner, openMenu, shareItem, archiveItem } = menus; const { newChat, rename, openChat, openApp, openScreen, leaveSidebar, seg } = navigation; // ---- chats: one flat list, newest first, the open one in the middle column ---- function renderHeading() { @@ -36,14 +37,16 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li b.setAttribute("aria-label", n >= UNREAD_MAX ? "More than 99 unread" : `${n} unread`); return [b]; } - // The person's one agent (Paul, 2026-10-13: "just one", a setting later): - // their first, the one the first run makes (`defaultAgent`): the first - // their computer took (its view lists its agents in the order it took - // them), or, before it took one, their agent fragment. New chats are with - // it. Any other agents they have stay out of the sidebar; their chats are - // chats, so they list and search as any other. + // The person's agents: those of their computer's that are theirs, in the + // order it took them (its view's order). + const ownAgents = () => [...agents().values()].filter((a) => byName(a.fragment)?.role === "owner"); + // Their main agent (Paul, 2026-10-30: several agents, one of them main): + // their first, the one the first run makes (`defaultAgent`), or, before + // their computer took one, their agent fragment. New chats are with it + // unless the person picks another agent's New chat; the sidebar shows it + // first, and each of their other agents on a row of its own below it. function primaryAgent() { - const first = [...agents().values()].find((a) => byName(a.fragment)?.role === "owner"); + const first = ownAgents()[0]; if (first) return { fragment: first.fragment, identity: first.identity }; const f = allFragments().find((x) => x.kind === "agent" && own(x)); return f ? { fragment: f.name, identity: null } : null; @@ -74,27 +77,25 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li const TURN_IDLE_MS_MAX = 15 * 60_000; const PROMPT_TTL_MS_DEFAULT = 60 * 60_000; const DOING_QUIET_MS = { working: TURN_IDLE_MS_MAX + LISTED_FRESH_MS, waiting: PROMPT_TTL_MS_DEFAULT + LISTED_FRESH_MS }; - // the chats its menu names, at most - const DOING_LISTED = 5; // the list socket (`watchList`, below): declared here, as the sidebar reads it - // The chats where agent `identity` is doing something, the most pressing - // first (waiting for the person, then on someone, then working), the - // newest first within each; each with when it stops counting (`until`). - function doingChats(identity) { - if (!identity || !listLive() || computer()?.phase !== "awake") return []; + // The most pressing first (waiting for the person, then on someone, then + // working), the newest first within each. + const pressing = (x, y) => DOING_ORDER.indexOf(x.doing) - DOING_ORDER.indexOf(y.doing) || y.at - x.at; + // What agents `who` (identities) are doing in chat `f`, the most pressing + // first, each with when it stops counting (`until`). + function doingIn(f, who) { + if (!who.length || !listLive() || computer()?.phase !== "awake") return []; const now = Date.now(); const out = []; - for (const f of chats()) { - for (const r of f.listed ?? []) { - const kind = r.channel === "work" && r.principal === identity ? DOING_OF_KIND[r.kind] : undefined; - if (!kind) continue; - const doing = kind === "asked" ? (r.fields?.asks === personId() ? "you" : "waiting") : kind; - const until = r.at + DOING_QUIET_MS[doing === "working" ? "working" : "waiting"]; - if (until > now) out.push({ chat: f.name, doing, at: r.at, until }); - } + for (const r of f.listed ?? []) { + const kind = r.channel === "work" && who.includes(r.principal) ? DOING_OF_KIND[r.kind] : undefined; + if (!kind) continue; + const doing = kind === "asked" ? (r.fields?.asks === personId() ? "you" : "waiting") : kind; + const until = r.at + DOING_QUIET_MS[doing === "working" ? "working" : "waiting"]; + if (until > now) out.push({ chat: f.name, doing, at: r.at, until }); } - return out.sort((x, y) => DOING_ORDER.indexOf(x.doing) - DOING_ORDER.indexOf(y.doing) || y.at - x.at); + return out.sort(pressing); } // The sidebar says it again as the first of what it shows stops counting. let doingTimer = 0; @@ -103,53 +104,128 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li const next = Math.min(...doing.map((x) => x.until)); if (Number.isFinite(next)) doingTimer = setTimeout(renderChats, Math.max(1000, next - Date.now())); } - function renderChats() { - const primary = primaryAgent(); - const agentRows = []; - if (primary) { - const doing = doingChats(primary.identity); - doingAgain(doing); - const says = DOING_SAYS[doing[0]?.doing]; - const row = el("button", "agent-heading sidebar-agent"); - row.id = "sidebar-agent"; - row.type = "button"; - row.dataset.key = primary.fragment; - row.setAttribute("aria-label", `${titleOf(primary.fragment)}${says ? `, ${says.toLowerCase()}` : ""}: your agent's menu`); - row.setAttribute("aria-haspopup", "menu"); - row.setAttribute("aria-controls", "menu"); - row.setAttribute("aria-expanded", String(!!menuOwner() && menuOwner().button === $("sidebar-agent"))); - const face = el("span", "sidebar-agent-mark"); - face.append(avatar(agentFace(primary.fragment, primary.identity), "small")); - // its name, and under it what it is doing, so the name keeps its room - const named = el("span", "sidebar-agent-named"); - named.append(el("strong", "title", titleOf(primary.fragment))); - if (says) named.append(el("span", `agent-doing ${doing[0].doing}`, says)); - row.append(face, named); - row.onclick = () => openPrimaryMenu(row); - agentRows.push(row); + // When a chat's newest message was said, as its row says it: today's + // time, then "Yesterday", a weekday within the week, else its date. + function saidAt(at) { + const d = new Date(at), now = new Date(); + const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); + const days = Math.round((day(now) - day(d)) / 86_400_000); + const t = el("time", null, + days <= 0 ? d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) + : days === 1 ? "Yesterday" + : days < 7 ? d.toLocaleDateString([], { weekday: "short" }) + : d.toLocaleDateString([], { month: "short", day: "numeric", ...(d.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }) })); + t.dateTime = d.toISOString(); + return t; + } + // A chat's row, its agent first (Paul, 2026-10-30: it reads as talking to + // agents, not as threads piling up): whose chat it is (its agents' faces, + // a group's stacked, their names, one agent's role) and what it is doing + // there, else when its newest message was said; then the chat's own name + // and its marks. A chat with none of the person's agents (someone + // else's) is its name alone. + function chatRow(f, theirs, doing) { + const active = f.name === current() && !page(); + const row = el("button", `row chat-row${active ? " active" : ""}`); + row.type = "button"; + row.dataset.key = `chat:${f.name}`; + row.setAttribute("aria-current", active ? "page" : "false"); + row.onclick = () => { openChat(f.name); leaveSidebar(); }; + const marks = [el("span", "label", chatTitle(f.name)), ...homeMark(f), ...unreadMark(f)]; + if (!theirs.length) { + row.append(...marks); + return row; + } + const face = el("span", "chat-face"); + face.append(mark(f.name)); + const who = el("span", "chat-who"); + who.append(el("span", "chat-agent", theirs.map((a) => titleOf(a.fragment)).join(", "))); + // one agent's role (its `meta.role`, on its row in the list), as a tag + const role = theirs.length === 1 ? byName(theirs[0].fragment)?.agentRole : null; + if (role) who.append(el("span", "role-tag", role)); + // what it is doing here, while it is, in place of when + if (doing) who.append(el("span", `agent-doing ${doing.doing}`, DOING_SAYS[doing.doing])); + else if (f.previewAt) who.append(saidAt(f.previewAt)); + const what = el("span", "chat-what"); + what.append(...marks); + const lines = el("span", "chat-lines"); + lines.append(who, what); + row.classList.add("two"); + row.append(face, lines); + return row; + } + // A row's day ("2:14 PM", then "Yesterday") moves at midnight: the + // sidebar is drawn again then. + let dayTimer = 0; + function dayAgain() { + clearTimeout(dayTimer); + const now = new Date(); + dayTimer = setTimeout(renderChats, new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime() - now.getTime() + 1000); + } + // Whom a new chat is with unless the person picks another (Paul, + // 2026-10-30): the open chat's agent (a group's lead) when it is one of + // theirs, else their main agent. + function newChatAgent() { + const mine = ownAgents(); + const open = current() && !page() ? agentsOf(current()).map((id) => mine.find((a) => a.identity === id)).find(Boolean) : null; + return open ?? primaryAgent(); + } + // The launcher's key, as the person's keyboard names it. + const LAUNCHER_KEY = /Mac|iPhone|iPad|iPod/.test(navigator.userAgentData?.platform ?? navigator.platform ?? "") ? "⌘K" : "Ctrl K"; + // "New chat with" and the agent it is with (`newChatAgent`), its chip; + // with more than one agent, the chip lists them all, and the one picked + // gets that chat alone. A guest makes no chat. + function renderStart() { + const agent = newChatAgent(); + const start = $("sidebar-start-chat"); + start.hidden = !agent?.identity || isGuest(); + $("sidebar-start").hidden = start.hidden; + if (start.hidden) return patch($("new-chat-agent"), []); + const name = titleOf(agent.fragment); + start.setAttribute("aria-label", `New chat with ${name}`); + start.onclick = () => newChat(agent.identity); + const many = ownAgents().length > 1; + const chip = el(many ? "button" : "span", "agent-pick"); + chip.dataset.key = `${agent.fragment} ${many}`; + chip.append(avatar(agentFace(agent.fragment, agent.identity)), el("span", "name", name)); + if (many) { + chip.type = "button"; + chip.setAttribute("aria-label", `${name}: pick another agent`); + chip.setAttribute("aria-haspopup", "menu"); + chip.setAttribute("aria-controls", "menu"); + const shown = $("new-chat-agent").firstElementChild; + chip.setAttribute("aria-expanded", String(!!shown && menuOwner()?.button === shown)); + chip.append(icon("caret")); + chip.onclick = () => openMenu(chip, ownAgents().map((a) => ({ + face: avatar(agentFace(a.fragment, a.identity)), + text: titleOf(a.fragment), + tag: byName(a.fragment)?.agentRole, + current: a.identity === newChatAgent()?.identity, + onClick: () => newChat(a.identity), + }))); } - patch($("primary-agent"), agentRows); - // another chat with it, one click from the top (its menu's New chat too) - const startChat = $("sidebar-start-chat"); - startChat.hidden = !primary?.identity || isGuest(); - startChat.onclick = () => newChat(primary.identity); + patch($("new-chat-agent"), [chip]); + } + function renderChats() { + renderStart(); // the launcher: a guest's searches; a seat's also starts a chat const launcherLabel = isGuest() ? "Search chats and apps" : "Search or start a new chat"; + $("launcher-key").textContent = LAUNCHER_KEY; for (const id of ["sidebar-new-chat", "new-chat"]) { - $(id).title = `${launcherLabel} (⌘K)`; - $(id).dataset.tooltip = `${launcherLabel} (⌘K)`; + $(id).title = `${launcherLabel} (${LAUNCHER_KEY})`; + $(id).dataset.tooltip = `${launcherLabel} (${LAUNCHER_KEY})`; $(id).setAttribute("aria-label", launcherLabel); } + const mine = new Map(ownAgents().map((a) => [a.identity, a])); + const seen = []; const rows = shown(chats()).sort(newestChat).map((f) => { - const active = f.name === current() && !page(); - const row = el("button", `row chat-row${active ? " active" : ""}`); - row.type = "button"; - row.dataset.key = `chat:${f.name}`; - row.setAttribute("aria-current", active ? "page" : "false"); - row.append(el("span", "label", chatTitle(f.name)), ...homeMark(f), ...unreadMark(f)); - row.onclick = () => { openChat(f.name); leaveSidebar(); }; - return row; + const theirs = agentsOf(f.name).map((id) => mine.get(id)).filter(Boolean); + const doing = doingIn(f, theirs.map((a) => a.identity)); + seen.push(...doing); + return chatRow(f, theirs, doing[0]); }); + doingAgain(seen); + dayAgain(); patch($("chats"), rows.length ? rows : [el("div", "empty-row", chats().length ? "Every chat is archived" : isGuest() ? "Chats shared with you show here" : "Your chats will appear here")]); } @@ -208,52 +284,29 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li }; return b; } - // The agent's own menu: where it is doing something (each chat opens), - // another chat with it, its name, its profile and its screen. A chat's own - // (Rename, Invite, Archive) is the chat's ⋯. What it is doing is read as - // it opens, not from when its row was drawn. - function openPrimaryMenu(trigger) { - const primary = primaryAgent(); - if (!primary) return; - openMenu(trigger, [ - ...doingChats(primary.identity) - .slice(0, DOING_LISTED) - .map((x) => ({ icon: "agent", text: `${DOING_SAYS[x.doing]} in ${titleOf(x.chat)}`, onClick: () => { openChat(x.chat); leaveSidebar(); } })), - ...(primary.identity && !isGuest() ? [{ icon: "newChat", text: "New chat", onClick: () => newChat(primary.identity) }] : []), - { icon: "rename", text: "Rename…", onClick: () => rename(primary.fragment) }, - { icon: "agent", text: "Its profile", onClick: () => openApp(primary.fragment) }, - ...(computer() ? [{ icon: "screen", text: "Its screen", onClick: () => openScreen(primary.fragment) }] : []), - ]); - } // Match the chat composer's edge reflection, including its 64px falloff. - // Follow the pointer across the sidebar and its fixed-position agent menu. const sidebarHoverPointer = matchMedia("(hover: hover) and (pointer: fine)"); let sidebarEdgeFrame = 0; let sidebarEdgePointer = null; - const sidebarEdgeControls = () => $("sidebar").querySelectorAll(".sidebar-agent, .sidebar-new-chat"); - // Keep transient styles off the rendered pill so roster updates preserve its node. - const expandedAgentEdge = control => control.id === "sidebar-agent" && !menu.hidden && menu.classList.contains("agent-menu"); - const sidebarEdgeSurface = control => expandedAgentEdge(control) ? menuEdge : control.id === "sidebar-agent" ? $("primary-agent") : control; + const sidebarEdgeControls = () => $("sidebar").querySelectorAll(".sidebar-new-chat"); function clearSidebarEdge() { cancelAnimationFrame(sidebarEdgeFrame); sidebarEdgeFrame = 0; - for (const control of sidebarEdgeControls()) sidebarEdgeSurface(control).style.setProperty("--edge-light", "0"); + for (const control of sidebarEdgeControls()) control.style.setProperty("--edge-light", "0"); } function updateSidebarEdge() { if (!sidebarEdgePointer || !sidebarHoverPointer.matches) return; for (const control of sidebarEdgeControls()) { - const bounds = control.getBoundingClientRect(); - const rect = { left: bounds.left, top: bounds.top, width: bounds.width, height: expandedAgentEdge(control) ? menu.getBoundingClientRect().bottom - bounds.top : bounds.height }; + const rect = control.getBoundingClientRect(); const x = sidebarEdgePointer.x - rect.left, y = sidebarEdgePointer.y - rect.top; const radius = Math.min(rect.width / 2, rect.height / 2, parseFloat(getComputedStyle(control).borderTopLeftRadius)); const qx = Math.abs(x - rect.width / 2) - rect.width / 2 + radius; const qy = Math.abs(y - rect.height / 2) - rect.height / 2 + radius; const distance = Math.max(0, Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - radius); const proximity = Math.max(0, 1 - distance / 64); - const surface = sidebarEdgeSurface(control); - surface.style.setProperty("--edge-x", `${x}px`); - surface.style.setProperty("--edge-y", `${y}px`); - surface.style.setProperty("--edge-light", String(proximity * proximity)); + control.style.setProperty("--edge-x", `${x}px`); + control.style.setProperty("--edge-y", `${y}px`); + control.style.setProperty("--edge-light", String(proximity * proximity)); } } document.addEventListener("pointermove", event => { @@ -289,7 +342,8 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li document.addEventListener("visibilitychange", markSeen); // The open chat's ⋯: its name, its people, its agents' profiles and // screens, and archiving. New chats are the launcher's, with the person's - // one agent (`primaryAgent`), whichever chat is open. + // main agent (`primaryAgent`), whichever chat is open, and each agent's + // menu's, with that agent. $("chat-options").onclick = () => openChatMenu(current(), $("chat-options")); function openChatMenu(name, trigger) { const group = isGroup(name); @@ -310,5 +364,5 @@ export function createChatList({ api, ui, data, selection, menus, navigation, li archiveItem(name), ]); } - return { renderHeading, renderChats, loadInvites, primaryAgent, markSeen, updateSidebarEdge }; + return { renderHeading, renderChats, loadInvites, primaryAgent, newChatAgent, markSeen }; } diff --git a/cell/shell/icons.js b/cell/shell/icons.js index e1759f34b..19dbae04c 100644 --- a/cell/shell/icons.js +++ b/cell/shell/icons.js @@ -20,6 +20,8 @@ const ICON = { external: [["path", { d: "M15 3h6v6" }], ["path", { d: "M10 14 21 3" }], ["path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" }]], link: [["path", { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }], ["path", { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }]], newChat: [["path", { d: "M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }], ["path", { d: "M18.4 2.6a1 1 0 0 1 3 3l-9 9a2 2 0 0 1-.85.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.85z" }]], + // Lucide's chevron-down + caret: [["path", { d: "m6 9 6 6 6-6" }]], // Lucide's bell bell: [["path", { d: "M10.268 21a2 2 0 0 0 3.464 0" }], ["path", { d: "M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" }]], // the shell's own diff --git a/cell/shell/index.html b/cell/shell/index.html index 0607729dd..27c7eb9b5 100644 --- a/cell/shell/index.html +++ b/cell/shell/index.html @@ -45,12 +45,14 @@