web+server+admin: Helpers catalog — members can discover admin-vetted bots (#50)
Under the connections-only roster (#43) a new bot shares no thread, so it was invisible to members. Per docs/bots.md, agents the admin marks available_to_members are the one exemption from the connections rule: - bot_configs gains description + available_to_members (additive ALTERs); the admin "Add an agent" form sets both (availability default-on there). - connections() unions in available, joined (keyed) agents, and roster entries carry the admin's one-line description for agents. - Pickers (new chat / new group / add people) split People vs a "Helpers" section, with the blurb on each helper row as the consent hint; the members list now badges bots too. addPeople also skips keyless entries (pending invitees have no pubkey to wrap the thread key to). - Catalog metadata stays in clear, consistent with roster labels (open decision in docs/bots.md resolved; noted there).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+257
−34
bots_test.go+140 −0db.go+33 −8docs/bots.md+9 −10main.go+6 −2web/pages/admin.html+5 −0web/static/admin.js+4 −0web/static/chat.js+54 −14web/static/style.css+6 −0
diff --git a/bots_test.go b/bots_test.go| new file mode 100644 |
| index 0000000..8dffaf6 |
| --- /dev/null |
| +++ b/bots_test.go |
| @@ -0,0 +1,140 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| +) |
| + |
| +// The Helpers catalog (docs/bots.md): agents the admin marked available appear in |
| +// EVERY member's connections — the one exemption from the connections-only roster, |
| +// so a new bot is discoverable before anyone shares a thread with it. |
| +func TestHelpersCatalogInConnections(t *testing.T) { |
| + db := newTestDB(t) |
| + mk := func(name string) string { |
| + id, err := db.createUser(name) |
| + if err != nil { |
| + t.Fatalf("createUser %s: %v", name, err) |
| + } |
| + if err := db.setUserPublicKey(id, "pub-"+name); err != nil { |
| + t.Fatalf("setUserPublicKey %s: %v", name, err) |
| + } |
| + return id |
| + } |
| + paul := mk("Paul") |
| + bot := mk("Sunny") |
| + if err := db.setUserAgent(bot); err != nil { |
| + t.Fatalf("setUserAgent: %v", err) |
| + } |
| + if err := db.setBotConfig(bot, "", "", "", "Answers questions and summarises chats", true); err != nil { |
| + t.Fatalf("setBotConfig: %v", err) |
| + } |
| + |
| + find := func(conns []RosterEntry, id string) *RosterEntry { |
| + for i := range conns { |
| + if conns[i].UserID == id { |
| + return &conns[i] |
| + } |
| + } |
| + return nil |
| + } |
| + |
| + // Paul shares no thread with the bot, yet it's in his roster — with the badge |
| + // and the admin's blurb. |
| + conns, err := db.connections(paul) |
| + if err != nil { |
| + t.Fatalf("connections: %v", err) |
| + } |
| + e := find(conns, bot) |
| + if e == nil { |
| + t.Fatalf("an available bot should appear in every member's connections: %+v", conns) |
| + } |
| + if !e.IsAgent || e.Description != "Answers questions and summarises chats" { |
| + t.Fatalf("catalog entry should carry is_agent + description: %+v", e) |
| + } |
| + |
| + // Admin flips availability off → the bot drops back out (still no shared thread). |
| + if err := db.setBotConfig(bot, "", "", "", "Answers questions and summarises chats", false); err != nil { |
| + t.Fatalf("setBotConfig off: %v", err) |
| + } |
| + conns, _ = db.connections(paul) |
| + if find(conns, bot) != nil { |
| + t.Fatal("an unavailable bot must NOT be visible without a shared thread") |
| + } |
| + |
| + // A keyless bot (invite minted, sidecar hasn't claimed it) never shows, even |
| + // when available — there's no public key to wrap a thread key to. |
| + ghost, err := db.createUser("Ghost") |
| + if err != nil { |
| + t.Fatalf("createUser ghost: %v", err) |
| + } |
| + if err := db.setUserAgent(ghost); err != nil { |
| + t.Fatalf("setUserAgent ghost: %v", err) |
| + } |
| + if err := db.setBotConfig(ghost, "", "", "", "not joined yet", true); err != nil { |
| + t.Fatalf("setBotConfig ghost: %v", err) |
| + } |
| + conns, _ = db.connections(paul) |
| + if find(conns, ghost) != nil { |
| + t.Fatal("a keyless (unclaimed) bot must not appear in the catalog") |
| + } |
| +} |
| + |
| +// End to end over HTTP: the admin's "Add an agent" carries the catalog metadata, |
| +// and once the sidecar joins, a stranger member sees the helper in /api/roster. |
| +func TestHelpersCatalogHTTP(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + // A member with no connection to the bot. |
| + paul, _ := srv.db.createUser("Paul") |
| + srv.db.createKeyInvite("inv-paul", paul, "", 0) |
| + tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "") |
| + srv.db.setUserPublicKey(paul, "pub-paul") |
| + |
| + // Admin mints an agent invite with a description, shown to members. |
| + r := adminPost(t, ts.URL+"/api/admin/key-invites", |
| + `{"label":"Sunny","id":"inv-bot","wrapped":"","agent":true, |
| + "model":"","system_prompt":"","docs":"", |
| + "description":"A friendly helper","available_to_members":true}`) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("create agent invite: got %d", r.StatusCode) |
| + } |
| + |
| + // Not joined yet → not in anyone's roster. |
| + fetchRoster := func() []RosterEntry { |
| + rr, _ := http.Get(ts.URL + "/api/roster?token=" + tok) |
| + var out struct { |
| + Roster []RosterEntry `json:"roster"` |
| + } |
| + json.NewDecoder(rr.Body).Decode(&out) |
| + return out.Roster |
| + } |
| + for _, e := range fetchRoster() { |
| + if e.Label == "Sunny" { |
| + t.Fatal("unclaimed bot should not be in the roster yet") |
| + } |
| + } |
| + |
| + // The sidecar claims the invite and registers its key (as agent/provision.mjs |
| + // does) → the helper appears for the unconnected member, badge + blurb intact. |
| + botTok, ok, err := srv.db.claimKeyInvite("inv-bot", "Sunny", "") |
| + if err != nil || !ok { |
| + t.Fatalf("bot claim: ok=%v err=%v", ok, err) |
| + } |
| + botID, _ := srv.db.deviceTokenUser(botTok) |
| + srv.db.setUserPublicKey(botID, "pub-sunny") |
| + |
| + var helper *RosterEntry |
| + roster := fetchRoster() |
| + for i := range roster { |
| + if roster[i].UserID == botID { |
| + helper = &roster[i] |
| + } |
| + } |
| + if helper == nil { |
| + t.Fatalf("joined available bot should be in a stranger's roster: %+v", roster) |
| + } |
| + if !helper.IsAgent || helper.Description != "A friendly helper" || helper.PublicKey != "pub-sunny" { |
| + t.Fatalf("helper entry should carry badge, blurb and key: %+v", helper) |
| + } |
| +} |
diff --git a/db.go b/db.go| index ff06c6b..b92504b 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -215,6 +215,11 @@ CREATE TABLE IF NOT EXISTS bot_configs ( |
| // Bot flag: a user driven by an agent (see agent/), not a person. Only |
| // used to badge them in the UI — it grants no privilege. |
| "ALTER TABLE users ADD COLUMN is_agent INTEGER NOT NULL DEFAULT 0", |
| + // Helpers catalog (docs/bots.md): admin-authored one-line description shown |
| + // to members, and the "show in members' Helpers list" flag. Catalog metadata |
| + // like the roster label — in clear, never message content. |
| + "ALTER TABLE bot_configs ADD COLUMN description TEXT NOT NULL DEFAULT ''", |
| + "ALTER TABLE bot_configs ADD COLUMN available_to_members INTEGER NOT NULL DEFAULT 0", |
| } { |
| if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -684,6 +689,9 @@ type RosterEntry struct { |
| PublicKey string `json:"public_key"` |
| Avatar string `json:"avatar,omitempty"` |
| IsAgent bool `json:"is_agent,omitempty"` // an agent/bot, not a person |
| + // One-line admin-authored blurb for agents (the Helpers catalog entry — |
| + // doubles as the consent hint). Always "" for people. |
| + Description string `json:"description,omitempty"` |
| } |
| func (db *DB) setUserPublicKey(userID, pub string) error { |
| @@ -827,11 +835,16 @@ type BotConfig struct { |
| Docs string `json:"docs"` |
| } |
| -func (db *DB) setBotConfig(userID, model, systemPrompt, docs string) error { |
| +func (db *DB) setBotConfig(userID, model, systemPrompt, docs, description string, available bool) error { |
| + av := 0 |
| + if available { |
| + av = 1 |
| + } |
| _, err := db.sql.Exec( |
| - `INSERT INTO bot_configs(user_id, model, system_prompt, docs, updated_at) VALUES(?, ?, ?, ?, ?) |
| - ON CONFLICT(user_id) DO UPDATE SET model = excluded.model, system_prompt = excluded.system_prompt, docs = excluded.docs, updated_at = excluded.updated_at`, |
| - userID, model, systemPrompt, docs, time.Now().Unix(), |
| + `INSERT INTO bot_configs(user_id, model, system_prompt, docs, description, available_to_members, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?) |
| + ON CONFLICT(user_id) DO UPDATE SET model = excluded.model, system_prompt = excluded.system_prompt, docs = excluded.docs, |
| + description = excluded.description, available_to_members = excluded.available_to_members, updated_at = excluded.updated_at`, |
| + userID, model, systemPrompt, docs, description, av, time.Now().Unix(), |
| ) |
| return err |
| } |
| @@ -861,7 +874,9 @@ func (db *DB) setAvatar(userID, blobID string) error { |
| // they share a thread with). Kept for admin/back-end use and tests. |
| func (db *DB) roster() ([]RosterEntry, error) { |
| rows, err := db.sql.Query( |
| - "SELECT id, label, public_key, COALESCE(avatar_blob, ''), COALESCE(is_agent, 0) FROM users WHERE public_key IS NOT NULL AND public_key <> '' ORDER BY label", |
| + `SELECT u.id, u.label, u.public_key, COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '') |
| + FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| + WHERE u.public_key IS NOT NULL AND u.public_key <> '' ORDER BY u.label`, |
| ) |
| if err != nil { |
| return nil, err |
| @@ -875,16 +890,23 @@ func (db *DB) roster() ([]RosterEntry, error) { |
| // connected to, or by inviting someone new. Keyless people (a pending invitee who |
| // hasn't joined yet) are included so a pending DM can still show their name; the |
| // client omits keyless entries from the "start a new chat" picker. |
| +// |
| +// The one exemption is the Helpers catalog (docs/bots.md): agents the admin has |
| +// marked available_to_members are shown to everyone — bots share no thread when |
| +// new, so they can't ride the connections rule for discovery. People stay |
| +// connections-only; only vetted, clearly-badged helpers are instance-visible. |
| func (db *DB) connections(userID string) ([]RosterEntry, error) { |
| rows, err := db.sql.Query(` |
| - SELECT u.id, u.label, COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0) |
| - FROM users u |
| + SELECT u.id, u.label, COALESCE(u.public_key, ''), COALESCE(u.avatar_blob, ''), COALESCE(u.is_agent, 0), COALESCE(b.description, '') |
| + FROM users u LEFT JOIN bot_configs b ON b.user_id = u.id |
| WHERE u.id = ? |
| OR u.id IN ( |
| SELECT m2.user_id FROM thread_members m1 |
| JOIN thread_members m2 ON m2.thread_id = m1.thread_id |
| WHERE m1.user_id = ? AND m2.user_id <> ? |
| ) |
| + OR (COALESCE(u.is_agent, 0) = 1 AND COALESCE(b.available_to_members, 0) = 1 |
| + AND u.public_key IS NOT NULL AND u.public_key <> '') |
| ORDER BY u.label`, userID, userID, userID) |
| if err != nil { |
| return nil, err |
| @@ -898,10 +920,13 @@ func scanRoster(rows *sql.Rows) ([]RosterEntry, error) { |
| for rows.Next() { |
| var e RosterEntry |
| var isAgent int |
| - if err := rows.Scan(&e.UserID, &e.Label, &e.PublicKey, &e.Avatar, &isAgent); err != nil { |
| + if err := rows.Scan(&e.UserID, &e.Label, &e.PublicKey, &e.Avatar, &isAgent, &e.Description); err != nil { |
| return nil, err |
| } |
| e.IsAgent = isAgent != 0 |
| + if !e.IsAgent { |
| + e.Description = "" // description is Helpers-catalog metadata, agents only |
| + } |
| out = append(out, e) |
| } |
| return out, rows.Err() |
diff --git a/docs/bots.md b/docs/bots.md| index b20951d..aa366cd 100644 |
| --- a/docs/bots.md |
| +++ b/docs/bots.md |
| @@ -286,16 +286,15 @@ groups**: the prominent affordance is "Add a helper" in the group create / |
| add-member flow (where the value is); "DM a helper" is the secondary sandbox |
| lane. Show only bots the admin marked available (see governance). |
| -Server shape: a catalog query `is_agent = 1 AND available_to_members = 1` with a |
| -`description`. **Open decision:** bot name/description are admin-authored catalog |
| -metadata — store consistent with how the instance stores roster labels (in clear |
| -today), or encrypt to members via the instance key if labels are to be kept from |
| -the server. |
| - |
| -**Integration note:** the `is_agent` badge is on `agent-sidecar` (off `main`); |
| -the connections model is on `worktree-user-invites-and-connections`. When they |
| -converge, move the `is_agent` SELECT/scan onto `connections()` / `scanRoster`, |
| -and add the catalog surface — it's new, not a tweak to the roster. |
| +**Built (helpers-catalog):** `bot_configs` gained `description` + |
| +`available_to_members` (set from the admin's "Add an agent" form, availability |
| +default-on there, off in the schema). `connections()` unions in agents matching |
| +`is_agent = 1 AND available_to_members = 1` that have registered a public key — |
| +that one exemption *is* the catalog; the client just renders roster entries with |
| +`is_agent` under a "Helpers" heading (new-chat and add-to-group pickers), with |
| +the description as the row's second line. The open decision on name/description |
| +storage was resolved toward **in clear**, consistent with roster labels today — |
| +revisit both together if labels ever move under the instance key. |
| ## Governance |
diff --git a/main.go b/main.go| index 1ead4e4..1d34449 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -1069,6 +1069,10 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| Model string `json:"model"` |
| SystemPrompt string `json:"system_prompt"` |
| Docs string `json:"docs"` |
| + // Helpers catalog metadata (only when Agent): the one-line blurb members |
| + // see, and whether the bot shows in everyone's Helpers list (docs/bots.md). |
| + Description string `json:"description"` |
| + Available bool `json:"available_to_members"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -1077,7 +1081,7 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| // Wrapped is optional now: a fresh-person invite carries no key (the joiner |
| // generates their own user keypair). Only an add-device invite wraps the |
| // user's private key. See docs/threads-and-dms.md. |
| - if body.ID == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 { |
| + if body.ID == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 || len(body.Description) > 300 { |
| httpError(w, http.StatusBadRequest, "missing id") |
| return |
| } |
| @@ -1098,7 +1102,7 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| - if err := s.db.setBotConfig(userID, body.Model, body.SystemPrompt, body.Docs); err != nil { |
| + if err := s.db.setBotConfig(userID, body.Model, body.SystemPrompt, body.Docs, strings.TrimSpace(body.Description), body.Available); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
diff --git a/web/pages/admin.html b/web/pages/admin.html| index ad97285..fdb7bfd 100644 |
| --- a/web/pages/admin.html |
| +++ b/web/pages/admin.html |
| @@ -67,6 +67,7 @@ |
| <p class="sub" style="margin:0 0 8px">Adds an AI bot as a member. This mints a one-time link the agent sidecar uses to set up the bot's identity; it then appears in chats as a member with a BOT badge. The sidecar for this instance must be running.</p> |
| <form id="agentForm" autocomplete="off"> |
| <input id="agentLabel" placeholder="Bot name (e.g. Claude)" maxlength="60" style="display:block;width:100%;margin:0 0 8px" /> |
| + <input id="agentDescription" placeholder="What this helper does, in one line — members see this in the Helpers list" maxlength="200" style="display:block;width:100%;margin:0 0 8px" /> |
| <select id="agentModel" style="display:block;width:100%;margin:0 0 8px"> |
| <option value="">Default model (Opus)</option> |
| <option value="claude-opus-4-8">Opus — most capable</option> |
| @@ -75,6 +76,10 @@ |
| </select> |
| <textarea id="agentPrompt" rows="4" placeholder="Personality / instructions (system prompt). Leave blank for a friendly default. A safety + privacy preamble is always applied on top." style="display:block;width:100%;margin:0 0 8px"></textarea> |
| <textarea id="agentDocs" rows="3" placeholder="Reference the bot should always know — house rules, an FAQ, etc. Optional." style="display:block;width:100%;margin:0 0 8px"></textarea> |
| + <label class="toggle-row" style="margin:0 0 8px"> |
| + <input type="checkbox" id="agentAvailable" checked /> |
| + <span>Show in everyone's Helpers list (untick to add it to chats yourself)</span> |
| + </label> |
| <button type="submit">Add agent</button> |
| </form> |
| </section> |
diff --git a/web/static/admin.js b/web/static/admin.js| index f234c57..93b130d 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -113,13 +113,17 @@ function wireCreateAgent() { |
| model: document.getElementById("agentModel").value, |
| system_prompt: document.getElementById("agentPrompt").value.trim(), |
| docs: document.getElementById("agentDocs").value.trim(), |
| + description: document.getElementById("agentDescription").value.trim(), |
| + available_to_members: document.getElementById("agentAvailable").checked, |
| }; |
| const minted = await mintInvite(name, null, true, config); |
| if (!minted) return; |
| document.getElementById("agentLabel").value = ""; |
| + document.getElementById("agentDescription").value = ""; |
| document.getElementById("agentModel").value = ""; |
| document.getElementById("agentPrompt").value = ""; |
| document.getElementById("agentDocs").value = ""; |
| + document.getElementById("agentAvailable").checked = true; |
| showInviteModal(minted.link, name || "New agent"); |
| loadUsers(); |
| }; |
diff --git a/web/static/chat.js b/web/static/chat.js| index b54ed23..ca93325 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -366,6 +366,31 @@ function agentTag() { |
| return t; |
| } |
| +// A picker row's name: the label, plus for agents the BOT tag and the admin's |
| +// 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()); |
| + if (e.description) { |
| + const d = document.createElement("span"); |
| + d.className = "helper-desc"; |
| + d.textContent = e.description; |
| + nm.appendChild(d); |
| + } |
| + } |
| + return nm; |
| +} |
| + |
| +// Section heading inside a pick-list (People / Helpers). |
| +function pickerSection(title) { |
| + const s = document.createElement("div"); |
| + s.className = "pick-section"; |
| + s.textContent = title; |
| + return s; |
| +} |
| + |
| // The instance key is a single AES key shared by all members, used only for |
| // all-members content (profile photos). It's wrapped to each member's user |
| // pubkey (server-blind, reusing the thread-key wrap). On boot: adopt our copy if |
| @@ -3017,6 +3042,9 @@ function showMembers() { |
| main.appendChild(avatarEl(name, () => avatarFor(uid))); |
| const lbl = document.createElement("span"); |
| lbl.textContent = me ? `${name} (you)` : name; |
| + // 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()); |
| main.appendChild(lbl); |
| if (!me) |
| main.onclick = async () => { |
| @@ -3101,7 +3129,8 @@ function addPeople() { |
| const t = threads.find((x) => x.id === activeId); |
| if (!t || t.kind !== "group") return; |
| const here = new Set(t.members || []); |
| - const candidates = roster.filter((e) => !here.has(e.user_id)); |
| + // Joined connections only (keyless = pending invitee, no pubkey to wrap to). |
| + const candidates = roster.filter((e) => !here.has(e.user_id) && e.public_key); |
| const backdrop = modal(); |
| const card = backdrop.querySelector(".sheet"); |
| const h = document.createElement("h2"); |
| @@ -3128,7 +3157,8 @@ function addPeople() { |
| const renderList = () => { |
| const q = filter.value.trim().toLowerCase(); |
| list.innerHTML = ""; |
| - for (const e of candidates.filter((x) => x.label.toLowerCase().includes(q))) { |
| + const matches = candidates.filter((x) => x.label.toLowerCase().includes(q)); |
| + const addRow = (e) => { |
| const row = document.createElement("label"); |
| row.className = "list-row"; |
| const cb = document.createElement("input"); |
| @@ -3138,12 +3168,18 @@ function addPeople() { |
| cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id); |
| add.disabled = selected.size === 0; |
| }; |
| - const nm = document.createElement("span"); |
| - nm.textContent = e.label; |
| row.appendChild(cb); |
| - row.appendChild(nm); |
| + row.appendChild(pickerName(e)); |
| list.appendChild(row); |
| - } |
| + }; |
| + // Same People/Helpers split as the new-chat picker: adding a bot to a group |
| + // is a distinct, visible act — never something buried among the people. |
| + const people = matches.filter((e) => !e.is_agent); |
| + const helpers = matches.filter((e) => e.is_agent); |
| + if (people.length && helpers.length) list.appendChild(pickerSection("People")); |
| + for (const e of people) addRow(e); |
| + if (helpers.length) list.appendChild(pickerSection("Helpers")); |
| + for (const e of helpers) addRow(e); |
| }; |
| filter.addEventListener("input", renderList); |
| renderList(); |
| @@ -3364,7 +3400,7 @@ function showNewChat() { |
| list.appendChild(p); |
| return; |
| } |
| - for (const e of matches) { |
| + const addRow = (e) => { |
| if (mode === "group") { |
| const row = document.createElement("label"); |
| row.className = "list-row"; |
| @@ -3375,17 +3411,13 @@ function showNewChat() { |
| cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id); |
| create.disabled = selected.size === 0; |
| }; |
| - const nm = document.createElement("span"); |
| - nm.textContent = e.label; |
| - if (e.is_agent) nm.appendChild(agentTag()); |
| row.appendChild(cb); |
| - row.appendChild(nm); |
| + row.appendChild(pickerName(e)); |
| list.appendChild(row); |
| } else { |
| const b = document.createElement("button"); |
| b.className = "list-row"; |
| - b.textContent = e.label; |
| - if (e.is_agent) b.appendChild(agentTag()); |
| + b.appendChild(pickerName(e)); |
| b.onclick = () => { |
| const dm = existingDM(e.user_id); |
| if (dm) { |
| @@ -3397,7 +3429,15 @@ function showNewChat() { |
| }; |
| list.appendChild(b); |
| } |
| - } |
| + }; |
| + // People first, then bots under their own "Helpers" heading — distinct from |
| + // People on purpose ("not a person"), with the admin's blurb as consent hint. |
| + const people = matches.filter((e) => !e.is_agent); |
| + const helpers = matches.filter((e) => e.is_agent); |
| + if (people.length && helpers.length) list.appendChild(pickerSection("People")); |
| + for (const e of people) addRow(e); |
| + if (helpers.length) list.appendChild(pickerSection("Helpers")); |
| + for (const e of helpers) addRow(e); |
| }; |
| filter.addEventListener("input", renderList); |
| renderList(); |
diff --git a/web/static/style.css b/web/static/style.css| index 7ee0426..bc442d1 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1220,6 +1220,12 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .list-row:hover:not(.static) { background: var(--bubble-them); } |
| .list-row.static { cursor: default; } |
| .list-row input { width: auto; } |
| +/* Helpers catalog: bots sit under their own picker heading, with a one-line blurb */ |
| +.pick-section { |
| + padding: 16px 28px 6px; font-size: 12px; font-weight: 700; color: var(--muted); |
| + text-transform: uppercase; letter-spacing: 0.06em; |
| +} |
| +.helper-desc { display: block; font-size: 13px; font-weight: 400; color: var(--muted); margin-top: 2px; } |
| /* member row: tappable identity on the left, a Remove action on the right */ |
| .member-main { |
| flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; |