web: group header counts bots separately from members (#87)
The subtitle for a group with two people and three bots used to read "5 members"; it now reads "2 members, 3 bots" (roster is_agent), so a room's bot presence is visible without opening the member list. Bots are omitted when there are none. Also documents the always-use-a-worktree rule for PR work in CLAUDE.md.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+43
−2
CLAUDE.md+3 −0changelog/2026-07-02-header-bot-count.md+3 −0test/ui.test.mjs+16 −0web/static/chat.js+9 −2web/static/ui.js+12 −0
diff --git a/CLAUDE.md b/CLAUDE.md| index 25fd0dd..2477a30 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -138,6 +138,9 @@ Principles that hold regardless of environment: |
| land in parallel, so pushing directly to `main` causes push races and forced |
| rebases. Branch per feature (e.g. `reply-to-messages`), open a PR, and |
| **squash-merge** into `main` (`gh pr merge --squash --delete-branch`). |
| +- **Always do PR work in a git worktree**, not the main checkout. Several |
| + sessions share this checkout, so editing in place trips over concurrent work; |
| + a worktree keeps each PR isolated until it merges. |
| - **Multiple commits per branch are fine** — they squash into one clean commit on |
| `main`, so iterate freely. Within a branch, prefix commits by area: `web:`, |
| `server:`, `admin:`, `sw:`, `crypto:`, `docs:`. |
diff --git a/changelog/2026-07-02-header-bot-count.md b/changelog/2026-07-02-header-bot-count.md| new file mode 100644 |
| index 0000000..b939901 |
| --- /dev/null |
| +++ b/changelog/2026-07-02-header-bot-count.md |
| @@ -0,0 +1,3 @@ |
| +Group headers now count people and bots separately — a group with two people |
| +and a helper bot says "2 members, 1 bot" instead of "3 members", so you can |
| +see at a glance who's actually in the room. |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index adf8711..1c93778 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -12,6 +12,7 @@ import { |
| composerToken, |
| pinnedFirst, |
| ownMessage, |
| + memberCountLabel, |
| DEFAULT_THEME, |
| DEFAULT_APPEARANCE, |
| } from "../web/static/ui.js"; |
| @@ -163,5 +164,20 @@ await test("ownMessage: never matches on empty identity", () => { |
| assert.equal(ownMessage({}, "u1", "Paul"), false); |
| }); |
| +await test("memberCountLabel: people and bots counted separately", () => { |
| + assert.equal(memberCountLabel(2, 3), "2 members, 3 bots"); |
| + assert.equal(memberCountLabel(1, 1), "1 member, 1 bot"); |
| +}); |
| + |
| +await test("memberCountLabel: no bots → members only (no ', 0 bots')", () => { |
| + assert.equal(memberCountLabel(5, 0), "5 members"); |
| + assert.equal(memberCountLabel(1, 0), "1 member"); |
| + assert.equal(memberCountLabel(0, 0), "0 members"); |
| +}); |
| + |
| +await test("memberCountLabel: all bots → bots only (no '0 members')", () => { |
| + assert.equal(memberCountLabel(0, 2), "2 bots"); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/chat.js b/web/static/chat.js| index ad56628..e45011b 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -30,6 +30,7 @@ import { |
| composerToken, |
| pinnedFirst, |
| ownMessage, |
| + memberCountLabel, |
| } from "/static/ui.js"; |
| import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount, youtubeId } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| @@ -1394,8 +1395,14 @@ function paintThreadHeader(id) { |
| const isGroup = t && t.kind === "group"; |
| document.getElementById("thread-title").textContent = threadName[id] || "Chat"; |
| const sub = document.getElementById("thread-sub"); |
| - const n = t ? (t.members || []).length : 0; |
| - sub.textContent = isGroup ? `${n} member${n === 1 ? "" : "s"}` : ""; |
| + // Count people and bots separately (roster is_agent), so the subtitle reads |
| + // "2 members, 3 bots" rather than lumping bots in as members. |
| + const members = t ? t.members || [] : []; |
| + const bots = members.filter((id) => { |
| + const e = roster.find((x) => x.user_id === id); |
| + return e && e.is_agent; |
| + }).length; |
| + sub.textContent = isGroup ? memberCountLabel(members.length - bots, bots) : ""; |
| sub.onclick = isGroup ? () => showMembers() : null; // tap "N members" → member list |
| sub.style.cursor = isGroup ? "pointer" : ""; |
| // The kebab is always available (every chat can be closed); the group-only |
diff --git a/web/static/ui.js b/web/static/ui.js| index 74dec3c..f145e45 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -75,6 +75,18 @@ export function ownMessage(payload, myUserId, myName) { |
| return !!myName && payload.name === myName; |
| } |
| +// ---- group header --------------------------------------------------------- |
| +// The member-count subtitle for a group header, counting people and bots |
| +// separately ("2 members, 3 bots") so a room's bot presence is visible at a |
| +// glance. Bots are omitted when there are none; an all-bot group (people = 0, |
| +// bots > 0) shows just the bot count. Pure, so it's unit-tested in Node. |
| +export function memberCountLabel(people, bots) { |
| + const parts = []; |
| + if (people || !bots) parts.push(`${people} member${people === 1 ? "" : "s"}`); |
| + if (bots) parts.push(`${bots} bot${bots === 1 ? "" : "s"}`); |
| + return parts.join(", "); |
| +} |
| + |
| // ---- chat list ordering -------------------------------------------------- |
| // Stable pinned-first partition of the thread list: pinned chats float to the |
| // top keeping their relative (recency) order, everything else follows in its |