eleven / messenger Public

Chat UI: split helper count, badge DM rows, center AI/BOT badges (#137)

Client-side follow-ups to the AI/BOT helper split (#133): the group header count
now splits by kind ("N members, M bots, K AI agents"), DM rows in the chat index
carry the helper's AI/BOT badge, and every badge is vertically centered with its
name (flex name lines, replacing a fixed baseline nudge).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell dadf01f9bcf435fd53fac49243ff22fa69eb5ef1 parent 4329267
4 files changed, +71 −30
  • test/ui.test.mjs +13 −9
  • web/static/chat.js +49 −18
  • web/static/style.css +2 −1
  • web/static/ui.js +7 −2
diff --git a/test/ui.test.mjs b/test/ui.test.mjs
index 55b3a4b..62057d0 100644
--- a/test/ui.test.mjs
+++ b/test/ui.test.mjs
@@ -188,19 +188,23 @@ await test("sanitizeActions: caps the count and tolerates garbage", () => {
assert.equal(longLabel[0].label.length, 40);
});
-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: people, bots, and AI agents counted separately", () => {
+ assert.equal(memberCountLabel(2, 2, 1), "2 members, 2 bots, 1 AI agent");
+ assert.equal(memberCountLabel(1, 1, 2), "1 member, 1 bot, 2 AI agents");
+ assert.equal(memberCountLabel(2, 3), "2 members, 3 bots"); // agents defaults to 0
});
-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: zero categories are dropped", () => {
+ assert.equal(memberCountLabel(5, 0, 0), "5 members");
+ assert.equal(memberCountLabel(1, 0, 0), "1 member");
+ assert.equal(memberCountLabel(0, 0, 0), "0 members");
+ assert.equal(memberCountLabel(2, 0, 1), "2 members, 1 AI agent"); // bots dropped, agents kept
});
-await test("memberCountLabel: all bots → bots only (no '0 members')", () => {
- assert.equal(memberCountLabel(0, 2), "2 bots");
+await test("memberCountLabel: all helpers → no '0 members'", () => {
+ assert.equal(memberCountLabel(0, 2, 0), "2 bots");
+ assert.equal(memberCountLabel(0, 0, 3), "3 AI agents");
+ assert.equal(memberCountLabel(0, 1, 1), "1 bot, 1 AI agent");
});
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
diff --git a/web/static/chat.js b/web/static/chat.js
index 5a6e6c6..55e4211 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -663,8 +663,11 @@ function agentTag(muted, kind) {
t.title = ai
? "An AI helper — what you send it is sent to an AI provider to answer"
: "An automated helper (bot), not a person — it runs on this server";
+ // The chip is centered by its flex parent (each name line is a flex row with
+ // align-items:center — see the call sites), so it needs no baseline nudge;
+ // flex:none keeps it from shrinking, line-height:1 keeps the box tight.
t.style.cssText =
- "margin-left:6px;font-size:0.7em;font-weight:600;letter-spacing:0.03em;padding:1px 5px;border-radius:6px;vertical-align:middle;transform:translateY(-1px);" +
+ "flex:none;margin-left:6px;font-size:0.7em;line-height:1;font-weight:600;letter-spacing:0.03em;padding:2px 5px;border-radius:6px;vertical-align:middle;" +
(muted ? "background:var(--border,#ddd);color:var(--muted,#666);" : "background:var(--accent,#888);color:#fff;");
return t;
}
@@ -673,15 +676,19 @@ function agentTag(muted, kind) {
// one-line description (the Helpers catalog blurb — the "what am I adding?" hint).
function pickerName(e) {
const nm = document.createElement("span");
- nm.textContent = e.label;
- if (e.is_agent) {
- nm.appendChild(agentTag(false, e.kind));
- if (e.description) {
- const d = document.createElement("span");
- d.className = "helper-desc";
- d.textContent = e.description;
- nm.appendChild(d);
- }
+ // Name + badge on a centered flex line; the description (if any) sits below.
+ const line = document.createElement("span");
+ line.style.cssText = "display:flex;align-items:center;";
+ const txt = document.createElement("span");
+ txt.textContent = e.label;
+ line.appendChild(txt);
+ if (e.is_agent) line.appendChild(agentTag(false, e.kind));
+ nm.appendChild(line);
+ if (e.is_agent && e.description) {
+ const d = document.createElement("span");
+ d.className = "helper-desc";
+ d.textContent = e.description;
+ nm.appendChild(d);
}
return nm;
}
@@ -1427,7 +1434,19 @@ function renderThreadList() {
body.className = "thread-row-body";
const name = document.createElement("span");
name.className = "thread-row-name";
- name.textContent = threadName[t.id] || "Chat";
+ // Text in its own span so it keeps ellipsis-truncating while the row is a
+ // flex line (which vertically centers the name with any badge).
+ const nameText = document.createElement("span");
+ nameText.className = "thread-row-name-text";
+ nameText.textContent = threadName[t.id] || "Chat";
+ name.appendChild(nameText);
+ // Badge a DM whose other member is a helper, so the chat index shows AI/BOT
+ // like the picker and message senders do (muted — it repeats per row).
+ if (t.kind === "dm") {
+ const other = (t.members || []).find((id) => id !== myUserId);
+ const e = roster.find((x) => x.user_id === other);
+ if (e && e.is_agent) name.appendChild(agentTag(true, e.kind));
+ }
body.appendChild(name);
if (threadPreview[t.id]) {
const prev = document.createElement("span");
@@ -1682,14 +1701,19 @@ function paintThreadHeader(id) {
const isGroup = t && t.kind === "group";
document.getElementById("thread-title").textContent = threadName[id] || "Chat";
const sub = document.getElementById("thread-sub");
- // Count people and bots separately (roster is_agent), so the subtitle reads
- // "2 members, 3 bots" rather than lumping bots in as members.
+ // Count people, deterministic bots, and AI agents separately (roster is_agent
+ // + kind), so the subtitle reads "2 members, 2 bots, 1 AI agent" — mirroring
+ // the BOT/AI badges rather than lumping helpers in as members.
const members = t ? t.members || [] : [];
- const bots = members.filter((id) => {
+ let bots = 0;
+ let agents = 0;
+ for (const id of members) {
const e = roster.find((x) => x.user_id === id);
- return e && e.is_agent;
- }).length;
- sub.textContent = isGroup ? memberCountLabel(members.length - bots, bots) : "";
+ if (!e || !e.is_agent) continue;
+ if (e.kind === "ai") agents++;
+ else bots++;
+ }
+ sub.textContent = isGroup ? memberCountLabel(members.length - bots - agents, bots, agents) : "";
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
@@ -1914,6 +1938,8 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache
if (!mine && t && t.kind === "group") {
const nm = document.createElement("div");
nm.className = "name";
+ nm.style.display = "flex"; // center the name text with its AI/BOT badge
+ nm.style.alignItems = "center";
nm.textContent = data.name || "";
// Badge bot senders on the name label itself, so "who's a bot" is visible
// on every message, not just in the picker. Sender names aren't signed, so
@@ -4084,7 +4110,12 @@ function showMembers() {
main.className = "member-main";
main.appendChild(avatarEl(name, () => avatarFor(uid)));
const lbl = document.createElement("span");
- lbl.textContent = me ? `${name} (you)` : name;
+ lbl.style.display = "flex"; // center the name with its badge
+ lbl.style.alignItems = "center";
+ const lblText = document.createElement("span");
+ lblText.style.minWidth = "0"; // keep ellipsis truncation inside the flex line
+ lblText.textContent = me ? `${name} (you)` : name;
+ lbl.appendChild(lblText);
// Transparency: a bot in the room stays visibly a bot, not just in the picker.
const entry = roster.find((x) => x.user_id === uid);
if (entry && entry.is_agent) lbl.appendChild(agentTag(false, entry.kind));
diff --git a/web/static/style.css b/web/static/style.css
index 8c267e2..33aca73 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1299,7 +1299,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
header/profile/reaction avatars keep their sizes. */
.thread-row .avatar { width: 52px; height: 52px; font-size: 21px; }
.thread-row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
-.thread-row-name { font-size: 16px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.thread-row-name { font-size: 16px; font-weight: 600; display: flex; align-items: center; min-width: 0; }
+.thread-row-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Up to two lines of the latest message, then ellipsize (the spacious look).
white-space:normal undoes the nowrap inherited from the global `button` rule
(.thread-row is a <button>) — without it the line-clamp can't wrap and the
diff --git a/web/static/ui.js b/web/static/ui.js
index eb6f748..8b7a330 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -80,10 +80,15 @@ export function ownMessage(payload, myUserId, myName) {
// 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) {
+// Subtitle count, split by helper kind so it mirrors the badges: people are
+// "member(s)", deterministic helpers "bot(s)" (BOT badge), LLM-backed ones
+// "AI agent(s)" (AI badge). Zero categories are dropped; "0 members" shows only
+// when a group has no helpers at all.
+export function memberCountLabel(people, bots, agents = 0) {
const parts = [];
- if (people || !bots) parts.push(`${people} member${people === 1 ? "" : "s"}`);
+ if (people || (!bots && !agents)) parts.push(`${people} member${people === 1 ? "" : "s"}`);
if (bots) parts.push(`${bots} bot${bots === 1 ? "" : "s"}`);
+ if (agents) parts.push(`${agents} AI agent${agents === 1 ? "" : "s"}`);
return parts.join(", ");
}