eleven / ocho Public

web: a Threads list in the ▤ menu — every thread in this chat, freshest first (#1491)

Threads became good to be IN and left no way to find one: "there's no way to
see a list of threads — it's hard to find a thread if I miss clicking the
notification." So the ▤ Views menu gains a Threads row listing this chat's
threads, one per root, most recently active at the top.

- `threadListItems(msgs)` (pure, exported, Node-tested) derives the rows.
  Membership is `threadRootOf` — the same read the "N Replies" tally makes,
  so a row's count and its root's tally can't disagree, and plain quote
  replies never appear. Each row carries the root's author + snippet, the
  reply count, and the message that last landed in it; rows sort by that
  message with the feed's own comparator, so a pending optimistic reply
  floats its thread to the top exactly as it visibly should. A thread whose
  root has paged out keeps its row, headed by the quoted snapshot its replies
  carry — the same fallback the thread view's header uses.
- `threadListSignature(rows)` (pure, exported, tested) is what the open list
  compares before repainting: the rows are buttons, so a fold that leaves the
  list looking identical must not rebuild them under a keyboard user. On a
  real change, focus returns to the row it was on.
- The list renders as an ordinary side command panel (Memory's column), so
  there is one right dock and tapping a row closes the list as the thread
  arrives — you came to find a thread, you found it. Closed explicitly rather
  than via openThread's own hidePanel, which is gated on the docked
  presentation and would strand the band under a narrow overlay.
- Rows are real buttons: keyboard-operable, visible focus ring, accessible
  names that name the destination ("Open thread, 4 Replies, James: …").
  Counts go through replyCountLabel, so they pluralise and localise like the
  tally and the quote card.
- No unread dot, deliberately. Read state is per CHAT only (`read_marks` /
  shell.js `readMarks` / native `ReadState.lastSeen`); opening the chat
  advances it past every thread reply in it, so a dot derived from that would
  claim "caught up" the moment you glanced at the chat — the exact lie being
  complained about. Per-thread read state is its own feature; half-inventing
  it here would put a wrong dot in front of members.
- The row appears the moment a chat gets its first thread and goes with its
  last (the existing lchat:views-changed repaint, fired only on a real count
  change), and the ▤ button keeps hiding itself when nothing offers a view.

Web only — native gets the list in a later PR. No wire format, no fold
changes, no event-vector changes.


Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev d05ee9a11703077c7be4f0313ea1b1a55b80190c parent d39c309
5 files changed, +319 −3
  • changelog/2026-08-28-thread-pane.md +4 −1
  • test/apps.test.mjs +77 −1
  • web/static/apps/chat.js +207 −0
  • web/static/shell.js +7 −1
  • web/static/style.css +24 −0
diff --git a/changelog/2026-08-28-thread-pane.md b/changelog/2026-08-28-thread-pane.md
index a4b64dd..e09fb77 100644
--- a/changelog/2026-08-28-thread-pane.md
+++ b/changelog/2026-08-28-thread-pane.md
@@ -19,7 +19,10 @@ Threads now open beside the chat on a desktop-sized screen instead of over it.
Tap a message's "N Replies" tally and the thread docks as a column on the
right, with its own reply box at the bottom — so you can follow the main
conversation and the thread at the same time. While a thread is open you'll
-find it under the ▤ Views button in the header. On a phone the thread still
+find it under the ▤ Views button in the header — and that same menu now has a
+"Threads" row listing every thread in the chat, most recently active first, so
+a thread you missed the notification for is one tap away instead of a scroll
+back through the conversation. On a phone the thread still
lifts over the chat the way it always has. Tapping the quote card above a
plain reply jumps you to the message it's quoting; a search hit or link to a
reply made in a thread opens that thread for you.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 93508df..07229f0 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -90,7 +90,7 @@ import { app as dnsimpleApp } from "../web/static/apps/dnsimple.js"; // register
import { app as setupApp } from "../web/static/apps/setup.js"; // the first-run setup conversation's app
import { app as welcomeApp } from "../web/static/apps/welcome.js"; // the joiner welcome conversation's app
import { app as adminApp } from "../web/static/apps/admin.js"; // the admin panel (registers /admin)
-import { app as chatApp, viewItems, threadItems, threadReplyCount, inMainFeed, bySidIndex, newlyDirtyMentionSids, threadLinkCount, replyCountLabel, quoteTarget, threadTarget, jumpReachNote, pageResult, feedNearBottom, feedGlideToBottom, feedCancelGlide } from "../web/static/apps/chat.js"; // the chat app
+import { app as chatApp, viewItems, threadItems, threadReplyCount, threadListItems, threadListSignature, inMainFeed, bySidIndex, newlyDirtyMentionSids, threadLinkCount, replyCountLabel, quoteTarget, threadTarget, jumpReachNote, pageResult, feedNearBottom, feedGlideToBottom, feedCancelGlide } from "../web/static/apps/chat.js"; // the chat app
import { app as vaultApp } from "../web/static/apps/vault.js"; // the Vault app (registers /vault)
import { app as emojiApp, searchEmoji } from "../web/static/apps/emoji.js"; // registers /emoji (the command-panel reference app)
import { app as gifApp, pickRendition, renditionCandidates, gifItems, GIF_MAX_BYTES } from "../web/static/apps/gif.js"; // registers /gif
@@ -1297,6 +1297,82 @@ await test("chat: inMainFeed — a reply made in a thread lives only there (whil
assert.equal(inMainFeed({ sid: 6, reply: { id: 0, name: "", snippet: "", thread: true } }, pending), true, "flagged with no resolvable root shows inline, even mid-send");
});
+await test("chat: threadListItems lists only deliberate threads, newest activity first", () => {
+ const msgs = [
+ { id: "m1", sid: 1, name: "James", text: "trying lots of things", raw: { text: "trying lots of things" }, ts: 1, reply: null, rev: 0 },
+ { id: "m2", sid: 2, name: "Paul", text: "writing pipeline", raw: { text: "writing pipeline" }, ts: 2, reply: null, rev: 0 },
+ { id: "m3", sid: 3, name: "Ciara", text: "the Evan link", raw: { text: "the Evan link" }, ts: 3, reply: null, rev: 0 },
+ // A plain quote reply on m3 — an inline aside. It carries no tally on its
+ // root and no thread to come back to, so it must NOT put m3 in the list.
+ { id: "m4", sid: 4, name: "Paul", text: "nice", raw: { text: "nice" }, ts: 4, reply: { id: 3, name: "Ciara", snippet: "the Evan link" }, rev: 0 },
+ // James's thread: two replies, the older activity.
+ { id: "m5", sid: 5, name: "Paul", text: "which ones?", raw: { text: "which ones?" }, ts: 5, reply: { id: 1, name: "James", snippet: "trying lots of things", thread: true }, rev: 0 },
+ { id: "m6", sid: 6, name: "James", text: "these", raw: { text: "these" }, ts: 6, reply: { id: 1, name: "James", snippet: "trying lots of things", thread: true }, rev: 0 },
+ // Paul's thread: one reply, but the LATEST one in the chat.
+ { id: "m7", sid: 7, name: "Ciara", text: "shipping when?", raw: { text: "shipping when?" }, ts: 7, reply: { id: 2, name: "Paul", snippet: "writing pipeline", thread: true }, rev: 0 },
+ ];
+ const rows = threadListItems(msgs);
+ assert.deepEqual(rows.map((r) => r.rootSid), [2, 1], "sorted by LAST REPLY, not root age — the quote-replied m3 never appears");
+ assert.deepEqual(rows.map((r) => r.count), [1, 2], "each root's tally counts only its thread's replies");
+ assert.deepEqual(rows.map((r) => r.name), ["Paul", "James"], "the row is headed by the ROOT's author, never the replier's");
+ assert.equal(rows[0].snippet, "writing pipeline", "and by the root's own snippet, re-derived from the loaded message");
+ assert.ok(rows.every((r) => r.rootLoaded), "both roots are in the window");
+ assert.deepEqual(rows.map((r) => r.last.sid), [7, 6], "`last` is the newest message in each thread — the sort key");
+ assert.deepEqual(threadListItems([]), [], "an empty window has no threads");
+ assert.deepEqual(threadListItems(msgs.slice(0, 4)), [], "a chat with only quote replies has no threads at all — the ▤ row stays away");
+});
+
+await test("chat: threadListItems keeps a thread whose root is unloaded, headed by the quoted snapshot", () => {
+ // Cold-open a busy chat: the loaded page starts after the root, exactly the
+ // case the thread view's own header falls back for.
+ const page = [
+ { id: "m50", sid: 50, name: "Bo", text: "chatter", raw: { text: "chatter" }, ts: 1, reply: null, rev: 0 },
+ { id: "m51", sid: 51, name: "Cy", text: "re", raw: { text: "re" }, ts: 2, reply: { id: 7, name: "Al", snippet: "old root", thread: true }, rev: 0 },
+ // A second reply into the same thread. Every thread send derives its
+ // target from the ROOT (threadTarget), so both point straight at sid 7 —
+ // one row, not one per reply, even with nothing of the top loaded.
+ { id: "m52", sid: 52, name: "Al", text: "re-re", raw: { text: "re-re" }, ts: 3, reply: { id: 7, name: "Al", snippet: "old root", thread: true }, rev: 0 },
+ ];
+ const rows = threadListItems(page);
+ assert.equal(rows.length, 1, "one thread, not one row per reply");
+ assert.equal(rows[0].rootSid, 7);
+ assert.equal(rows[0].rootLoaded, false, "the row knows it's showing a stand-in");
+ assert.equal(rows[0].name, "Al", "the quoted snapshot's author heads the row");
+ assert.equal(rows[0].snippet, "old root", "…and its snippet — the same fallback the thread view's header uses");
+ assert.equal(rows[0].count, 2, "both replies count");
+ // The root pages in: the row is now headed by the live message.
+ const withRoot = [{ id: "m7", sid: 7, name: "Al", text: "old root, edited", raw: { text: "old root, edited" }, ts: 0, reply: null, rev: 1 }, ...page];
+ const after = threadListItems(withRoot);
+ assert.equal(after[0].rootLoaded, true);
+ assert.equal(after[0].snippet, "old root, edited", "a loaded root's snippet wins over the snapshot the replies carry");
+});
+
+await test("chat: threadListItems floats a thread with a still-pending reply to the top", () => {
+ const msgs = [
+ { id: "m1", sid: 1, name: "Al", text: "a", raw: { text: "a" }, ts: 1, reply: null, rev: 0 },
+ { id: "m2", sid: 2, name: "Bo", text: "b", raw: { text: "b" }, ts: 2, reply: null, rev: 0 },
+ { id: "m9", sid: 9, name: "Cy", text: "re b", raw: { text: "re b" }, ts: 9, reply: { id: 2, name: "Bo", snippet: "b", thread: true }, rev: 0 },
+ // Just sent into a's thread — no sid until the echo lands, but visibly the
+ // newest thing in the chat. bySidPendingLast already says so; the list
+ // must agree, or your own reply would sort your thread to the bottom.
+ { id: "cabc", sid: 0, cid: "abc", pending: true, name: "Me", text: "sending…", raw: { text: "sending…" }, ts: 20, reply: { id: 1, name: "Al", snippet: "a", thread: true }, rev: 0 },
+ ];
+ assert.deepEqual(threadListItems(msgs).map((r) => r.rootSid), [1, 2], "the thread you just replied in is top of the list");
+});
+
+await test("chat: threadListSignature changes exactly when the rendered list would", () => {
+ const rows = [
+ { rootSid: 2, count: 1, name: "Paul", snippet: "writing pipeline" },
+ { rootSid: 1, count: 2, name: "James", snippet: "trying lots" },
+ ];
+ const sig = threadListSignature(rows);
+ assert.equal(threadListSignature(rows.map((r) => ({ ...r }))), sig, "same list, same signature — no repaint, no stolen focus");
+ assert.notEqual(threadListSignature([rows[1], rows[0]]), sig, "a re-sort repaints");
+ assert.notEqual(threadListSignature([{ ...rows[0], count: 2 }, rows[1]]), sig, "a new reply repaints");
+ assert.notEqual(threadListSignature([{ ...rows[0], snippet: "edited" }, rows[1]]), sig, "an edited root (or one that just paged in) repaints");
+ assert.notEqual(threadListSignature(rows.slice(1)), sig, "a thread going away repaints");
+});
+
await test("chat: the chat.msg fold carries the thread flag (default false) and keeps it in raw for edits", () => {
const reduce = chatApp.reduce;
let s = reduce(undefined, { type: "chat.msg", text: "root", name: "Al", uid: "u-al", ts: 1 }, { id: 10 });
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index f414471..4496467 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -335,6 +335,21 @@ export function activeThreadPane(threadId) {
return t && t.isOpen() ? t : null;
}
+// threadListCount / openThreadList: the ▤ Threads row's two questions — is
+// there anything to list in this chat (0 hides the row, and with it the whole
+// menu when nothing else offers a view), and open the list. Kept as plain
+// exports beside activeThreadPane so the shell asks chat.js rather than
+// re-deriving threads from state it doesn't own.
+export function threadListCount(threadId) {
+ const t = threads.get(threadId);
+ return t && t.listCount ? t.listCount() : 0;
+}
+
+export function openThreadList(threadId) {
+ const t = threads.get(threadId);
+ return !!(t && t.openList && t.openList());
+}
+
// Threads whose next render should snap to the newest message even if the reader
// had scrolled up — set when you send from the composer (a reply included), since
// there's no optimistic echo: your message only lands on the server round-trip's
@@ -473,6 +488,63 @@ export function threadItems(msgs, rootSid) {
return out.sort(bySidPendingLast);
}
+// threadListItems: every thread in the loaded window, newest activity first —
+// what the ▤ Threads list shows. One row per thread ROOT: the root's author +
+// snippet (the line you recognise it by), how many replies it holds, and the
+// message that last landed in it.
+//
+// Membership is threadRootOf — the same read the "N Replies" tally makes, so
+// the count on a row and the count on its root can never disagree. Plain
+// quote replies therefore never appear: an inline chain is a conversation
+// already visible in the feed, not a thread anyone opened, and it has no
+// thread to come back to.
+//
+// Ordering is by LAST REPLY, not root age: the list answers "where is the
+// conversation", so this morning's root that nobody answered sits below a
+// week-old one someone replied to a minute ago. `last` is picked (and the
+// rows sorted) with the feed's own comparator, so a still-pending optimistic
+// reply counts as the newest thing in its thread — as it visibly is.
+//
+// A thread whose root has scrolled out of the loaded window keeps its row,
+// headed by the quoted snapshot its replies carry — exactly the fallback the
+// thread view's own header uses (`rootLoaded` says which one a row is).
+// Pure and exported for tests.
+export function threadListItems(msgs) {
+ const bySid = bySidIndex(msgs);
+ const byRoot = new Map(); // root sid → row
+ for (const m of msgs) {
+ const rootSid = threadRootOf(m);
+ if (!rootSid) continue;
+ let row = byRoot.get(rootSid);
+ if (!row) {
+ const root = bySid.get(rootSid);
+ row = {
+ rootSid,
+ rootLoaded: !!root,
+ name: root ? root.name || "" : m.reply.name || "",
+ snippet: root ? replySnippet(root.raw) : m.reply.snippet || "",
+ count: 0,
+ last: m,
+ };
+ byRoot.set(rootSid, row);
+ }
+ row.count++;
+ if (bySidPendingLast(row.last, m) < 0) row.last = m;
+ }
+ return [...byRoot.values()].sort((a, b) => bySidPendingLast(b.last, a.last));
+}
+
+// threadListSignature: everything the rendered list actually shows — order,
+// each row's identity, its tally, and the line it's headed by (which changes
+// when an unloaded root pages in, or when someone edits it). The open panel
+// compares this before repainting, so a fold that leaves the list looking
+// identical never rebuilds its buttons under a keyboard user. Exported for
+// tests: "a repaint happens exactly when the list changed" is a rule worth
+// pinning, not a coincidence of string building.
+export function threadListSignature(rows) {
+ return rows.map((r) => `${r.rootSid}:${r.count}:${r.name}:${r.snippet}`).join("|");
+}
+
// bySidIndex: the loaded window keyed by sid — what inMainFeed checks a thread
// reply's root against. Exported so callers outside viewItems (jump fallback,
// hotkeys, ↑-to-edit, tests) build the same index rather than each re-deriving
@@ -1046,6 +1118,11 @@ export const app = defineApp({
// closed; stashed on the root like _hotkeys so the instant-restore path
// (which skips buildSkeleton) keeps the live mount's view current.
if (root._refreshThread) root._refreshThread();
+ // The ▤ Threads list folds from that same state: a new reply re-sorts it
+ // and moves a count. Repaints only when what it shows actually changed
+ // (its own signature check) — the rows are buttons, and a reader tabbing
+ // them must not lose their place to someone else's message.
+ if (root._refreshThreadList) root._refreshThreadList();
// The in-feed typing row, exactly like the original feed: a .typing element
// kept as the feed's last child (below the newest message). keyedList only
@@ -4531,10 +4608,140 @@ function buildSkeleton(el, ctx) {
});
document.dispatchEvent(new CustomEvent("lchat:views-changed"));
};
+
+ // ---- the ▤ Threads list: this chat's threads, newest activity first ----
+ // The gap the pane left: "there's no way to see a list of threads — it's
+ // hard to find a thread if I miss clicking the notification." A thread only
+ // exists where someone chose it, so a chat has a handful, and they're
+ // findable — if there's somewhere to look. This is that somewhere.
+ //
+ // It rides the SIDE COMMAND PANEL (the docked column Memory and bot views
+ // already use) rather than inventing a surface. That reuse carries one
+ // deliberate consequence: there is a single right dock, so a docked thread
+ // and this list can't both be up. Tapping a row therefore closes the list
+ // as the thread arrives — which is the behaviour we'd want anyway: you came
+ // to find a thread, you found it. We close it here explicitly rather than
+ // leaning on openThread's own hidePanel(), because that one is gated on the
+ // DOCKED presentation — on a narrow screen the thread lifts as an overlay
+ // and would otherwise leave the list's bottom band stranded beneath it.
+ //
+ // NO UNREAD DOT, deliberately. Read state in this product is per CHAT and
+ // nothing else: one `read_marks(user_id, thread_id, last_read_at)` watermark
+ // server-side, `readMarks` in shell.js, `ReadState.lastSeen` natively. Opening
+ // the chat advances it past every thread reply in it — thread replies are
+ // ordinary messages, so they move the chat's `last_at` even though the
+ // transcript never shows them. A dot derived from that would read "all
+ // caught up" the instant you glanced at the chat, which is exactly the lie
+ // Paul is complaining about. Per-thread read state is a real feature (a
+ // watermark per root, synced like the chat one); half-inventing it here
+ // would put a wrong dot in front of members. Rows carry activity order
+ // instead — the freshest thread is simply top.
+ let threadListBody = null; // the open panel's body element (null = closed)
+ let threadListSig = ""; // what that body last painted — see the fold hook
+
+ const paintThreadList = (el) => {
+ const rows = threadListItems(((ctx.getState() || {}).msgs) || []);
+ threadListSig = threadListSignature(rows);
+ el.replaceChildren();
+ if (!rows.length) {
+ // Only reachable with the list already open (the ▤ row hides at zero) —
+ // every thread's replies were deleted while you were looking at them.
+ const p = document.createElement("p");
+ p.className = "thread-list-empty";
+ p.textContent = "No threads in this chat. Choose “Reply in thread” on a message to start one.";
+ el.appendChild(p);
+ return;
+ }
+ const list = document.createElement("div");
+ list.className = "thread-list";
+ for (const r of rows) {
+ const label = replyCountLabel(r.count); // the same string the root's tally wears
+ const who = r.name ? r.name + ": " : "";
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "thread-list-row";
+ b.dataset.root = String(r.rootSid); // how a repaint puts focus back on your row
+ const line = document.createElement("span");
+ line.className = "thread-list-line";
+ const nameEl = document.createElement("span");
+ nameEl.className = "thread-list-who";
+ nameEl.textContent = who;
+ const snip = document.createElement("span");
+ snip.className = "thread-list-snippet";
+ snip.textContent = r.snippet || "";
+ line.append(nameEl, snip);
+ const count = document.createElement("span");
+ count.className = "thread-list-count";
+ count.textContent = label;
+ b.append(line, count);
+ // The accessible name says what the row DOES ("Open thread") before the
+ // size and the line, so a screen reader announces a destination rather
+ // than a fragment of someone's sentence. Both visible strings are
+ // carried through verbatim, so voice control still matches on the words
+ // a sighted user can see (WCAG 2.5.3).
+ b.setAttribute("aria-label", `Open thread, ${label}, ${who}${r.snippet || ""}`);
+ b.onclick = () => {
+ hidePanel(); // one right dock — see the note above
+ // No opener worth recording: the row dies with the list, so focus
+ // return falls to closeThread's main-composer fallback.
+ openThread(r.rootSid, { id: r.rootSid, name: r.name, snippet: r.snippet }, null);
+ };
+ list.appendChild(b);
+ }
+ el.appendChild(list);
+ };
+
+ // Two things follow the fold. (1) The ▤ menu's Threads row appears the
+ // moment this chat gets its first thread and goes when its last one does —
+ // so a thread someone else starts while you're reading surfaces the row
+ // without a reload. That's the same repaint signal the pane's own row uses,
+ // fired only on a real count change (a menu repaint per message would be
+ // silly). (2) The open list itself repaints — a new reply re-sorts the rows
+ // and moves a count — but only when what it SHOWS changed, because the rows
+ // are BUTTONS: rebuilding them on every fold would yank focus out from under
+ // someone tabbing the list. When a real change does land, focus goes back on
+ // the row it was on (same root), so a live thread can't steal the keyboard.
+ // Starts at 0 — the state the menu is already painted in when a chat opens,
+ // so a chat with no threads never fires a pointless repaint on first render.
+ let threadListSeen = 0; // last count the ▤ menu was told about
+ root._refreshThreadList = () => {
+ const rows = threadListItems(((ctx.getState() || {}).msgs) || []);
+ if (rows.length !== threadListSeen) {
+ threadListSeen = rows.length;
+ document.dispatchEvent(new CustomEvent("lchat:views-changed"));
+ }
+ if (!threadListBody || !threadListBody.isConnected) return;
+ if (threadListSignature(rows) === threadListSig) return;
+ const active = document.activeElement;
+ const keep = active && threadListBody.contains(active) ? active.dataset.root : "";
+ paintThreadList(threadListBody);
+ if (keep) {
+ const back = threadListBody.querySelector(`.thread-list-row[data-root="${CSS.escape(keep)}"]`);
+ if (back) back.focus();
+ }
+ };
+
threads.set(ctx.threadId, {
open: openThread,
close: closeThread,
isOpen: () => !!threadSid,
+ // How many threads this chat has right now — the ▤ row's gate.
+ listCount: () => threadListItems(((ctx.getState() || {}).msgs) || []).length,
+ openList: () => {
+ showPanel({
+ title: "Threads",
+ position: "side",
+ render: (el) => {
+ threadListBody = el;
+ paintThreadList(el);
+ },
+ onClose: () => {
+ threadListBody = null;
+ threadListSig = "";
+ },
+ });
+ return true;
+ },
// The docked pane's composer api, while one is mounted — how setConnected
// (and PR 3's composerFor routing) reach the second composer.
paneComposer: () => (threadDocked && paneBlock ? paneBlock.api : null),
diff --git a/web/static/shell.js b/web/static/shell.js
index 199bf1c..0084f05 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -146,7 +146,7 @@ import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admi
import "/static/apps/webhooks.js"; // incoming-webhooks management (admin-only; /webhooks — docs/webhooks.md)
import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/member-apps.md)
import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-memory scratchpad (docs/eleven-memory.md)
-import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf, activeThreadPane } from "/static/apps/chat.js";
+import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf, activeThreadPane, threadListCount, openThreadList } from "/static/apps/chat.js";
import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js";
import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
import { INVITER_HINT_KEY, readInviterHint, inviterHintTarget } from "/static/invitehint.js";
@@ -3770,6 +3770,12 @@ function viewsMenuEntries(id) {
},
});
}
+ // …then the list of this chat's OTHER threads (chat.js's threadListItems,
+ // rendered as a side panel there): the view you're in comes before the
+ // index of the rest. Absent when the chat has no threads, so a chat where
+ // nobody has ever used "Reply in thread" is exactly as it was — and the ▤
+ // button stays hidden unless something else offers a view.
+ if (threadListCount(id)) entries.push({ svg: ICON_THREAD, label: "Threads", open: () => openThreadList(id) });
if (threadHasAIPeer(id)) entries.push({ svg: ICON_BRAIN, label: "Memory", open: () => openMemoryPanel(id) });
const st = appStateFor(id, "views");
const views = st && Array.isArray(st.views) ? st.views : [];
diff --git a/web/static/style.css b/web/static/style.css
index 131f6bf..4b7a280 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1505,6 +1505,30 @@ select:disabled { opacity: 0.5; cursor: default; }
.chatapp-thread-panel--docked .bubble { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
}
+/* ---- the ▤ Threads list (apps/chat.js threadListItems): one row per thread
+ in this chat, rendered into an ordinary side command panel. Rows are real
+ buttons — keyboard-reachable, with a visible ring — and the tally sits in
+ its own column so the numbers line up down the list rather than trailing
+ ragged after snippets of every length. */
+.thread-list { display: flex; flex-direction: column; gap: 2px; }
+.thread-list-empty { margin: 4px 0; font-size: 13px; color: var(--muted); line-height: 1.45; }
+.thread-list-row {
+ display: flex; align-items: baseline; gap: 10px; width: 100%;
+ min-height: 34px; padding: 7px 10px;
+ border: none; border-radius: 10px; background: none;
+ color: var(--ink); font: inherit; text-align: left; cursor: pointer;
+}
+.thread-list-row:hover { background: var(--bubble-them); }
+.thread-list-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; background: var(--bubble-them); }
+/* One line per row: the author's name leads, the snippet trails off. */
+.thread-list-line {
+ flex: 1 1 auto; min-width: 0; font-size: 13px; line-height: 1.35;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.thread-list-who { font-weight: 600; }
+.thread-list-snippet { color: var(--muted); }
+.thread-list-count { flex: 0 0 auto; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
+
/* composer "Replying to …" / "Editing message" banners, above the composer
(and any attachment preview). They share the .reply-bar inner markup. */
.composer-reply,