eleven / messenger Public

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>
Paul Campbell 01b22989c36cd390f4ffece2505f5acb80ea5b1d parent 64a8d1e
5 files changed, +43 −2
  • CLAUDE.md +3 −0
  • changelog/2026-07-02-header-bot-count.md +3 −0
  • test/ui.test.mjs +16 −0
  • web/static/chat.js +9 −2
  • web/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