eleven / messenger Public

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>
Paul Campbell cdd2fc9fc67ee2a42cbd3b8888edb0aeda284859 parent 96d036d
5 files changed, +179 −8
  • web/pages/index.html +4 −0
  • web/static/apps.js +13 −5
  • web/static/apps/views.js +13 −3
  • web/static/shell.js +126 −0
  • web/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 {