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>
5 files changed,
+319
−3
changelog/2026-08-28-thread-pane.md+4 −1test/apps.test.mjs+77 −1web/static/apps/chat.js+207 −0web/static/shell.js+7 −1web/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, |