diff --git a/changelog.d/526-group-bot-label.fixed.md b/changelog.d/526-group-bot-label.fixed.md new file mode 100644 index 00000000..44f77178 --- /dev/null +++ b/changelog.d/526-group-bot-label.fixed.md @@ -0,0 +1 @@ +A friend's Bot in a group chat or face-to-face room is now named like everywhere else, Bot name plus its owner's username: `Reviewer (flybot682b8a43)` instead of `Reviewer · FlyBot 682b8a43 (flybot682b8a43)`. An owner name made up from the install id (`FlyBot `) gives way to the username in member lists, message headers, @ menu labels and join, leave and room-lead lines; the full handle stays in the tooltip ([#526](https://github.com/ThinkFlowLab/FlyBot/issues/526)). diff --git a/core/client.js b/core/client.js index 5b3fe29a..73a970a2 100644 --- a/core/client.js +++ b/core/client.js @@ -1459,9 +1459,28 @@ function pickerBotLabel(bot) { isChief: bot.remote.isChief }); } +var AUTO_OWNER = /^flybot[\s._-]*(?=[0-9a-f]*\d)[0-9a-f]{6,}$/i; +function isAutoOwnerName(name) { + return AUTO_OWNER.test(String(name ?? "").trim()); +} function ownerNameOf(record) { if (!record) return ""; - return cleanPeerName(record.ownerName || record.owner || record.homeName || record.ownerHome || record.home || ownerFromHandle(record.handle), ""); + const named = cleanPeerName(record.ownerName || record.owner || record.homeName || record.ownerHome || record.home || ownerFromHandle(record.handle), ""); + if (!isAutoOwnerName(named)) return named; + const other = [record.ownerName, record.owner, record.homeName].map((value) => cleanPeerName(value, "")).find((value) => value && !isAutoOwnerName(value)); + return other || cleanPeerName(ownerFromHandle(record.handle) || ownerFromHandle(record.ownerHome) || ownerFromHandle(record.home), "") || named; +} +function seatOwnerName(ownerName, handle) { + return ownerName ? ownerNameOf({ ownerName, handle }) : ""; +} +var ownerKey = (value) => String(value ?? "").toLowerCase().replace(/[\s._@-]+/g, ""); +function remoteBotName(record, fallback = "") { + const name = cleanPeerName(record?.displayName || record?.name, fallback); + const at = name.lastIndexOf(" · "); + if (at <= 0) return name; + const tail = ownerKey(name.slice(at + 3)); + const owners = [record?.ownerName, record?.owner, record?.homeName, ownerFromHandle(record?.handle), ownerNameOf(record)].map(ownerKey).filter(Boolean); + return isAutoOwnerName(name.slice(at + 3)) || owners.includes(tail) ? name.slice(0, at).trim() : name; } function isChiefRecord(record) { if (!record) return false; @@ -1470,7 +1489,7 @@ function isChiefRecord(record) { } function remoteBotTitle(record) { const owner = ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); - const name = cleanPeerName(record?.displayName || record?.name, isChiefRecord(record) ? "Chief" : cleanPeerName(record?.handle, "Bot")); + const name = remoteBotName(record, isChiefRecord(record) ? "Chief" : cleanPeerName(record?.handle, "Bot")); return `${name} (${owner})`; } function remoteOwnerBadge(record) { @@ -5094,8 +5113,8 @@ function faceSeats(room2, roster, { left = false } = {}) { key: p.handle || p.id, local, record: p, - person: local ? cleanPeerName(p.ownerName || roster.self?.displayName, "") : ownerNameOf(p), - bot: local ? local.name : cleanPeerName(p.displayName || p.name, "Bot"), + person: local ? seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, "") : ownerNameOf(p), + bot: local ? local.name : remoteBotName(p, "Bot"), mine: Boolean(local) }; }; @@ -8604,7 +8623,7 @@ var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLo var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null; var myOwnerName = (roster, live, bot) => { const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle)); - return cleanPeerName(seat?.ownerName || roster.self?.displayName, ""); + return seatOwnerName(seat?.ownerName, seat?.handle) || cleanPeerName(roster.self?.displayName, ""); }; var uniqueBy = (list, key) => { const seen = /* @__PURE__ */ new Set(); @@ -12137,7 +12156,7 @@ var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLow var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead)); function FacePersonRow({ person, onOpenBot, lead, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) { const bots = person.seats.flatMap((seat, index) => { - const name = seat.local && onOpenBot ? (0, import_react30.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react30.createElement)("span", { key: seat.key, className: "bt-face-bot" }, seat.bot); + const name = seat.local && onOpenBot ? (0, import_react30.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react30.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot); const marked = isLeadSeat(seat, lead) ? [name, (0, import_react30.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name]; return index === 0 ? marked : [(0, import_react30.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked]; }); @@ -12351,7 +12370,7 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { "span", { className: "bt-gs-who" }, (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), - (0, import_react30.createElement)("span", { className: "bt-row-title" }, person.label || remoteBotTitle(person)), + (0, import_react30.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)), room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Friend's Bot")) ), room2.origin === "face-to-face" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") @@ -14819,7 +14838,7 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti "span", { className: "bt-member-name" }, (0, import_react37.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), - (0, import_react37.createElement)("span", null, person.label || remoteBotTitle(person)) + (0, import_react37.createElement)("span", { title: person.handle || void 0 }, person.label || remoteBotTitle(person)) ), face ? null : (0, import_react37.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) ) diff --git a/core/src/client/at-menu.js b/core/src/client/at-menu.js index 709781bf..98796f1c 100644 --- a/core/src/client/at-menu.js +++ b/core/src/client/at-menu.js @@ -5,7 +5,7 @@ // rows stay beside them, because a source cannot be hidden for one Session (a face // room filters the session rows out of view: face-at-filter.js). import { mentionCandidates, remoteMentionRows } from './flybot-registry.js' -import { cleanPeerName, friendBotTitle, ownerNameOf, remoteBotTitle, listedChatBots } from '../shared/remote-label.js' +import { cleanPeerName, friendBotTitle, ownerNameOf, remoteBotTitle, listedChatBots, seatOwnerName } from '../shared/remote-label.js' import { t } from './i18n.js' import { currentPart, relativeShort } from './text.js' @@ -78,7 +78,7 @@ export const remoteRecord = bot => (bot?.remote ? (bot.remote === true ? bot : b // As `faceSeats` names it: the owner name Bot Social stamped on my Bot's seat, else my own name. const myOwnerName = (roster, live, bot) => { const seat = live.find(p => !p.remote && p.kind !== 'human' && (p.id === bot.id || (bot.handle && p.handle === bot.handle))) - return cleanPeerName(seat?.ownerName || roster.self?.displayName, '') + return seatOwnerName(seat?.ownerName, seat?.handle) || cleanPeerName(roster.self?.displayName, '') } const uniqueBy = (list, key) => { const seen = new Set() diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index bafd5901..f9302c35 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -207,7 +207,7 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) h('div', { className: 'bt-member' }, h('span', { className: 'bt-member-name' }, h(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), - h('span', null, person.label || remoteBotTitle(person))), + h('span', { title: person.handle || undefined }, person.label || remoteBotTitle(person))), face ? null : h('span', { className: 'bt-chip' }, t('Friend\'s Bot'))))), ...members.map((member) => { const entry = entries.find(item => item.id === member.id) diff --git a/core/src/client/face-seats.js b/core/src/client/face-seats.js index f69ae271..48f2f952 100644 --- a/core/src/client/face-seats.js +++ b/core/src/client/face-seats.js @@ -2,7 +2,7 @@ // tiles, the @ menu, the bubble headers and the @ chips in a bubble (#229, #305, #457). Pure, so // the bubble module can use it. import { t } from './i18n.js' -import { cleanPeerName, ownerNameOf } from '../shared/remote-label.js' +import { cleanPeerName, ownerNameOf, remoteBotName, seatOwnerName } from '../shared/remote-label.js' // A face-to-face room lists people, each with the Bots they brought, and names every Bot one way // for every member: "Chief (Bob)", my own "Chief (Ann)" too (#229, #384, #457). Names come from @@ -17,8 +17,8 @@ export function faceSeats(room, roster, { left = false } = {}) { key: p.handle || p.id, local, record: p, - person: local ? cleanPeerName(p.ownerName || roster.self?.displayName, '') : ownerNameOf(p), - bot: local ? local.name : cleanPeerName(p.displayName || p.name, 'Bot'), + person: local ? (seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, '')) : ownerNameOf(p), + bot: local ? local.name : remoteBotName(p, 'Bot'), mine: Boolean(local), } } diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index 79e39dea..873f61fe 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -41,7 +41,7 @@ export function FacePersonRow({ person, onOpenBot, lead, className = 'bt-set-row const bots = person.seats.flatMap((seat, index) => { const name = seat.local && onOpenBot ? h('button', { key: seat.key, type: 'button', className: 'bt-face-bot', title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) - : h('span', { key: seat.key, className: 'bt-face-bot' }, seat.bot) + : h('span', { key: seat.key, className: 'bt-face-bot', title: seat.record?.handle || undefined }, seat.bot) const marked = isLeadSeat(seat, lead) ? [name, h('span', { key: `${seat.key}-lead`, className: 'bt-tag', 'data-flybot': 'face-lead-tag' }, t('Lead'))] : [name] return index === 0 ? marked : [h('span', { key: `${seat.key}-sep` }, t(', ')), ...marked] }) @@ -184,7 +184,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { remotes.map((person) => h('div', { key: person.handle || person.id, className: 'bt-set-row bt-bs-row bt-gs-member' }, h('span', { className: 'bt-gs-who' }, h(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), - h('span', { className: 'bt-row-title' }, person.label || remoteBotTitle(person)), + h('span', { className: 'bt-row-title', title: person.handle || undefined }, person.label || remoteBotTitle(person)), room.origin === 'face-to-face' ? null : h('span', { className: 'bt-tag' }, t('Friend\'s Bot'))), room.origin === 'face-to-face' || members.length + remotes.length <= 2 ? null diff --git a/core/src/shared/remote-label.js b/core/src/shared/remote-label.js index be3de6b2..abcb2dc9 100644 --- a/core/src/shared/remote-label.js +++ b/core/src/shared/remote-label.js @@ -75,9 +75,38 @@ export function pickerBotLabel(bot) { }) } +// An owner name made up from an install id ("FlyBot 682b8a43") names nobody (#526): the owner's +// username, the part of the home handle Bot Social derived from it, does. +const AUTO_OWNER = /^flybot[\s._-]*(?=[0-9a-f]*\d)[0-9a-f]{6,}$/i +export function isAutoOwnerName(name) { + return AUTO_OWNER.test(String(name ?? '').trim()) +} + export function ownerNameOf(record) { if (!record) return '' - return cleanPeerName(record.ownerName || record.owner || record.homeName || record.ownerHome || record.home || ownerFromHandle(record.handle), '') + const named = cleanPeerName(record.ownerName || record.owner || record.homeName || record.ownerHome || record.home || ownerFromHandle(record.handle), '') + if (!isAutoOwnerName(named)) return named + const other = [record.ownerName, record.owner, record.homeName].map(value => cleanPeerName(value, '')).find(value => value && !isAutoOwnerName(value)) + return other || cleanPeerName(ownerFromHandle(record.handle) || ownerFromHandle(record.ownerHome) || ownerFromHandle(record.home), '') || named +} + +// The owner Bot Social stamped on my own Bot's seat, read the same way (#526); none: ''. +export function seatOwnerName(ownerName, handle) { + return ownerName ? ownerNameOf({ ownerName, handle }) : '' +} + +const ownerKey = value => String(value ?? '').toLowerCase().replace(/[\s._@-]+/g, '') + +// Bot Social labels a room member " · ". As a Bot's name that repeats the owner the +// title adds anyway (#526: "Reviewer · FlyBot 682b8a43 (flybot682b8a43)"), so only the Bot's part +// stays. A name whose tail is not that owner is the Bot's own name and is kept whole. +export function remoteBotName(record, fallback = '') { + const name = cleanPeerName(record?.displayName || record?.name, fallback) + const at = name.lastIndexOf(' · ') + if (at <= 0) return name + const tail = ownerKey(name.slice(at + 3)) + const owners = [record?.ownerName, record?.owner, record?.homeName, ownerFromHandle(record?.handle), ownerNameOf(record)].map(ownerKey).filter(Boolean) + return isAutoOwnerName(name.slice(at + 3)) || owners.includes(tail) ? name.slice(0, at).trim() : name } export function isChiefRecord(record) { @@ -90,7 +119,7 @@ export function isChiefRecord(record) { // its owner in parentheses: "Helper (Bob)", and a friend's Chief the same way: "Chief (Bob)". export function remoteBotTitle(record) { const owner = ownerNameOf(record) || cleanPeerName(record?.handle, 'Friend') - const name = cleanPeerName(record?.displayName || record?.name, isChiefRecord(record) ? 'Chief' : cleanPeerName(record?.handle, 'Bot')) + const name = remoteBotName(record, isChiefRecord(record) ? 'Chief' : cleanPeerName(record?.handle, 'Bot')) return `${name} (${owner})` } diff --git a/core/test/group-bot-label.test.js b/core/test/group-bot-label.test.js new file mode 100644 index 00000000..8ac4529b --- /dev/null +++ b/core/test/group-bot-label.test.js @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' +import * as labels from '../src/shared/remote-label.js' +import { presentRoom } from '../src/host/room-membership.js' + +// #526: in a group chat or face room a friend's Bot read "Reviewer · FlyBot 682b8a43 (flybot682b8a43)": +// Bot Social's member label (" · ") as the Bot's name, then the owner again from its +// handle. Owner decision 2026-10-20 (option a): the #498 person-first name, "Reviewer (flybot682b8a43)", +// with an owner name made up from the install id replaced by the owner's username; the handle stays +// on hover. + +async function load(entry) { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL(`../src/client/${entry}`, import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const module = { exports: {} } + runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), + useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', + }, + 'react-dom': { createPortal: node => node }, + '@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + }) + return module.exports +} + +const find = (node, match) => { + if (Array.isArray(node)) return node.flatMap(child => find(child, match)) + if (node === null || typeof node !== 'object') return [] + return [...(match(node) ? [node] : []), ...find(node.children, match)] +} +const plain = value => JSON.parse(JSON.stringify(value)) +const text = node => find(node, () => true).flatMap(n => n.children ?? []).filter(child => typeof child === 'string').join('') + +const { facePeople, faceSeatLabel, faceSeats } = await load('face-seats.js') +const { FacePersonRow } = await load('group-settings.js') +const { RemoteMessage, remoteSpeaker } = await load('cells.js') + +const { ownerNameOf, remoteBotTitle } = labels +// Added by #526; read through the namespace so the test also runs (red) on the tip before it. +const remoteBotName = record => labels.remoteBotName(record) +const isAutoOwnerName = name => labels.isAutoOwnerName(name) + +const handle = '@reviewer.flybot682b8a43.home' +// The seat as Bot Social sends it for an owner who never set a name (face-rooms.ts memberLabel). +const reviewer = { kind: 'bot', remote: true, id: handle, handle, displayName: 'Reviewer', ownerName: 'FlyBot 682b8a43', label: 'Reviewer · FlyBot 682b8a43' } +const mine = { id: 'b1', name: 'Chief' } +const seatMine = { kind: 'bot', id: 'b1', handle: '@chief.ann.home', displayName: 'Chief', ownerName: 'Ann' } +const roster = (participants = [seatMine, reviewer], origin = 'face-to-face') => ({ + self: { memberId: 'm1', displayName: 'Ann' }, + bots: [mine], + byId: { b1: mine }, + remoteBots: [], + roomsById: { r: { id: 'r', origin, members: ['b1'], participants } }, +}) + +test('#526: the reported label becomes Bot name + owner username', () => { + // The bubble header with no seat to read (a group chat, a seat not synced yet): the sender label. + const source = { remote: { handle }, senderName: 'Reviewer · FlyBot 682b8a43' } + assert.equal(remoteSpeaker(source, { bots: [], remoteBots: [] }).name, 'Reviewer (flybot682b8a43)') + assert.equal(remoteBotTitle({ displayName: 'Reviewer · FlyBot 682b8a43', handle }), 'Reviewer (flybot682b8a43)') + assert.equal(remoteBotTitle({ displayName: 'Reviewer', ownerName: 'FlyBot 682b8a43', handle }), 'Reviewer (flybot682b8a43)') + assert.equal(ownerNameOf({ ownerName: 'FlyBot 682b8a43', ownerHome: '@flybot682b8a43.home' }), 'flybot682b8a43') +}) + +test('#526: a face room names that Bot and its person the same way, the id on hover', () => { + const view = roster() + const seat = faceSeats(view.roomsById.r, view).find(item => !item.mine) + assert.equal(faceSeatLabel(seat), 'Reviewer (flybot682b8a43)') + const people = facePeople(view.roomsById.r, view) + assert.deepEqual(plain(people.map(person => person.name)), ['Ann', 'flybot682b8a43']) + const row = FacePersonRow({ person: people[1] }) + const bot = find(row, n => n.props?.className === 'bt-face-bot')[0] + assert.equal(text(bot), 'Reviewer') + assert.equal(bot.props.title, handle, 'the full handle stays in the tooltip') + + const source = { remote: { handle }, senderName: 'Reviewer · FlyBot 682b8a43' } + assert.equal(remoteSpeaker(source, view, 'r').name, 'Reviewer (flybot682b8a43)') + const drawn = RemoteMessage({ source, text: 'LGTM', roster: view, actions: {}, sessionId: 'r' }) + const header = find(drawn, n => n.props?.className === 'bt-group-name')[0] + assert.equal(text(header), 'Reviewer (flybot682b8a43)') + assert.equal(header.props.title, handle) + + // The host's room view (Members panel, @ menu labels) says the same. + const shown = presentRoom({ id: 'r', origin: 'face-to-face', members: ['b1'], participants: [seatMine, reviewer], log: [] }, undefined, { bots: {}, rooms: {} }) + assert.equal(shown.presentedParticipants.find(p => p.handle === handle).label, 'Reviewer (flybot682b8a43)') + // My own Bot when my own owner name is the made-up one. + const auto = roster([{ ...seatMine, handle: '@chief.flybot1a2b3c4d.home', ownerName: 'FlyBot 1a2b3c4d' }, reviewer]) + assert.equal(faceSeatLabel(faceSeats(auto.roomsById.r, auto).find(item => item.mine)), 'Chief (flybot1a2b3c4d)') +}) + +test('#526: real names stay as they are', () => { + assert.equal(remoteBotTitle({ displayName: 'Coder', ownerName: 'Bob', handle: '@coder.bob.home' }), 'Coder (Bob)') + // A "·" in a Bot's own name is not an owner label. + assert.equal(remoteBotTitle({ displayName: 'Ops · Night', ownerName: 'Bob', handle: '@ops.bob.home' }), 'Ops · Night (Bob)') + assert.equal(remoteBotName({ displayName: 'Coder · Bob', handle: '@coder.bob.home' }), 'Coder') + // A person really called FlyBot, or a home renamed FlyBot2 on a taken handle (#136), keeps that name. + for (const name of ['FlyBot', 'FlyBot2', 'FlyBot Team', 'Flybot fan']) assert.equal(isAutoOwnerName(name), false, name) + assert.equal(remoteBotTitle({ displayName: 'Helper', ownerName: 'FlyBot2', handle: '@helper.flybot2.home' }), 'Helper (FlyBot2)') + // No username to fall back on: the made-up name is still better than none. + assert.equal(remoteBotTitle({ displayName: 'Helper', ownerName: 'FlyBot 682b8a43' }), 'Helper (FlyBot 682b8a43)') + // A group chat (not face to face) seat reads the same. + const view = roster([seatMine, { ...reviewer, label: undefined }], 'group') + assert.equal(remoteSpeaker({ remote: { handle }, senderName: 'Reviewer' }, view, 'r').name, 'Reviewer (flybot682b8a43)') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a38cde11..7708c895 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -156,6 +156,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A face-to-face room is person-first (#229, #457, owner decision 2026-10-20). `face-seats.js` (re-exported by `group-settings.js`) builds it from participant fields and the roster, never message text: `faceSeats` lists the live Bot seats (`{ left: true }` adds the ones that left, marked `left`), `faceSeatLabel` names each Bot ` ()`, mine included (`Chief (SelfB)`, `Chief (SelfA)`; a Bot is never `(you)`), `faceBotLabel` gives a local Bot that name in a face room, and `facePeople` groups the seats by person (me once, first; a friend by home) with `facePersonLabel` putting `(you)` on me only. The overview and group settings **Members · N** count people and draw one row per person (`FacePersonRow`: person mark, name, then that person's Bots; my Bots open their chat). - In a face room a bubble names its speaker the same way (#457): my Bots' lines `Chief (SelfB)` (`cells.js` `GroupMessage` via `faceBotLabel`), a friend's Bot `Researcher (SelfA)` (`remoteSpeaker`), name only, with the handle as the tooltip instead of a globe and handle line. My own typed line is mine, not my Bot's: it carries my person mark and name (`data-face-mine`) and is never drawn held, so it does not change colour while a Bot's reply waits (`user-message.js`). A `@Chief (SelfA)` chip stays bound to that Bot after it leaves: `faceMentionables` also matches the left seats (after the live ones), so the name never falls through to my own Chief. - **One name for a friend's Bot (#384, #305, owner decision 2026-10-19).** `remoteBotTitle` (`shared/remote-label.js`) is the only format: the Bot's name, then its owner in parentheses (`ownerNameOf`: the human owner, `ownerName` / `owner`, before the home's label `homeName`), `Helper (Bob)`, and a friend's Chief the same, `Chief (Bob)` (no `'s Chief` special case). For a friend's chat in the roster, `friendBotTitle(bot)` is that one call (#498), on both homes: the sidebar row (one string, no muted owner part), chat header, tab title, composer placeholder, row tooltip and the `@` rows (a friend's Bot by that name with its handle as the description; the inserted text is unchanged; a friend's Bot is offered once: in a group by its seat, in a Bot's chat by its remote member row, which inserts the handle, not also by its chat row); in a face room `faceSeatLabel` / `at-menu.js` `faceLabel` give the same format (`Chief (Bob)`, my own Bot `Chief (SelfB)` with my name, #457). The social plugin's classic script reads the same functions from `globalThis.__flybot__.names` (`friendBotTitle`, `friendBotOwner`, set in `flybot-slots.js` `registry()`). Approval cards name the receiving person instead (`Chief → SelfB1233`, #418). `faceAliases` keeps the older labels (`Bob · Chief`, `Chief · Bob's Bot`, `Bob's Chief`) as aliases, so a line typed before the change still calls the same Bot. DSH's own Sessions rows in a face room's `@` menu are filtered in FlyBot (`face-at-filter.js`, below) until DSH has an API for it (#422). +- **Group and face-room Bot labels (#526, owner decision 2026-10-20, option a).** The same person-first format: `Reviewer (flybot682b8a43)`, never Bot Social's member label plus the handle (`Reviewer · FlyBot 682b8a43 (flybot682b8a43)`). `remoteBotName` drops a trailing ` · ` from a Bot's name when it is that Bot's owner (the server's `label` reaching a name slot, e.g. a bubble header with no seat yet); `ownerNameOf` passes over an owner name made up from an install id (`isAutoOwnerName`: `FlyBot` + hex id) to the owner's username from the home handle, and keeps it only when no handle is known; `seatOwnerName` reads my own seat's owner the same way (`face-seats.js`, `at-menu.js`). The Bot names in `FacePersonRow` and the remote rows of the group Members lists (`group-settings.js`, `details-drawer.js`) carry the handle as a tooltip, as bubble headers already do. Covered by `core/test/group-bot-label.test.js`. - A face-to-face room's subtitle (`roomSubtitle` → `faceRoomCount` in `group-settings.js`) names what it counts, `1 person · 1 Bot` / `2 people · 4 Bots`: Bots are the live Bot seats, people the distinct owners among them (me once, a friend by owner name); human seats and Bots that left do not count (#306). - The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`Chief (SelfA)`, `Chief (SelfB)`), plus **Everyone** with the room's Bot count (`2 Bots`, #457). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305). - A face room's `@` menu shows no DSH Sessions rows (#305, owner decision 2026-10-19: filter in FlyBot for now; #422 asks DSH for an API). DSH's built-in `reference` source cannot be dropped for one Session, so `HeaderPill` (`header.js`) starts `actions.filterFaceAtMenu` for a face room, which watches that Session's trigger controller (`inputTriggers.sessionOf`, the same one the composer uses) with `watchFaceMenu` (`face-at-filter.js`): a `