web: the Views dropdown — every view of a chat, one header button (#841)
* web: the Views dropdown — every view of a chat, one header button A ▤ dropdown beside the chat kebab lists the chat's live views (newest first, same order as /views) — and in a DM with an AI helper, Memory rides at the top as the persistent view (Paul's design call: memory IS a view of the chat). The button shows only where the dropdown has something to offer, and a view folding in mid-conversation surfaces it without a reload (views.js pokes lchat:views-changed). Cherry-pick candidate: needs both #816 (views) and #829 (memory) — PR it against main once both land. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: Views button reads "Open" / the open view's name; per-view remove Paul's header polish: the dropdown button says "Open" at rest and names whatever the sidebar is currently showing (an additive command-panel observer mirrors panel state; the embed's set-slot is untouched); "Close views sidebar" appears only while something is open; and every bot view row carries a ✕ that emits the existing view.remove event — done-with views leave for every member and device. Memory stays persistent (no remove). Rows restructured as div + sibling buttons (a button can't legally nest a button). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: views dropdown polish — plain close row, no repeated glyph in the panel head ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+179
−8
web/pages/index.html+4 −0web/static/apps.js+13 −5web/static/apps/views.js+13 −3web/static/shell.js+126 −0web/static/style.css+23 −0
diff --git a/web/pages/index.html b/web/pages/index.html| index 5a29176..0400510 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -189,6 +189,10 @@ |
| <div class="thread-title" id="thread-title"></div> |
| <div class="thread-sub" id="thread-sub"></div> |
| </div> |
| + <div class="menu-wrap hidden" id="views-menu-wrap"> |
| + <button class="icon-btn views-btn" id="views-menu-btn" aria-haspopup="true"><span id="views-menu-label">Open</span> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button> |
| + <div class="menu hidden" id="views-menu" role="menu"></div> |
| + </div> |
| <div class="menu-wrap" id="thread-menu-wrap"> |
| <button class="icon-btn" id="thread-menu-btn" aria-label="Chat options" aria-haspopup="true">⋮</button> |
| <div class="menu hidden" id="thread-menu" role="menu"> |
diff --git a/web/static/apps.js b/web/static/apps.js| index 49c6c66..4629fa1 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -1222,12 +1222,20 @@ let panelObserver = null; // (threadId, spec|null) => void |
| export function setCommandPanelObserver(fn) { |
| panelObserver = fn; |
| } |
| +// Additive observers, for host chrome that mirrors panel state without owning |
| +// it (the shell's Views button names whatever the sidebar is showing). The |
| +// single set-slot above stays the command-host embed's — the two never fight. |
| +const panelObservers = new Set(); |
| +export function addCommandPanelObserver(fn) { |
| + panelObservers.add(fn); |
| +} |
| export function notifyCommandPanel(threadId, spec) { |
| - if (!panelObserver) return; |
| - try { |
| - panelObserver(threadId, spec || null); |
| - } catch (e) { |
| - console.error(e); |
| + for (const fn of panelObserver ? [panelObserver, ...panelObservers] : [...panelObservers]) { |
| + try { |
| + fn(threadId, spec || null); |
| + } catch (e) { |
| + console.error(e); |
| + } |
| } |
| } |
diff --git a/web/static/apps/views.js b/web/static/apps/views.js| index a0a9d3a..28878c1 100644 |
| --- a/web/static/apps/views.js |
| +++ b/web/static/apps/views.js |
| @@ -127,14 +127,14 @@ export const viewsApp = defineApp({ |
| // merge to reason about and stale fields can't linger. |
| "view.create": (s, e) => { |
| const v = sanitizeView(e); |
| - return v ? { views: upsert(s.views || [], v) } : s; |
| + return v ? poked({ views: upsert(s.views || [], v) }) : s; |
| }, |
| "view.update": (s, e) => { |
| const v = sanitizeView(e); |
| if (!v || !(s.views || []).some((x) => x.id === v.id)) return s; // update never creates |
| - return { views: upsert(s.views, v) }; |
| + return poked({ views: upsert(s.views, v) }); |
| }, |
| - "view.remove": (s, e) => ({ views: (s.views || []).filter((v) => v.id !== e.id) }), |
| + "view.remove": (s, e) => poked({ views: (s.views || []).filter((v) => v.id !== e.id) }), |
| }, |
| // Views arrive alongside a bot reply that already narrates them (and carries |
| // the Open view chip) — a second action line would just double the bubble. |
| @@ -156,6 +156,16 @@ export const viewsApp = defineApp({ |
| styles: schemaStyles + viewsStyles(), |
| }); |
| +// poked passes folded state through while nudging the shell's ▤ Views button |
| +// (a view arriving mid-conversation should surface it without a reload). |
| +// Headless folds (Node tests) skip the nudge. |
| +function poked(state) { |
| + try { |
| + if (typeof document !== "undefined") document.dispatchEvent(new CustomEvent("lchat:views-changed")); |
| + } catch {} |
| + return state; |
| +} |
| + |
| function viewsFor(threadId) { |
| const s = appStateFor(threadId, "views"); |
| return s && Array.isArray(s.views) ? s.views : []; |
diff --git a/web/static/shell.js b/web/static/shell.js| index d27e28c..d361aa8 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -111,6 +111,7 @@ import { |
| closeCommandPanel, |
| setMemoryStore, |
| appGlyphInto, |
| + addCommandPanelObserver, |
| } from "/static/apps.js"; |
| // Register the mini-apps (side-effect imports). Each is a single self-contained |
| // file (JS + its own CSS). Personal apps get their own sidebar thread; shared |
| @@ -3200,12 +3201,85 @@ function renderApp() { |
| paintAvatar(document.getElementById("me-avatar"), session.name, () => avatarFor(myUserId)); |
| document.getElementById("me-btn").onclick = editProfile; |
| + // The ▤ Views dropdown (beside the kebab): the chat's live views, plus |
| + // Memory as the persistent view of an AI DM. Rebuilt on every open, so it |
| + // always reflects the folded state of the thread you're looking at. |
| + const vmenu = document.getElementById("views-menu"); |
| + const vmenuBtn = document.getElementById("views-menu-btn"); |
| + const closeVMenu = () => vmenu.classList.add("hidden"); |
| + vmenuBtn.onclick = (e) => { |
| + e.stopPropagation(); |
| + closeTMenu(); // one header menu at a time |
| + if (!vmenu.classList.contains("hidden")) return closeVMenu(); |
| + vmenu.replaceChildren(); |
| + // The standing last item closes whatever the sidebar is showing — the |
| + // menu is the sidebar's home, so its off switch lives here too. |
| + const entries = [...viewsMenuEntries(activeId)]; |
| + if (openPanelTitle[activeId]) entries.push({ label: "Close views sidebar", open: () => closeCommandPanel(activeId) }); |
| + for (const entry of entries) { |
| + // A row is a DIV holding sibling buttons (open + remove) — a button |
| + // can't legally nest a button, and browsers eject the inner one. |
| + const row = document.createElement("div"); |
| + row.className = "menu-item views-item"; |
| + const b = document.createElement("button"); |
| + b.className = "views-item-main"; |
| + b.setAttribute("role", "menuitem"); |
| + // A command row (Close) is plain text; view rows carry their glyph. |
| + if (entry.svg || entry.glyph) { |
| + const g = document.createElement("span"); |
| + g.className = "views-item-glyph"; |
| + if (entry.svg) g.innerHTML = entry.svg; // static in-code SVG, never data |
| + else g.textContent = entry.glyph; |
| + b.appendChild(g); |
| + } |
| + const l = document.createElement("span"); |
| + l.className = "views-item-label"; |
| + l.textContent = entry.label; |
| + b.appendChild(l); |
| + b.onclick = () => { |
| + closeVMenu(); |
| + entry.open(); |
| + }; |
| + row.appendChild(b); |
| + // A bot view you're done with removes from here — the ✕ emits the |
| + // existing view.remove event, so it leaves for every member and device |
| + // (Memory and the close row aren't removable; they carry no remove). |
| + if (entry.remove) { |
| + const x = document.createElement("button"); |
| + x.className = "views-item-x"; |
| + x.setAttribute("aria-label", `Remove "${entry.label}"`); |
| + x.innerHTML = ICON_X; // static in-code SVG |
| + x.onclick = (e) => { |
| + e.stopPropagation(); |
| + closeVMenu(); |
| + entry.remove(); |
| + }; |
| + row.appendChild(x); |
| + } |
| + vmenu.appendChild(row); |
| + } |
| + vmenu.classList.remove("hidden"); |
| + }; |
| + document.addEventListener("click", (e) => { |
| + if (e.target !== vmenuBtn && !vmenu.contains(e.target)) closeVMenu(); |
| + }); |
| + // A view folding in (or out of) the open thread re-paints the button. |
| + document.addEventListener("lchat:views-changed", () => paintViewsMenu(activeId)); |
| + // Mirror what the sidebar is showing: the button reads "Open" at rest and |
| + // the open view's own name while one is up (additive observer — the |
| + // command-host embed keeps the set-slot). |
| + addCommandPanelObserver((threadId, spec) => { |
| + openPanelTitle[threadId] = (spec && spec.title) || ""; |
| + if (threadId === activeId) paintViewsMenu(threadId); |
| + }); |
| + |
| // Per-thread kebab (thread header): rename group / view members. |
| const tmenu = document.getElementById("thread-menu"); |
| const tmenuBtn = document.getElementById("thread-menu-btn"); |
| const closeTMenu = () => tmenu.classList.add("hidden"); |
| tmenuBtn.onclick = (e) => { |
| e.stopPropagation(); |
| + closeVMenu(); // one header menu at a time |
| if (nativeMenu) return openNativeMenu(tmenu, tmenuBtn); // native NSMenu on the Mac app |
| tmenu.classList.toggle("hidden"); |
| }; |
| @@ -3709,6 +3783,57 @@ function stashAppPane(id) { |
| // openThread so membership changes (add/remove people) can refresh it in place — |
| // openThread now early-returns for the already-open thread (feed cache), so it |
| // can't be reused to re-render the header. |
| +// threadHasAIPeer: is this a DM whose other member is an AI agent? Gates the |
| +// memory surfaces (docs/eleven-memory.md) — the ⋮ Memory item and the Views |
| +// dropdown's persistent Memory entry. |
| +function threadHasAIPeer(id) { |
| + const t = threads.find((x) => x.id === id); |
| + if (!t || t.kind !== "dm") return false; |
| + return (t.members || []).some((uid) => { |
| + const e = roster.find((x) => x.user_id === uid); |
| + return e && e.is_agent && e.kind === "ai"; |
| + }); |
| +} |
| + |
| +// viewsMenuEntries: what the ▤ Views dropdown offers for a thread — Memory |
| +// (the persistent view of an AI DM) first, then every live bot view, newest |
| +// first (the same order /views shows). Empty = the button hides. |
| +function viewsMenuEntries(id) { |
| + const entries = []; |
| + if (threadHasAIPeer(id)) entries.push({ glyph: "🧠", label: "Memory", open: () => openMemoryPanel(id) }); |
| + const st = appStateFor(id, "views"); |
| + const views = st && Array.isArray(st.views) ? st.views : []; |
| + for (const v of views.slice().reverse()) { |
| + entries.push({ |
| + glyph: v.glyph || "▤", |
| + label: v.title, |
| + open: () => openThreadView(id, v.id), |
| + remove: () => { |
| + // If the sidebar is showing this very view, close it with the removal. |
| + if (openPanelTitle[id] === v.title) closeCommandPanel(id); |
| + emitAppEvent(id, { type: "view.remove", id: v.id }); |
| + }, |
| + }); |
| + } |
| + return entries; |
| +} |
| + |
| +// paintViewsMenu shows the ▤ button only where the dropdown has something to |
| +// offer. Called from paintThreadHeader and whenever a view folds (views.js |
| +// pokes lchat:views-changed), so a view arriving mid-conversation surfaces |
| +// the button without a reload. |
| +const openPanelTitle = {}; // threadId → title of the open command panel ("" = none) |
| + |
| +function paintViewsMenu(id) { |
| + const wrap = document.getElementById("views-menu-wrap"); |
| + if (!wrap) return; |
| + wrap.classList.toggle("hidden", !id || (viewsMenuEntries(id).length === 0 && !openPanelTitle[id])); |
| + // At rest the button offers "Open"; while a view is up it names it — the |
| + // header tells you where you are, not just what exists. |
| + const label = document.getElementById("views-menu-label"); |
| + if (label) label.textContent = (id && openPanelTitle[id]) || "Open"; |
| +} |
| + |
| function paintThreadHeader(id) { |
| const t = threads.find((x) => x.id === id); |
| const isGroup = t && t.kind === "group"; |
| @@ -3763,6 +3888,7 @@ function paintThreadHeader(id) { |
| } |
| document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat"; |
| document.getElementById("thread-mute").textContent = muted[id] ? "Unmute chat" : "Mute chat"; |
| + paintViewsMenu(id); |
| // End conversation is a DM-only act (issue #578) — and once ended, the |
| // banner's Reopen is the way back, so the item hides on a closed DM too. |
| document.getElementById("thread-end").style.display = t && t.kind === "dm" && !dmClosed[id] ? "" : "none"; |
diff --git a/web/static/style.css b/web/static/style.css| index 26f7d1f..cd93f7a 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -413,6 +413,29 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| justify-content: center; |
| } |
| .icon-btn svg { display: block; } |
| +/* The Views dropdown (thread header): a labelled button, and menu rows with a |
| + fixed glyph column so long view titles wrap under their own text, never |
| + under the emoji. */ |
| +.views-btn { padding: 6px 10px; font-size: 13px; font-weight: 600; gap: 4px; } |
| +#views-menu-label { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| +.views-btn svg { width: 14px; height: 14px; } |
| +.views-item-glyph svg { width: 15px; height: 15px; display: inline-block; vertical-align: -2px; } |
| +.menu-item.views-item { display: flex; align-items: center; gap: 0; padding: 0; } |
| +.menu-item.views-item:hover { background: none; } |
| +.views-item-main { |
| + flex: 1 1 auto; min-width: 0; display: flex; align-items: flex-start; gap: 8px; |
| + background: none; border: none; color: inherit; font: inherit; text-align: left; |
| + padding: 10px 12px; border-radius: 10px; cursor: pointer; |
| +} |
| +.views-item-main:hover, .views-item-main:focus-visible { background: var(--bubble-them); } |
| +.views-item-glyph { flex: 0 0 20px; text-align: center; } |
| +.views-item-label { flex: 1 1 auto; min-width: 0; } |
| +.views-item-x { |
| + flex: 0 0 auto; border: none; background: none; color: var(--muted); cursor: pointer; |
| + padding: 2px; border-radius: 6px; min-width: 24px; min-height: 24px; |
| +} |
| +.views-item-x svg { width: 13px; height: 13px; display: block; margin: auto; } |
| +.views-item-x:hover, .views-item-x:focus-visible { color: var(--ink); background: var(--bubble-them); } |
| .icon-btn:hover { background: var(--bubble-them); } |
| .menu-wrap { position: relative; flex: none; } |
| .menu { |