diff --git a/changelog.d/498-friend-bot-name.fixed.md b/changelog.d/498-friend-bot-name.fixed.md new file mode 100644 index 00000000..ce79edf4 --- /dev/null +++ b/changelog.d/498-friend-bot-name.fixed.md @@ -0,0 +1 @@ +A friend's Bot now has one name everywhere in a friend chat, on both homes: the sidebar row, chat header, tab, composer placeholder and @ menu all read `Chief (SelfB1233)`, from one shared formatter, and the @ menu no longer shows the handle as the Bot's name. A held message's approval card names the person who gets it (`Chief → SelfB1233`), the same before and after a reload, instead of `Chief → Chief (SelfB1233)` ([#498](https://github.com/ThinkFlowLab/FlyBot/issues/498)). diff --git a/core/client.js b/core/client.js index 4ad60cf0..cba974bf 100644 --- a/core/client.js +++ b/core/client.js @@ -1467,6 +1467,21 @@ function remoteBotTitle(record) { function remoteOwnerBadge(record) { return ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); } +function friendRecord(bot) { + const remote = bot?.remote && typeof bot.remote === "object" ? bot.remote : {}; + return { + displayName: remote.displayName || remote.name || bot?.name, + ownerName: remote.ownerName || remote.owner || remote.homeName, + handle: remote.handle ?? bot?.handle, + isChief: remote.isChief + }; +} +function friendBotTitle(bot) { + return remoteBotTitle(friendRecord(bot)); +} +function friendBotOwner(bot) { + return remoteOwnerBadge(friendRecord(bot)); +} function sharedRoomTitle(inviterName) { return `Shared room from ${cleanPeerName(inviterName, "a friend")}`; } @@ -2143,6 +2158,7 @@ function registry2() { globalThis.__flybot__.BotMark ??= BotMark; globalThis.__flybot__.BotAvatar ??= BotAvatar; globalThis.__flybot__.lookOf ??= lookOf; + globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner }; return slots; } var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options }); @@ -2258,12 +2274,7 @@ var chatOf = (approval, roster) => approval.request?.meta?.roomId ?? approval.re function recipientName(approval, roster) { const handle = handleOf(approval.request?.to); const bot = friendChatOf(approval, roster); - if (bot) { - const owner = bot.remote.owner || bot.remote.homeName; - if (owner) return `${bot.name} (${owner})`; - return bot.name; - } - return approval.recipientName ?? approval.targetName ?? handle ?? String(approval.request?.to ?? ""); + return approval.recipientName || (bot ? friendBotOwner(bot) : "") || approval.targetName || handle || String(approval.request?.to ?? ""); } function FlyBotApprovals({ useRoster, actions } = {}) { const entries2 = useSlot("flybot.approvals"); @@ -3214,7 +3225,6 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-row-owner{flex:0 100 auto;min-width:24px;font-size:13px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2)} .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -4245,12 +4255,7 @@ function sessionTabLabel(roster, sessionId) { const bot = roster.byId?.[sessionId]; const room2 = roster.roomsById?.[sessionId]; if (bot?.remote) { - return remoteBotTitle({ - displayName: bot.name, - ownerName: bot.remote.homeName || bot.remote.owner, - handle: bot.remote.handle, - isChief: bot.remote.isChief - }); + return friendBotTitle(bot); } if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; @@ -5413,8 +5418,8 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use } function HeldReply({ held, roster, sessionId, actions, closing, children }) { const bot = roster.byId[sessionId]; - const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to && isListedRemoteChat(item)); - const toName = chat ? remoteBotTitle({ displayName: chat.name, ownerName: chat.remote.homeName || chat.remote.owner, handle: chat.remote.handle, isChief: chat.remote.isChief }) : held.toOwner || held.to || t("a friend"); + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); + const toName = cleanPeerName(held.toOwner) || (chat ? friendBotOwner(chat) : ownerNameOf({ handle: held.to })) || held.to || t("a friend"); return (0, import_react8.createElement)( "div", { className: "bt-held-reply", "data-state": held.status, "data-held": held.status === "pending" ? "" : void 0 }, @@ -6988,11 +6993,9 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); }; const pinned = bot.pinned === true && !tile; - const remoteRecord2 = bot.remote ? { displayName: bot.name, ownerName: bot.remote.homeName || bot.remote.owner, handle: bot.remote.handle, isChief: bot.remote.isChief } : null; - const shown = remoteRecord2 ? remoteBotTitle(remoteRecord2) : bot.name; - const rowName = remoteRecord2 ? cleanPeerName(bot.name, shown) : shown; - const owner = remoteRecord2 ? ownerNameOf(remoteRecord2) || cleanPeerName(bot.remote.handle) : ""; - const secondLine = awayLine || preview || (remoteRecord2 ? t("Friend's Bot") : ""); + const remote = Boolean(bot.remote); + const shown = remote ? friendBotTitle(bot) : bot.name; + const secondLine = awayLine || preview || (remote ? t("Friend's Bot") : ""); const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); if (tile && wide) { return (0, import_react12.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react12.createElement)( @@ -7024,7 +7027,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio "aria-current": active ? "page" : void 0, // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. "data-bot-id": id, - title: remoteRecord2 ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), + title: remote ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), onClick: () => actions.openSession(id), onContextMenu }, @@ -7036,8 +7039,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio (0, import_react12.createElement)( "span", { className: "bt-row-name" }, - (0, import_react12.createElement)("span", { className: "bt-row-title" }, rowName), - owner ? (0, import_react12.createElement)("span", { className: "bt-row-owner" }, owner) : null, + (0, import_react12.createElement)("span", { className: "bt-row-title" }, shown), bot.remote ? (0, import_react12.createElement)(RemoteMark, { remote: bot.remote }) : null, pinned ? (0, import_react12.createElement)(PinMark) : null ), @@ -7908,7 +7910,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); - const name = bot?.remote ? remoteBotTitle({ displayName: bot.name, ownerName: bot.remote.homeName || bot.remote.owner, handle: bot.remote.handle, isChief: bot.remote.isChief }) : room2?.displayName || (room2?.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2?.name) || bot?.name || ""; + const name = bot?.remote ? friendBotTitle(bot) : room2?.displayName || (room2?.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2?.name) || bot?.name || ""; const useNews = useMemoryNews ?? noNews; const memoryIds = bot ? [bot.id] : room2?.members ?? []; const notice = useMemoryNotice(memoryIds, useNews); @@ -8408,8 +8410,9 @@ function atSources({ roster, sessions, launched, iconFor }) { const snapshot = roster.getSnapshot(); const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]); const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({ - // A friend's Bot by its one name, "Chief (Bob)"; its handle is the second line (#384). - name: row.bot?.remote && !face ? remoteBotTitle(remoteRecord(row.bot)) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? remoteBotTitle({ ...row.remote, displayName: row.remote.name, ownerName: row.remote.owner || row.remote.homeName }) : t("Everyone"), + // A friend's Bot by its one name, "Chief (Bob)"; its handle is the second line (#384). A seat + // carries its own name; a friend's chat is named as its sidebar row and header (#498). + name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? remoteBotTitle({ ...row.remote, displayName: row.remote.name, ownerName: row.remote.owner || row.remote.homeName }) : t("Everyone"), ...row.bot?.role && !face ? { description: row.bot.role } : {}, ...row.remote ? { description: row.remote.handle } : {}, ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}, @@ -16627,7 +16630,7 @@ function mentionableBots(roster, sessionId, { fromFriend = false } = {}) { const bots = room2 ? room2.members.map((id) => roster.byId[id]).filter(Boolean) : roster.byId[sessionId] ? roster.bots : []; const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })); const remotes = (room2?.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, mentionLabel: remoteBotTitle(p) })); - return bots.concat(humans, remotes, bots.filter((bot) => typeof bot?.remote?.handle === "string").map((bot) => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ""), mentionLabel: remoteBotTitle({ displayName: bot.name, ownerName: bot.remote.homeName || bot.remote.owner, handle: bot.remote.handle, isChief: bot.remote.isChief }) }))); + return bots.concat(humans, remotes, bots.filter((bot) => typeof bot?.remote?.handle === "string").map((bot) => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ""), mentionLabel: friendBotTitle(bot) }))); } function faceMentionables(room2, roster, fromFriend) { const humans = (room2.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })); diff --git a/core/src/client/at-menu.js b/core/src/client/at-menu.js index c190297f..709781bf 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, ownerNameOf, remoteBotTitle, listedChatBots } from '../shared/remote-label.js' +import { cleanPeerName, friendBotTitle, ownerNameOf, remoteBotTitle, listedChatBots } from '../shared/remote-label.js' import { t } from './i18n.js' import { currentPart, relativeShort } from './text.js' @@ -138,8 +138,9 @@ export function atSources({ roster, sessions, launched, iconFor }) { const snapshot = roster.getSnapshot() const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]) const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map(row => ({ - // A friend's Bot by its one name, "Chief (Bob)"; its handle is the second line (#384). - name: row.bot?.remote && !face ? remoteBotTitle(remoteRecord(row.bot)) : row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? remoteBotTitle({ ...row.remote, displayName: row.remote.name, ownerName: row.remote.owner || row.remote.homeName }) : t('Everyone'), + // A friend's Bot by its one name, "Chief (Bob)"; its handle is the second line (#384). A seat + // carries its own name; a friend's chat is named as its sidebar row and header (#498). + name: row.bot?.remote && !face ? (row.bot.kind === 'bot' ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot)) : row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? remoteBotTitle({ ...row.remote, displayName: row.remote.name, ownerName: row.remote.owner || row.remote.homeName }) : t('Everyone'), ...(row.bot?.role && !face ? { description: row.bot.role } : {}), ...(row.remote ? { description: row.remote.handle } : {}), ...(row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}), diff --git a/core/src/client/brand.js b/core/src/client/brand.js index 0d37d556..201cf7c6 100644 --- a/core/src/client/brand.js +++ b/core/src/client/brand.js @@ -2,7 +2,7 @@ // Paths stay here so tests can import them without React. import { isAutoFaceTitle } from '../shared/face-title.js' -import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { friendBotTitle, sharedRoomTitle } from '../shared/remote-label.js' export const FLYBOT_WING = 'M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z' export const FLYBOT_WING_LOW = 'M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z' @@ -32,12 +32,7 @@ export function sessionTabLabel(roster, sessionId) { const bot = roster.byId?.[sessionId] const room = roster.roomsById?.[sessionId] if (bot?.remote) { - return remoteBotTitle({ - displayName: bot.name, - ownerName: bot.remote.homeName || bot.remote.owner, - handle: bot.remote.handle, - isChief: bot.remote.isChief, - }) + return friendBotTitle(bot) } // An ended face-to-face room (closed, or someone left) no longer lists people who are not in it (#250). // A name the user chose is kept. diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 98c1c6e9..c0d16cf7 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -9,7 +9,7 @@ import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js' import { SecretToolCell } from './secret-card.js' import { dateLocale, t } from './i18n.js' import { remoteMessageText } from './flybot-registry.js' -import { isListedRemoteChat, normRemoteHandle, remoteBotTitle } from '../shared/remote-label.js' +import { cleanPeerName, friendBotOwner, normRemoteHandle, ownerNameOf, remoteBotTitle } from '../shared/remote-label.js' import { faceBotLabel, faceSeatLabel, faceSeats } from './face-seats.js' import { fileSize } from './flybot-slots.js' import { HeldReplyActions } from './held-reply.js' @@ -193,11 +193,10 @@ export function AssistantCell({ node, groupPart, sessionId, openFile, fileMentio // turn's closing text carries the buttons; an earlier text of the same turn is only marked. function HeldReply({ held, roster, sessionId, actions, closing, children }) { const bot = roster.byId[sessionId] - // The friend's Bot by name and owner when its chat is listed (#384), else the person (#418). - const chat = (roster.bots ?? []).find(item => item.remote?.handle === held.to && isListedRemoteChat(item)) - const toName = chat - ? remoteBotTitle({ displayName: chat.name, ownerName: chat.remote.homeName || chat.remote.owner, handle: chat.remote.handle, isChief: chat.remote.isChief }) - : held.toOwner || held.to || t('a friend') + // The person who gets the reply (#418, #498): the host stored it with the hold, so the card reads + // the same live and after a reload; "Chief → Chief (SelfB1233)" named two Bots and no person. + const chat = (roster.bots ?? []).find(item => item.remote?.handle === held.to) + const toName = cleanPeerName(held.toOwner) || (chat ? friendBotOwner(chat) : ownerNameOf({ handle: held.to })) || held.to || t('a friend') return h('div', { className: 'bt-held-reply', 'data-state': held.status, 'data-held': held.status === 'pending' ? '' : undefined }, children, closing ? h(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null) diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 330bef6d..8f86d2cd 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -25,12 +25,13 @@ // Section(title) and Row(label, hint) so the page looks like the others and reads zh in a zh UI // // The same registry object also carries the host Bot look so a plugin can draw a Bot -// exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf (set in registry()). +// exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf, and name a friend's Bot +// or its owner the same way: __flybot__.names.friendBotTitle / friendBotOwner (set in registry()). // // Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry). import { Component, createElement as h, Fragment, useEffect, useState } from 'react' import { call as callHost } from './api.js' -import { isListedRemoteChat } from '../shared/remote-label.js' +import { friendBotOwner, friendBotTitle, isListedRemoteChat } from '../shared/remote-label.js' import { registry as baseRegistry } from './flybot-registry.js' import { BotAvatar, BotMark, lookOf } from './mark.js' import { t } from './i18n.js' @@ -52,6 +53,8 @@ export function registry() { globalThis.__flybot__.BotMark ??= BotMark globalThis.__flybot__.BotAvatar ??= BotAvatar globalThis.__flybot__.lookOf ??= lookOf + // The same for a friend's Bot's one name and its owner (#498), so the social plugin says it as the sidebar does. + globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner } return slots } @@ -182,16 +185,12 @@ const friendChatOf = (approval, roster) => { // Bot that wrote it (#278). The chat's dock draws its card; the pointer opens that chat. // A Bot's held reply to a friend is decided on its bubble in the chat it answers (#418). const chatOf = (approval, roster) => approval.request?.meta?.roomId ?? approval.request?.meta?.replyIn?.sessionId ?? friendChatOf(approval, roster)?.id ?? (typeof approval.request?.from === 'string' ? approval.request.from : undefined) -// A friend's Bot by name and owner when the roster knows its chat; otherwise what the host called it. +// The person who gets it (#418, #498): the host's `recipientName`, else the owner of the friend's chat, +// else what the host called it. "Chief → SelfB1233", never two Bot names side by side. function recipientName(approval, roster) { const handle = handleOf(approval.request?.to) const bot = friendChatOf(approval, roster) - if (bot) { - const owner = bot.remote.owner || bot.remote.homeName - if (owner) return `${bot.name} (${owner})` - return bot.name - } - return approval.recipientName ?? approval.targetName ?? handle ?? String(approval.request?.to ?? '') + return approval.recipientName || (bot ? friendBotOwner(bot) : '') || approval.targetName || handle || String(approval.request?.to ?? '') } export function FlyBotApprovals({ useRoster, actions } = {}) { diff --git a/core/src/client/header.js b/core/src/client/header.js index f0f38c56..9d00e67b 100644 --- a/core/src/client/header.js +++ b/core/src/client/header.js @@ -6,7 +6,7 @@ import { botState, BotAvatar, RoomAvatar } from './mark.js' import { ActivityIndicator } from './activity-row.js' import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' import { t } from './i18n.js' -import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { friendBotTitle, sharedRoomTitle } from '../shared/remote-label.js' import { holdComposerLabel } from './composer-label.js' // ------------------------------------------------------------------------- @@ -71,7 +71,7 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus const room = roster.roomsById[sessionId] const detailsOpen = useUi(state => state.details === sessionId) const name = bot?.remote - ? remoteBotTitle({ displayName: bot.name, ownerName: bot.remote.homeName || bot.remote.owner, handle: bot.remote.handle, isChief: bot.remote.isChief }) + ? friendBotTitle(bot) : (room?.displayName || (room?.sharedFrom ? sharedRoomTitle(room.sharedFrom.inviterName) : room?.name) || bot?.name || '') const useNews = useMemoryNews ?? noNews const memoryIds = bot ? [bot.id] : room?.members ?? [] diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index a2062324..5eddc3da 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -11,7 +11,7 @@ import { reducedMotion } from './activity-row.js' import { Shimmer, workPhrase } from './cells.js' import { springEasing, SPRING_SHAPE } from './motion.js' import { hostText, t } from './i18n.js' -import { cleanPeerName, ownerNameOf, remoteBotTitle, sharedRoomTitle, isListedRemoteChat } from '../shared/remote-label.js' +import { friendBotTitle, sharedRoomTitle, isListedRemoteChat } from '../shared/remote-label.js' import { showsDshRow } from './dsh-offer.js' import { InboxRow } from './inbox.js' @@ -66,14 +66,11 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio actions.openMenu({ kind: 'bot', id, x: event.clientX, y: event.clientY }) } const pinned = bot.pinned === true && !tile - const remoteRecord = bot.remote ? { displayName: bot.name, ownerName: bot.remote.homeName || bot.remote.owner, handle: bot.remote.handle, isChief: bot.remote.isChief } : null - const shown = remoteRecord ? remoteBotTitle(remoteRecord) : bot.name - // A friend's Bot row (#384): its name, then its owner, on one line like every other row; the - // second line is the preview, or "Friend's Bot" before the first message. The full title is on hover. - const rowName = remoteRecord ? cleanPeerName(bot.name, shown) : shown - // A plain per-Bot identity (@alice.bot) names no owner: its handle tells two "Helper" rows apart. - const owner = remoteRecord ? ownerNameOf(remoteRecord) || cleanPeerName(bot.remote.handle) : '' - const secondLine = awayLine || preview || (remoteRecord ? t("Friend's Bot") : '') + const remote = Boolean(bot.remote) + // A friend's Bot row (#384, #498): "Chief (SelfB1233)" on one line, as its header and the @ menu + // say it; the second line is the preview, or "Friend's Bot" before the first message. + const shown = remote ? friendBotTitle(bot) : bot.name + const secondLine = awayLine || preview || (remote ? t("Friend's Bot") : '') const label = [shown, main ? t('Main Bot') : '', pinned ? t('Pinned') : '', running || away ? t('Working') : '', waiting ? t('Waiting for your approval') : alert ? t('Waiting for your answer') : '', unread ? t('Unread activity') : ''].filter(Boolean).join(', ') if (tile && wide) { return h('div', { className: 'bt-tile-wrap' }, h('button', { @@ -91,14 +88,13 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio const row = h('button', { type: 'button', className: 'bt-row', 'aria-label': label, 'aria-current': active ? 'page' : undefined, // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. - 'data-bot-id': id, title: remoteRecord ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), onClick: () => actions.openSession(id), onContextMenu, + 'data-bot-id': id, title: remote ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), onClick: () => actions.openSession(id), onContextMenu, }, h(BotAvatar, { bot, size: 36, state, main }), pinned && !wide ? h(PinMark) : null, wide ? h('span', { className: 'bt-row-body' }, h('span', { className: 'bt-row-name' }, - h('span', { className: 'bt-row-title' }, rowName), - owner ? h('span', { className: 'bt-row-owner' }, owner) : null, + h('span', { className: 'bt-row-title' }, shown), bot.remote ? h(RemoteMark, { remote: bot.remote }) : null, pinned ? h(PinMark) : null), secondLine ? h('span', { className: 'bt-row-preview' }, secondLine) : null) : null, unreadMark) diff --git a/core/src/client/styles.js b/core/src/client/styles.js index 03bf216c..847d9f73 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -149,7 +149,6 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-row-owner{flex:0 100 auto;min-width:24px;font-size:13px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2)} .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} diff --git a/core/src/client/user-message.js b/core/src/client/user-message.js index f7a23446..4f30b9d7 100644 --- a/core/src/client/user-message.js +++ b/core/src/client/user-message.js @@ -4,7 +4,7 @@ import { mentionSpans } from '../shared/mentions.js' import { BotMark, PersonMark } from './mark.js' import { CheckIcon, CopyIcon } from './icons.js' import { dateLocale, t } from './i18n.js' -import { cleanPeerName, faceAliases, remoteBotTitle } from '../shared/remote-label.js' +import { cleanPeerName, faceAliases, friendBotTitle, remoteBotTitle } from '../shared/remote-label.js' import { facePeople, faceSeatLabel, faceSeats } from './face-seats.js' // ------------------------------------------------------------------------- @@ -43,7 +43,7 @@ export function mentionableBots(roster, sessionId, { fromFriend = false } = {}) const remotes = (room?.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) .map(p => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, mentionLabel: remoteBotTitle(p) })) return bots.concat(humans, remotes, bots.filter(bot => typeof bot?.remote?.handle === 'string') - .map(bot => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ''), mentionLabel: remoteBotTitle({ displayName: bot.name, ownerName: bot.remote.homeName || bot.remote.owner, handle: bot.remote.handle, isChief: bot.remote.isChief }) }))) + .map(bot => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ''), mentionLabel: friendBotTitle(bot) }))) } // A face-to-face line was typed on either home, so a Bot is matched by every name the room diff --git a/core/src/shared/remote-label.js b/core/src/shared/remote-label.js index 7cf59e8b..be3de6b2 100644 --- a/core/src/shared/remote-label.js +++ b/core/src/shared/remote-label.js @@ -99,6 +99,29 @@ export function remoteOwnerBadge(record) { return ownerNameOf(record) || cleanPeerName(record?.handle, 'Friend') } +// The record those two read for a friend's Bot as the roster lists it: its chat (`bot.remote` from +// remote-chat.js, named by the chat) or a seat that carries its own name and owner. +function friendRecord(bot) { + const remote = bot?.remote && typeof bot.remote === 'object' ? bot.remote : {} + return { + displayName: remote.displayName || remote.name || bot?.name, + ownerName: remote.ownerName || remote.owner || remote.homeName, + handle: remote.handle ?? bot?.handle, + isChief: remote.isChief, + } +} + +// A friend's Bot, one name on both homes (#498): the sidebar row, the chat header and tab, the +// composer placeholder and the @ menu all say "Chief (SelfB1233)", from this one function. +export function friendBotTitle(bot) { + return remoteBotTitle(friendRecord(bot)) +} + +// The person who owns a friend's Bot: who a held reply to that Bot goes to (#418, #498). +export function friendBotOwner(bot) { + return remoteOwnerBadge(friendRecord(bot)) +} + export function sharedRoomTitle(inviterName) { return `Shared room from ${cleanPeerName(inviterName, 'a friend')}` } diff --git a/core/test/at-menu.test.js b/core/test/at-menu.test.js index c7b406e4..5990467c 100644 --- a/core/test/at-menu.test.js +++ b/core/test/at-menu.test.js @@ -175,6 +175,25 @@ test('#384: a friend\'s Bot with its own chat is offered once in a Bot\'s chat, assert.equal(remoteBotTitle({ displayName: 'Coder', homeName: 'Family Workspace' }), 'Coder (Family Workspace)') }) +test('#498: a friend\'s chat as the host keeps it (no name on `remote`) is offered by its one name, as its sidebar row says it', async () => { + const chat = indexRoster({ + bots: [ + { id: 'chief', name: 'Chief', sessionId: 'chief' }, + { id: 'b-chief', name: 'Chief', sessionId: 'b-chief', remote: { handle: '@chief.selfb1233.home', home: '@selfb1233.home', homeName: 'SelfB1233' } }, + ], + rooms: [], + }) + const [bots] = atSources({ + roster: { getSnapshot: () => chat }, + sessions: { list: { getSnapshot: () => ({ ids: [], byId: {} }) } }, + launched: () => false, + iconFor: () => 'bot', + }) + const listed = await bots.candidates({ sessionId: 'chief' }, { query: '' }) + const row = listed.find(item => item.description === '@chief.selfb1233.home') + assert.equal(row?.name, 'Chief (SelfB1233)', 'not "@chief.selfb1233.home (SelfB1233)"') +}) + test('#305: "Everyone" in a group counts the Bots it reaches, seated friends\' Bots too, in the right number', async () => { const room = (participants) => indexRoster({ bots: [{ id: 'chief', name: 'Chief', sessionId: 'chief' }], diff --git a/core/test/bot-row-model.test.js b/core/test/bot-row-model.test.js index 717c1343..f0db9110 100644 --- a/core/test/bot-row-model.test.js +++ b/core/test/bot-row-model.test.js @@ -23,6 +23,6 @@ test('#366: the model a row names is the served name of the chosen model, else t test('#366: every own Bot row (the tree draws the same row) takes that title; a friend\'s Bot keeps its own', () => { const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') const row = sidebar.slice(sidebar.indexOf('function BotRow('), sidebar.indexOf('function RemoteMark(')) - assert.match(row, /className: 'bt-row', [^\n]*\n[^\n]*\n\s*'data-bot-id': id, title: remoteRecord \? shown : botRowTitle\(shown, modelOf\(bot, roster\)\?\.name, wide\), onClick/) + assert.match(row, /className: 'bt-row', [^\n]*\n[^\n]*\n\s*'data-bot-id': id, title: remote \? shown : botRowTitle\(shown, modelOf\(bot, roster\)\?\.name, wide\), onClick/) assert.match(sidebar, /function TreeNode\([\s\S]*?h\(BotRow, \{ \.\.\.rowProps, bot, asRow: true/) }) diff --git a/core/test/flybot-client.test.js b/core/test/flybot-client.test.js index 7c2b0d74..6325b66d 100644 --- a/core/test/flybot-client.test.js +++ b/core/test/flybot-client.test.js @@ -210,20 +210,26 @@ test('a face-room sidebar row stays on one line and shows its full title on hove assert.match(styles, /\.bt-row-title\{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\}/) }) -test("a friend's Bot sidebar row is its name and owner on one line, with a second line like own rows (#384)", async () => { +test("a friend's Bot sidebar row is its one name, \"Chief (Bob)\", on one line, with a second line like own rows (#384, #498)", async () => { const { readFileSync } = await import('node:fs') const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n') const styles = readFileSync(new URL('../src/client/styles.js', import.meta.url), 'utf8').replace(/\r\n/g, '\n') const row = sidebar.slice(sidebar.indexOf('function BotRow('), sidebar.indexOf('function RemoteMark(')) - assert.match(row, /const rowName = remoteRecord \? cleanPeerName\(bot\.name, shown\) : shown/) - assert.match(row, /h\('span', \{ className: 'bt-row-title' \}, rowName\)/) - assert.match(row, /owner \? h\('span', \{ className: 'bt-row-owner' \}, owner\) : null/) - assert.match(row, /const owner = remoteRecord \? ownerNameOf\(remoteRecord\) \|\| cleanPeerName\(bot\.remote\.handle\) : ''/) + // The same string as the header, tab, composer placeholder and @ menu: one formatter (#498). + assert.match(row, /const shown = remote \? friendBotTitle\(bot\) : bot\.name/) + assert.match(row, /h\('span', \{ className: 'bt-row-title' \}, shown\)/) + // No second, muted owner part ("Chief" + grey "Bob" read as a different format). + assert.doesNotMatch(row, /bt-row-owner/) + assert.doesNotMatch(styles, /\.bt-row-owner\{/) // Before the first message the second line still says what the row is. - assert.match(row, /const secondLine = awayLine \|\| preview \|\| \(remoteRecord \? t\("Friend's Bot"\) : ''\)/) - // The full "Helper · Bob's Bot" title is on hover and in the label. - assert.match(row, /title: remoteRecord \? shown :/) - assert.match(styles, /\.bt-row-owner\{flex:0 100 auto;[^}]*white-space:nowrap;overflow:hidden;text-overflow:ellipsis\}/) + assert.match(row, /const secondLine = awayLine \|\| preview \|\| \(remote \? t\("Friend's Bot"\) : ''\)/) + // The full name is on hover and in the label. + assert.match(row, /title: remote \? shown :/) + for (const file of ['header.js', 'brand.js', 'user-message.js', 'at-menu.js']) { + const src = readFileSync(new URL(`../src/client/${file}`, import.meta.url), 'utf8') + assert.match(src, /friendBotTitle\(/, `${file} names a friend's chat through friendBotTitle`) + assert.doesNotMatch(src, /ownerName: bot\.remote\.homeName \|\| bot\.remote\.owner/, `${file} builds no name of its own`) + } assert.match(styles, /\.bt-row-remote\{[^}]*color:var\(--bt-ink-2\)\}/) const { DICTIONARY } = await import('../src/client/i18n.js') assert.equal(DICTIONARY.zh["Friend's Bot"], '好友的 Bot') diff --git a/core/test/held-reply.test.js b/core/test/held-reply.test.js index 0d07e9db..1dbb8bfb 100644 --- a/core/test/held-reply.test.js +++ b/core/test/held-reply.test.js @@ -99,10 +99,18 @@ test('after the decision the reply says Sent or Not sent in place, and they diff assert.equal(find(auto, n => n.type === 'button').length, 0) }) -test('a friend\'s Bot with a listed chat is named as the chat list names it; no held reply, no wrapper', () => { - const friend = { id: 'f1', name: 'Chief', remote: { handle: '@chief.selfb1238.home', owner: 'SelfB1238' } } - const drawn = draw(chief([held('pending', { toOwner: undefined })], [friend])) - assert.ok(strings(wrapper(drawn)).includes('Chief → Chief (SelfB1238)')) +test('#498: the card names the person who gets the reply, the same with or without the friend\'s chat; no held reply, no wrapper', () => { + const friend = { id: 'f1', name: 'Chief', remote: { handle: '@chief.selfb1238.home', homeName: 'SelfB1238' } } + // The stored recipient wins over the friend's chat, so a reload (which lists the chat) reads the same. + const live = strings(wrapper(draw(chief([held('pending')])))) + const reloaded = strings(wrapper(draw(chief([held('pending')], [friend])))) + assert.ok(live.includes('Chief → SelfB1238')) + assert.ok(reloaded.includes('Chief → SelfB1238')) + assert.equal(reloaded.some(line => line.includes('Chief (SelfB1238)')), false, 'never two Bot names side by side') + // A hold from before the recipient was stored: the owner of the friend's chat, still a person. + assert.ok(strings(wrapper(draw(chief([held('pending', { toOwner: undefined })], [friend])))).includes('Chief → SelfB1238')) + // No chat and no stored person: the owner from the handle, not the handle. + assert.ok(strings(wrapper(draw(chief([held('pending', { toOwner: undefined })])))).includes('Chief → selfb1238')) assert.equal(wrapper(draw(chief())), undefined) assert.equal(wrapper(draw(chief([held('pending', { turn: 4 })]))), undefined, 'another turn') }) diff --git a/core/test/overlay-approvals.test.js b/core/test/overlay-approvals.test.js index 2c315112..01d3fc60 100644 --- a/core/test/overlay-approvals.test.js +++ b/core/test/overlay-approvals.test.js @@ -114,7 +114,9 @@ test('#260: an approval no open chat shows becomes a pointer to its chat, after const tree = render(FlyBotApprovals, inst) const text = JSON.stringify(tree) assert.match(text, /bt-flybot-pointer/) - assert.match(text, /Helper → Chief \(UiA1415\)/) + // The person who gets it (#418, #498), not the friend's Bot by name. + assert.match(text, /Helper → UiA1415/) + assert.doesNotMatch(text, /Chief \(UiA1415\)/) assert.match(text, /\+1 more/) assert.doesNotMatch(text, /Approve/, 'a pointer has no decision buttons: the chat\'s card does') const find = (node, pred) => (node && typeof node === 'object' ? (pred(node) ? [node] : [...(node.children ?? []), ...(Array.isArray(node) ? node : [])].flatMap(child => find(child, pred))) : []) diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 4922c9a8..257065e8 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -20,7 +20,7 @@ import { viewerRole, } from '../src/host/room-membership.js' import { install as installReach, reach } from '../src/host/reach.js' -import { remoteBotTitle, sharedRoomTitle } from '../src/shared/remote-label.js' +import { friendBotOwner, friendBotTitle, remoteBotTitle, sharedRoomTitle } from '../src/shared/remote-label.js' import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js' import { fenceUntrusted } from '../src/host/delivery.js' import { mentionRows } from '../src/client/at-menu.js' @@ -135,6 +135,25 @@ test('remote-label: owner badge and shared-room title', () => { assert.equal(sharedRoomTitle('Alice'), 'Shared room from Alice') }) +test('#498: one formatter names a friend\'s Bot chat on both homes, and its owner for approval cards', () => { + // A friend's chat as remote-chat.js keeps it: the chat's name, the home's display name, the handle. + const onA = { id: 'r1', name: 'Chief', remote: { handle: '@chief.selfb1233.home', home: '@selfb1233.home', homeName: 'SelfB1233' } } + const onB = { id: 'r2', name: 'Chief', remote: { handle: '@chief.selfa1233.home', home: '@selfa1233.home', homeName: 'SelfA1233' } } + assert.equal(friendBotTitle(onA), 'Chief (SelfB1233)') + assert.equal(friendBotTitle(onB), 'Chief (SelfA1233)') + assert.equal(friendBotOwner(onA), 'SelfB1233') + // The same string remoteBotTitle gives the seat of that Bot in a room. + assert.equal(friendBotTitle(onA), remoteBotTitle({ displayName: 'Chief', ownerName: 'SelfB1233', handle: '@chief.selfb1233.home' })) + // The human owner before the home's label; no owner at all: the one in the handle. + assert.equal(friendBotTitle({ name: 'Coder', remote: { handle: '@coder.bob.home', owner: 'Bob', homeName: 'Family Workspace' } }), 'Coder (Bob)') + assert.equal(friendBotTitle({ name: 'Helper', remote: { handle: '@helper.ann.home' } }), 'Helper (ann)') + assert.equal(friendBotOwner({ name: 'Helper', remote: { handle: '@helper.ann.home' } }), 'ann') + // A seat row carries its own name, so a row already titled is not titled twice. + assert.equal(friendBotTitle({ name: 'Chief (Bob)', remote: { displayName: 'Chief', ownerName: 'Bob', handle: '@chief.bob.home' } }), 'Chief (Bob)') + // Peer strings stay cleaned. + assert.equal(friendBotTitle({ name: 'Evl', remote: { handle: '@x.bob.home', homeName: 'B`ob' } }), 'Evil (Bob)') +}) + test('unpublished Bot is rejected; published remote joins with owner fields', async (t) => { const { rt } = host(t, TEAM) await rt.load() diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 9afd1868..499910cc 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -562,6 +562,8 @@ declare global { /** Sidebar avatar (mark plus optional badges). Prefer this at 24px with badge: false. */ BotAvatar?: FlyBotBotAvatar lookOf?: FlyBotLookOf + /** A friend's Bot chat (a roster Bot with `remote`): its one name, "Chief (Bob)", and its owner, "Bob" (#498). */ + names?: { friendBotTitle(bot?: any): string; friendBotOwner(bot?: any): string } } | undefined } diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 77d759f6..e8fa7f4a 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -57,7 +57,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t A `flybot.session.dock` component also gets the host translator `t`, so a plugin's card can read in the interface language (the social hold card's *Held for your approval: contains …* line, #371; its words are in `i18n.js`). -The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second copy (#260): an approval whose policy a `flybot.session.dock` entry declares (`policy`, e.g. social) is drawn by that dock in its chat, which reports the ids it shows through `claim(ids)`; the overlay skips claimed ids. A docked approval no open chat shows becomes a small pointer (`Sender → Recipient`, `Waiting for approval`, **Review** opens the chat via `actions.openSession`) after 1.2 s, so moving between chats never flashes it; the chat that is open never gets a pointer for its own approvals (each `FlyBotSessionDock` reports its chat through `chatOpened`, #271). An approval's chat is its room, else the chat whose turn a held reply answers (`meta.replyIn`, #418), else the friend's chat it is addressed to, else the Bot that wrote it; the pointer's Review opens that one (#278). The claims live in `approval-claims.js` so a chat cell can claim without the slot components. Any other policy's approval is a card in the overlay, with the same two lines as the dock's card (`Waiting for approval` / `Not sent`, then `Sender → Recipient`; the policy's long reason is the tooltip) and Approve / Reject (Retry for a failed send). The recipient is named from the roster (`UiA1415's Chief`, `Helper · UiB1415's Bot`) when the friend's chat is known, else as the host named it. `senderName` / `targetName` are resolved server-side and a delegated marker follows a relayed send (`hop > 0`). The decision itself is owner-bound in the host (#52); see [core-host](core-host.md). +The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second copy (#260): an approval whose policy a `flybot.session.dock` entry declares (`policy`, e.g. social) is drawn by that dock in its chat, which reports the ids it shows through `claim(ids)`; the overlay skips claimed ids. A docked approval no open chat shows becomes a small pointer (`Sender → Recipient`, `Waiting for approval`, **Review** opens the chat via `actions.openSession`) after 1.2 s, so moving between chats never flashes it; the chat that is open never gets a pointer for its own approvals (each `FlyBotSessionDock` reports its chat through `chatOpened`, #271). An approval's chat is its room, else the chat whose turn a held reply answers (`meta.replyIn`, #418), else the friend's chat it is addressed to, else the Bot that wrote it; the pointer's Review opens that one (#278). The claims live in `approval-claims.js` so a chat cell can claim without the slot components. Any other policy's approval is a card in the overlay, with the same two lines as the dock's card (`Waiting for approval` / `Not sent`, then `Sender → Recipient`; the policy's long reason is the tooltip) and Approve / Reject (Retry for a failed send). The recipient is the person who gets it (#418, #498): the host's `recipientName`, else the owner of the friend's chat (`friendBotOwner`), else as the host named it, so a card reads `Chief → SelfB1233`, never two Bot names. `senderName` / `targetName` are resolved server-side and a delegated marker follows a relayed send (`hop > 0`). The decision itself is owner-bound in the host (#52); see [core-host](core-host.md). ### Face-to-face room lead @@ -98,7 +98,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### One sidebar list -- `TeamSidebar` lists local Bots, listed remote chats (`isListedRemoteChat`), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows the Bot's name (`bt-row-title`), then its owner (`bt-row-owner`, muted, shrinks first) and the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message, and the full `remoteBotTitle` ("Helper (Bob)") is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. +- `TeamSidebar` lists local Bots, listed remote chats (`isListedRemoteChat`), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message, and the full name is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. ### Agent inbox (#349, step 1) @@ -150,7 +150,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A person's line keeps its line breaks (#455): `.bt-person-msg .bt-bubble :is(p,li)` is `white-space: pre-line`, as the sender's own `pre-wrap` bubble, since Markdown would join single newlines. A trigger whose source has `shown: true` (a line a running round already drew, host #455) draws nothing. Covered by `core/test/face-people.test.js`. - 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). The chat header, tab title, composer placeholder, row tooltip, approval overlay (`flybot-slots.js` `recipientName`), 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) and in a face room `faceSeatLabel` / `at-menu.js` `faceLabel` (`Chief (Bob)`, my own Bot `Chief (SelfB)` with my name, #457) all use it. `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). +- **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). - 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 `