diff --git a/changelog.d/457-face-room-person-first.fixed.md b/changelog.d/457-face-room-person-first.fixed.md new file mode 100644 index 00000000..0e58a7b4 --- /dev/null +++ b/changelog.d/457-face-room-person-first.fixed.md @@ -0,0 +1 @@ +Face-to-face rooms are person-first: one tile and one Members row per person (Members counts people, and only people are "(you)"), every Bot named "Bot (person)" including your own, a mention of a friend's Bot stays with that Bot after it leaves, bubble headers show just the name with the handle on hover, and your own typed line keeps your name and colour. Auto-send in a face room is now one switch per Bot, off by default ([#457](https://github.com/ThinkFlowLab/FlyBot/issues/457)). diff --git a/core/client.js b/core/client.js index 4faa3e6e..b61811de 100644 --- a/core/client.js +++ b/core/client.js @@ -315,11 +315,10 @@ var ZH = { "Admin leads": "管理员主持", "Plain messages go to the admin, who calls others with @Name.": "普通消息先给管理员,由管理员用 @名字 点名其他成员。", Everyone: "所有人", - "Every Bot in this room": "房间里的所有 Bot", "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", "Members · {count}": "成员 · {count}", "{name} (you)": "{name}(你)", - "{bot} (you)": "{bot}(你的)", + Someone: "某人", "Leave the group without an admin": "让群聊不设管理员", Unset: "取消", "Make admin": "设为管理员", @@ -4001,6 +4000,7 @@ div:has(> nav [class*="_marks"]){display:none!important} .bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} .bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} .bt-urow{display:flex;justify-content:flex-end} +.bt-urow>.bt-lead{align-self:flex-end;margin-left:6px} .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} @@ -4990,6 +4990,65 @@ var SECRET_CSS = ` @media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} `; +// src/client/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { + const records = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human"); + const seat = (p) => { + const local = p.remote ? void 0 : roster.byId[p.id]; + return { + 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"), + mine: Boolean(local) + }; + }; + const seats = records.filter((p) => p.leftSeq === void 0).map(seat); + for (const id of room2.members ?? []) { + const bot = roster.byId[id]; + if (!bot || seats.some((item) => item.local?.id === id)) continue; + seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); + } + if (!left) return seats; + const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); + return [...seats, ...gone]; +} +function faceSeatLabel(seat) { + if (!seat.person) return seat.bot; + return `${seat.bot} (${seat.person})`; +} +function faceBotLabel(room2, roster, bot) { + if (room2?.origin !== "face-to-face" || !bot) return bot?.name; + const seat = faceSeats(room2, roster, { left: true }).find((item) => item.local?.id === bot.id || item.record?.id !== void 0 && item.record.id === bot.id); + return seat ? faceSeatLabel(seat) : bot.name; +} +function facePersonKey(seat) { + if (seat.mine) return ":you"; + const record = seat.record ?? {}; + const home = String(record.ownerHome || record.home || "").trim().toLowerCase(); + if (home) return `home:${home}`; + const parts = String(record.handle || "").trim().toLowerCase().replace(/^@+/, "").split("."); + if (parts.length >= 3 && parts.at(-1) === "home") return `home:${parts.slice(-2).join(".")}`; + return `name:${(ownerNameOf(record) || seat.person || seat.key).toLowerCase()}`; +} +function facePeople(room2, roster) { + const people = []; + for (const seat of faceSeats(room2, roster)) { + const key = facePersonKey(seat); + const known = people.find((person) => person.key === key); + if (known) { + known.seats.push(seat); + continue; + } + people.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); + } + return [...people.filter((person) => person.mine), ...people.filter((person) => !person.mine)]; +} +function facePersonLabel(person) { + return person.mine ? t("{name} (you)", { name: person.name }) : person.name; +} + // src/client/held-reply.js var import_react7 = require("react"); var call3 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); @@ -5362,7 +5421,7 @@ function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi, const name = (0, import_react8.createElement)( "span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, - bot.name, + faceBotLabel(room2, roster, bot) ?? bot.name, room2?.admin === bot.id ? (0, import_react8.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ); return (0, import_react8.createElement)( @@ -5376,8 +5435,10 @@ function remoteSpeaker(source, roster, sessionId) { const handle = source.remote.handle; const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); const chat = (roster.bots ?? []).find((bot2) => bot2.remote?.handle === handle); - const seat = faceSeatLook(roster.roomsById?.[sessionId], handle); - const name = remoteBotTitle({ + const room2 = roster.roomsById?.[sessionId]; + const seat = faceSeatLook(room2, handle); + const faceSeat = room2?.origin === "face-to-face" ? faceSeats(room2, roster, { left: true }).find((item) => !item.mine && normRemoteHandle(item.record?.handle) === normRemoteHandle(handle)) : void 0; + const name = faceSeat ? faceSeatLabel(faceSeat) : remoteBotTitle({ displayName: chat?.name ?? member?.name ?? source.senderName ?? handle, ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, handle, @@ -5403,6 +5464,8 @@ function faceSeatLook(room2, handle) { } var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { + const room2 = roster.roomsById?.[sessionId]; + const face = room2?.origin === "face-to-face"; const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId); const files = Array.isArray(source.attachments) ? source.attachments : []; const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; @@ -5426,7 +5489,8 @@ function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi return (0, import_react8.createElement)( "div", { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, - (0, import_react8.createElement)( + // A face room's header is the one name alone; the handle is on hover only (#457). + face ? (0, import_react8.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react8.createElement)( "span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, name, @@ -5914,7 +5978,8 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi "div", { className: "bt-ububble" }, (0, import_react8.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) - ) + ), + room2?.origin === "face-to-face" ? (0, import_react8.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react8.createElement)(PersonMark, { name: who || void 0 })) : null ); } if (reply.kind === "failed") { @@ -7755,7 +7820,7 @@ function mentionRows(roster, sessionId, query = "") { const face = isFaceRoom(here.room); const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0); const bots = here.room ? [ - ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name, true) } : bot), + ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot), ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), // A remote seat already listed under its handle (a duplicate participant record) is offered once. ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({ @@ -7764,7 +7829,7 @@ function mentionRows(roster, sessionId, query = "") { kind: "bot", remote: p, handle: p.handle, - ...face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot"), false) } : {} + ...face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) } : {} })) ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id); const needle = query.trim().toLowerCase(); @@ -7784,6 +7849,7 @@ function mentionRows(roster, sessionId, query = "") { } var isFaceRoom = (room2) => room2?.origin === "face-to-face"; var memberCount = (count) => t(count === 1 ? "{count} member" : "{count} members", { count }); +var botCount = (count) => count === 1 ? t("1 Bot") : t("{count} Bots", { count }); var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase(); var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null; var myOwnerName = (roster, live, bot) => { @@ -7799,11 +7865,7 @@ var uniqueBy = (list, key) => { return true; }); }; -var faceLabel = (person, bot, mine) => { - if (mine) return t("{bot} (you)", { bot }); - if (!person) return bot; - return `${bot} (${person})`; -}; +var faceLabel = (person, bot) => person ? `${bot} (${person})` : bot; function chatRows(roster, list, sessionId) { const here = conversationOf(roster, sessionId); if (here === null) return []; @@ -7840,8 +7902,8 @@ function atSources({ roster, sessions, launched, iconFor }) { ...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 } : {}, - // A face room's local member list holds only this home's Bots, so a count would be wrong (#305). - ...row.room ? { description: face ? t("Every Bot in this room") : memberCount(row.count) } : {}, + // A face room counts the Bots in it, mine and everyone else's (#305, #457). + ...row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {}, icon: iconFor(row), section: t("Bots"), value: row.text @@ -11202,33 +11264,6 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } ); } -// src/client/face-seats.js -function faceSeats(room2, roster) { - const live = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human" && p.leftSeq === void 0); - const seats = live.map((p) => { - const local = p.remote ? void 0 : roster.byId[p.id]; - return { - 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"), - mine: Boolean(local) - }; - }); - for (const id of room2.members ?? []) { - const bot = roster.byId[id]; - if (!bot || seats.some((seat) => seat.local?.id === id)) continue; - seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); - } - return seats; -} -function faceSeatLabel(seat) { - if (seat.mine) return t("{bot} (you)", { bot: seat.bot }); - if (!seat.person) return seat.bot; - return `${seat.bot} (${seat.person})`; -} - // src/client/group-settings.js var import_react27 = require("react"); var ROOM_MODES = [ @@ -11236,21 +11271,34 @@ var ROOM_MODES = [ ["everyone", "Everyone", "Every member answers in turn, admin first."] ]; var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone"; -var personKey = (seat) => { - const record = seat.record ?? {}; - const home = String(record.ownerHome || record.home || "").trim().toLowerCase(); - if (home) return `home:${home}`; - const parts = String(record.handle || "").trim().toLowerCase().replace(/^@+/, "").split("."); - if (parts.length >= 3 && parts.at(-1) === "home") return `home:${parts.slice(-2).join(".")}`; - return `name:${(ownerNameOf(record) || seat.person || seat.key).toLowerCase()}`; -}; function faceRoomCount(room2, roster) { - const seats = faceSeats(room2, roster); - const people = new Set(seats.map((seat) => seat.mine ? ":you" : personKey(seat))); - const persons = people.size === 1 ? t("1 person") : t("{count} people", { count: people.size }); - const bots = seats.length === 1 ? t("1 Bot") : t("{count} Bots", { count: seats.length }); + const people = facePeople(room2, roster).length; + const seats = faceSeats(room2, roster).length; + const persons = people === 1 ? t("1 person") : t("{count} people", { count: people }); + const bots = seats === 1 ? t("1 Bot") : t("{count} Bots", { count: seats }); return `${persons} · ${bots}`; } +function FacePersonRow({ person, onOpenBot, 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_react27.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_react27.createElement)("span", { key: seat.key, className: "bt-face-bot" }, seat.bot); + return index === 0 ? [name] : [(0, import_react27.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), name]; + }); + return (0, import_react27.createElement)( + "div", + { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 }, + (0, import_react27.createElement)( + "span", + { className: "bt-gs-who bt-face-person" }, + (0, import_react27.createElement)(PersonMark, { size, name: person.name }), + (0, import_react27.createElement)( + "span", + { className: "bt-face-person-text" }, + (0, import_react27.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)), + (0, import_react27.createElement)("span", { className: "bt-face-bots" }, bots) + ) + ) + ); +} function roomSubtitle(room2, roster) { if (room2.origin === "face-to-face") return faceRoomCount(room2, roster); const admin = roster.byId[room2.admin]; @@ -11269,7 +11317,7 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { const remotes = live.filter((p) => p.remote && p.leftSeq === void 0); const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); const face = room2.origin === "face-to-face"; - const seats = face ? faceSeats(room2, roster) : []; + const people = face ? facePeople(room2, roster) : []; const shared2 = humans.length > 1; const chiefs = new Set((roster.humans ?? []).map((person) => person.designatedChiefId).filter(Boolean)); const outside = roster.bots.filter((bot) => { @@ -11356,17 +11404,8 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { ), face ? (0, import_react27.createElement)( Card, - { title: t("Members · {count}", { count: seats.length }) }, - seats.map((seat) => (0, import_react27.createElement)( - "div", - { key: seat.key, className: "bt-set-row bt-bs-row bt-gs-member", "data-flybot": "face-seat" }, - (0, import_react27.createElement)( - "span", - { className: "bt-gs-who" }, - (0, import_react27.createElement)(BotAvatar, { bot: seat.local ?? lookBotForRemote(seat.record, roster), size: 26, badge: false, live: false }), - (0, import_react27.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(seat)) - ) - )) + { title: t("Members · {count}", { count: people.length }) }, + people.map((person) => (0, import_react27.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot })) ) : null, face ? null : (0, import_react27.createElement)( Card, @@ -11556,6 +11595,13 @@ var GROUP_SETTINGS_CSS = ` .bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px} .bt-gs-add{gap:8px;color:var(--bt-ink-2)} +.bt-face-person{cursor:default} +.bt-face-person:hover .bt-row-title{text-decoration:none} +.bt-face-person-text{display:flex;flex-direction:column;min-width:0} +.bt-face-bots{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:16px;color:var(--bt-ink-3)} +.bt-face-bot{padding:0;border:0;background:none;color:inherit;font:inherit} +button.bt-face-bot{cursor:pointer} +button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-underline-offset:2px} .bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out} .bt-bs-stack .bt-seg{align-self:stretch} @media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}} @@ -13935,20 +13981,17 @@ function SharedRoomApprovals({ room: room2 }) { ); } function FaceMembers({ room: room2, roster, actions }) { - const seats = faceSeats(room2, roster); + const people = facePeople(room2, roster); return (0, import_react35.createElement)( "div", null, - (0, import_react35.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: seats.length })), - (0, import_react35.createElement)("div", { className: "bt-members", role: "list" }, seats.map((seat) => { - const label = faceSeatLabel(seat); - const avatar = (0, import_react35.createElement)(BotAvatar, { bot: seat.local ?? lookBotForRemote(seat.record, roster), size: 26, badge: false, live: false }); - return (0, import_react35.createElement)( - "div", - { key: seat.key, role: "listitem", "data-flybot": "face-seat", "data-mine": seat.mine || void 0 }, - (0, import_react35.createElement)("div", { className: "bt-member" }, seat.local ? (0, import_react35.createElement)("button", { type: "button", className: "bt-member-name", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => actions.openSession(seat.local.id) }, avatar, (0, import_react35.createElement)("span", null, label)) : (0, import_react35.createElement)("span", { className: "bt-member-name" }, avatar, (0, import_react35.createElement)("span", null, label))) - ); - })) + (0, import_react35.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people.length })), + (0, import_react35.createElement)("div", { className: "bt-members", role: "list" }, people.map((person) => (0, import_react35.createElement)(FacePersonRow, { + key: person.key, + person, + className: "bt-member", + onOpenBot: (id) => actions.openSession(id) + }))) ); } function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) { @@ -15835,8 +15878,9 @@ function mentionableBots(roster, sessionId, { fromFriend = false } = {}) { } 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 })); - const seats = faceSeats(room2, roster); - const ordered = [...seats.filter((seat) => seat.mine !== fromFriend), ...seats.filter((seat) => seat.mine === fromFriend)]; + const all = faceSeats(room2, roster, { left: true }); + const seats = all.filter((seat) => !seat.left); + const ordered = [...seats.filter((seat) => seat.mine !== fromFriend), ...seats.filter((seat) => seat.mine === fromFriend), ...all.filter((seat) => seat.left)]; const bots = ordered.flatMap((seat) => { const target = seat.local ?? { id: seat.key, name: seat.bot, kind: "bot", remote: seat.record }; const record = { ...seat.record, ownerName: seat.record.ownerName || seat.person }; @@ -15920,22 +15964,24 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma const multiHuman = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).length > 1; const incoming = author && !author.mine; const person = incoming && author.authorKind === "human" && !author.ownerName; - const held = Boolean(room2 && room2.origin === "face-to-face" && !incoming && (room2.pendingCount ?? 0) > 0); + const faceMine = Boolean(room2?.origin === "face-to-face" && !incoming); + const myName = faceMine ? facePeople(room2, roster).find((entry) => entry.mine)?.name || cleanPeerName(roster.self?.displayName, "") || author?.name || "" : ""; return (0, import_react39.createElement)( "div", { className: incoming ? "bt-umsg bt-umsg-other" : "bt-umsg", "data-steering": steering || void 0, - "data-held": held || void 0, + "data-face-mine": faceMine || void 0, "data-author": author?.authorId, "data-author-kind": author?.authorKind, "data-author-name": author?.name }, person ? (0, import_react39.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react39.createElement)(PersonMark, { name: author.name })) : null, + faceMine ? (0, import_react39.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react39.createElement)(PersonMark, { name: myName || void 0 })) : null, (0, import_react39.createElement)( "div", { className: "bt-ustack" }, - author && (incoming || multiHuman) ? (0, import_react39.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react39.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, + faceMine && myName ? (0, import_react39.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react39.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react39.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, attachments.length > 0 ? (0, import_react39.createElement)(Attachments, { attachments, renderMessageImages }) : null, text !== "" || rest.length > 0 ? (0, import_react39.createElement)( "div", @@ -15958,7 +16004,7 @@ var USER_MESSAGE_CSS = ` .bt-composer-as{font-size:12px;line-height:16px;color:var(--bt-ink-3);padding:0 8px;align-self:center;white-space:nowrap} .bt-ustack{min-width:0;max-width:min(calc(var(--dsh-chat-content-width,748px) * .702),82%);display:flex;flex-direction:column;align-items:flex-end;gap:8px} .bt-ubody{max-width:100%;box-sizing:border-box;background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);white-space:pre-wrap;word-break:break-word} -.bt-umsg[data-held] .bt-ubody{background:color-mix(in srgb,var(--bt-user) 28%,var(--bt-card,#fff));color:var(--bt-ink);border:.5px dashed var(--bt-line-2,rgba(20,20,20,.22));opacity:.88} +.bt-umsg[data-face-mine]>.bt-lead{align-self:flex-end;margin-left:4px} .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} diff --git a/core/src/client/at-menu.js b/core/src/client/at-menu.js index bc655fe0..c190297f 100644 --- a/core/src/client/at-menu.js +++ b/core/src/client/at-menu.js @@ -33,14 +33,14 @@ export function mentionRows(roster, sessionId, query = '') { const bots = here.room ? [ ...here.room.members.map(id => roster.byId[id]).filter(Boolean) - .map(bot => (face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name, true) } : bot)), + .map(bot => (face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot)), ...live.filter(p => p.kind === 'human') .map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role })), // A remote seat already listed under its handle (a duplicate participant record) is offered once. ...uniqueBy(live.filter(p => p.remote), p => p.handle || p.id) .map(p => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle, - ...(face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot'), false) } : {}), + ...(face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot')) } : {}), })), ] : listedChatBots(roster).filter(bot => bot.id !== here.self.id) @@ -71,6 +71,7 @@ export function mentionRows(roster, sessionId, query = '') { const isFaceRoom = room => room?.origin === 'face-to-face' const memberCount = count => t(count === 1 ? '{count} member' : '{count} members', { count }) +const botCount = count => (count === 1 ? t('1 Bot') : t('{count} Bots', { count })) const normHandle = handle => String(handle ?? '').trim().replace(/^@+/, '').toLowerCase() // A friend's Bot chat in the roster: its remote record (the Bot itself in older rosters). export const remoteRecord = bot => (bot?.remote ? (bot.remote === true ? bot : bot.remote) : null) @@ -88,14 +89,10 @@ const uniqueBy = (list, key) => { return true }) } -// In a face-to-face room a Bot is named as the Members panel names it: "Chief (Ann)", -// "Chief (you)" (#229, #305, #384). Only the label changes; the inserted "@Name" stays the -// one the room routes on. -const faceLabel = (person, bot, mine) => { - if (mine) return t('{bot} (you)', { bot }) - if (!person) return bot - return `${bot} (${person})` -} +// In a face-to-face room a Bot is named as the Members panel names it, mine and a friend's +// alike: "Chief (Ann)", "Chief (Bob)" (#229, #305, #384, #457); "(you)" is for a person, never a +// Bot. Only the label changes; the inserted "@Name" stays the one the room routes on. +const faceLabel = (person, bot) => (person ? `${bot} (${person})` : bot) // The chats a message here can reference: every other Bot's chat, then the latest // conversations outside the team. Group chats stay out: a group's Session holds only @@ -146,8 +143,8 @@ export function atSources({ roster, sessions, launched, iconFor }) { ...(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 } : {}), - // A face room's local member list holds only this home's Bots, so a count would be wrong (#305). - ...(row.room ? { description: face ? t('Every Bot in this room') : memberCount(row.count) } : {}), + // A face room counts the Bots in it, mine and everyone else's (#305, #457). + ...(row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {}), icon: iconFor(row), section: t('Bots'), value: row.text, diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 61e30097..98c1c6e9 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -9,7 +9,8 @@ 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, remoteBotTitle } from '../shared/remote-label.js' +import { isListedRemoteChat, normRemoteHandle, 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' import { call } from './api.js' @@ -279,7 +280,8 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq }) if (held === 'held' || isHeldLine(room, text, bot)) return null const lead = h('button', { type: 'button', className: 'bt-lead', 'aria-label': t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, h(BotMark, { bot, size: 22 })) - const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name, + // In a face room my Bot reads as everyone's does, "Chief (Ann)" (#457). + const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, faceBotLabel(room, roster, bot) ?? bot.name, room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null) return h('div', { className: 'bt-group-msg' }, name, h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead })) @@ -292,10 +294,16 @@ export function remoteSpeaker(source, roster, sessionId) { const handle = source.remote.handle const member = (roster.remoteBots ?? []).find(bot => bot.handle === handle) const chat = (roster.bots ?? []).find(bot => bot.remote?.handle === handle) + const room = roster.roomsById?.[sessionId] // In a face-to-face room the Bot's seat carries its owner's look (Bot Social members[]): the // bubble draws that look, as the Members panel and the waiting tiles do (#182). - const seat = faceSeatLook(roster.roomsById?.[sessionId], handle) - const name = remoteBotTitle({ + const seat = faceSeatLook(room, handle) + // ...and its name, as Members and the @ menu name it: "Chief (Bob)", never a stored label plus a + // lowercase id from its handle (#457). A seat that left still names it. + const faceSeat = room?.origin === 'face-to-face' + ? faceSeats(room, roster, { left: true }).find(item => !item.mine && normRemoteHandle(item.record?.handle) === normRemoteHandle(handle)) + : undefined + const name = faceSeat ? faceSeatLabel(faceSeat) : remoteBotTitle({ displayName: chat?.name ?? member?.name ?? source.senderName ?? handle, ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, handle, @@ -327,6 +335,8 @@ function faceSeatLook(room, handle) { const cleanCarrier = value => (typeof value === 'string' && value.trim() && !/[@()]/.test(value) ? value.trim() : '') export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { + const room = roster.roomsById?.[sessionId] + const face = room?.origin === 'face-to-face' const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId) const files = Array.isArray(source.attachments) ? source.attachments : [] // The friend typed this line themselves (Bot Social attested it; the host set `typedBy`): it is the @@ -345,8 +355,11 @@ export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey ? h('button', { type: 'button', className: 'bt-lead', title: handle, 'aria-label': t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : h('span', { className: 'bt-lead', title: handle }, mark) return h('div', { className: 'bt-group-msg bt-remote-msg', 'data-remote': handle }, - h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, name, - h('span', { className: 'bt-remote-handle', title: source.remote.home ?? handle, 'aria-label': t('Friend\'s Bot') }, h(GlobeMark), handle)), + // A face room's header is the one name alone; the handle is on hover only (#457). + face + ? h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) }, title: handle }, name) + : h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, name, + h('span', { className: 'bt-remote-handle', title: source.remote.home ?? handle, 'aria-label': t('Friend\'s Bot') }, h(GlobeMark), handle)), h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) } @@ -848,10 +861,12 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions if (reply.other) { return h(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi }) } + // My own line in a face room has my person mark too, as everyone else's (#457). return h('div', { key: `user-${index}`, className: 'bt-urow', 'data-author': reply.authorId, 'data-author-name': who }, who ? h('span', { className: 'bt-uauthor' }, who) : null, h('div', { className: 'bt-ububble' }, - h(MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: 'compact' }))) + h(MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: 'compact' })), + room?.origin === 'face-to-face' ? h('span', { className: 'bt-lead', title: who || undefined }, h(PersonMark, { name: who || undefined })) : null) } if (reply.kind === 'failed') { return h('div', { key: `${reply.botId}-${index}`, className: 'bt-event bt-event-error', role: 'status' }, diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index 965a9ef2..77c5eefe 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -6,7 +6,7 @@ import { botState, BotMark, BotAvatar, RoomAvatar, lookBotForRemote } from './ma import { GlobeIcon, Favicon } from './browser-pane.js' import { useEscape } from './overlay-hooks.js' import { BotSettingsForm } from './bot-settings.js' -import { GroupSettingsForm, ROOM_MODES, faceSeatLabel, faceSeats, roomSubtitle, shownMode } from './group-settings.js' +import { FacePersonRow, GroupSettingsForm, ROOM_MODES, facePeople, roomSubtitle, shownMode } from './group-settings.js' import { ModelMenu, modelOf } from './model-menu.js' import { FolderMenu } from './folder-menu.js' import { MemoryRow } from './memory-panel.js' @@ -149,19 +149,15 @@ function SharedRoomApprovals({ room }) { h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject'))))) } -// People first, each with the Bot they brought; my own Bot opens its chat (#229). +// One row per person, each with the Bots they brought; my own Bot opens its chat (#229, #457). +// The count is people, not Bots. function FaceMembers({ room, roster, actions }) { - const seats = faceSeats(room, roster) + const people = facePeople(room, roster) return h('div', null, - h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: seats.length })), - h('div', { className: 'bt-members', role: 'list' }, seats.map((seat) => { - const label = faceSeatLabel(seat) - const avatar = h(BotAvatar, { bot: seat.local ?? lookBotForRemote(seat.record, roster), size: 26, badge: false, live: false }) - return h('div', { key: seat.key, role: 'listitem', 'data-flybot': 'face-seat', 'data-mine': seat.mine || undefined }, - h('div', { className: 'bt-member' }, seat.local - ? h('button', { type: 'button', className: 'bt-member-name', title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => actions.openSession(seat.local.id) }, avatar, h('span', null, label)) - : h('span', { className: 'bt-member-name' }, avatar, h('span', null, label)))) - }))) + h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: people.length })), + h('div', { className: 'bt-members', role: 'list' }, people.map(person => h(FacePersonRow, { + key: person.key, person, className: 'bt-member', onOpenBot: id => actions.openSession(id), + })))) } function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) { diff --git a/core/src/client/face-seats.js b/core/src/client/face-seats.js index b310ccbd..f69ae271 100644 --- a/core/src/client/face-seats.js +++ b/core/src/client/face-seats.js @@ -1,14 +1,17 @@ // Who sits in a face-to-face room, named one way everywhere: the Members panel, the waiting -// tiles and the @ chips in a bubble (#229, #305). Pure, so the bubble module can use it. +// 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' -// A face-to-face room lists people, each with the Bot they brought, named one way for every -// member: "Chief (Bob)", "Chief (you)" (#229, #384). Names come from the participant records -// (server member fields for a friend, the roster for my own Bot), never from message text. -export function faceSeats(room, roster) { - const live = (room.presentedParticipants ?? room.participants ?? []).filter(p => p.kind !== 'human' && p.leftSeq === undefined) - const seats = live.map((p) => { +// 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 +// the participant records (server member fields for a friend, the roster for my own Bot), never +// from message text. `left: true` keeps the seats of people who left, after the live ones, so a +// line sent before a leave still names the Bot it named (#457). +export function faceSeats(room, roster, { left = false } = {}) { + const records = (room.presentedParticipants ?? room.participants ?? []).filter(p => p.kind !== 'human') + const seat = (p) => { const local = p.remote ? undefined : roster.byId[p.id] return { key: p.handle || p.id, @@ -18,18 +21,57 @@ export function faceSeats(room, roster) { bot: local ? local.name : cleanPeerName(p.displayName || p.name, 'Bot'), mine: Boolean(local), } - }) + } + const seats = records.filter(p => p.leftSeq === undefined).map(seat) for (const id of room.members ?? []) { const bot = roster.byId[id] - if (!bot || seats.some(seat => seat.local?.id === id)) continue + if (!bot || seats.some(item => item.local?.id === id)) continue seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ''), bot: bot.name, mine: true }) } - return seats + if (!left) return seats + const gone = records.filter(p => p.leftSeq !== undefined && !seats.some(item => item.key === (p.handle || p.id))).map(item => ({ ...seat(item), left: true })) + return [...seats, ...gone] } -// One name per Bot (#384, #305): "Chief (Bob)", and my own "Chief (you)". +// One name per Bot (#384, #305, #457): "Chief (Bob)", and my own the same way, "Chief (Ann)". +// "(you)" belongs to a person, never to a Bot. export function faceSeatLabel(seat) { - if (seat.mine) return t('{bot} (you)', { bot: seat.bot }) if (!seat.person) return seat.bot return `${seat.bot} (${seat.person})` } + +// My own Bot as the room names it (#457): "Chief (Ann)", the person being the owner name Bot +// Social stamped on its seat, else my own name. Outside a face room a Bot is its plain name. +export function faceBotLabel(room, roster, bot) { + if (room?.origin !== 'face-to-face' || !bot) return bot?.name + const seat = faceSeats(room, roster, { left: true }).find(item => item.local?.id === bot.id || (item.record?.id !== undefined && item.record.id === bot.id)) + return seat ? faceSeatLabel(seat) : bot.name +} + +// People are told apart by home, not by display name: two owners may both be "Alex". +export function facePersonKey(seat) { + if (seat.mine) return ':you' + const record = seat.record ?? {} + const home = String(record.ownerHome || record.home || '').trim().toLowerCase() + if (home) return `home:${home}` + const parts = String(record.handle || '').trim().toLowerCase().replace(/^@+/, '').split('.') + if (parts.length >= 3 && parts.at(-1) === 'home') return `home:${parts.slice(-2).join('.')}` + return `name:${(ownerNameOf(record) || seat.person || seat.key).toLowerCase()}` +} + +// The room person-first (#457): one entry per person, mine first, each with the Bots they brought. +export function facePeople(room, roster) { + const people = [] + for (const seat of faceSeats(room, roster)) { + const key = facePersonKey(seat) + const known = people.find(person => person.key === key) + if (known) { known.seats.push(seat); continue } + people.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, '') : '') || t('Someone'), mine: seat.mine, seats: [seat] }) + } + return [...people.filter(person => person.mine), ...people.filter(person => !person.mine)] +} + +// "Ann (you)" for me, "Bob" for anyone else: "(you)" only ever on a person (#457). +export function facePersonLabel(person) { + return person.mine ? t('{name} (you)', { name: person.name }) : person.name +} diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index e3da3f5a..9ffc521d 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -1,12 +1,12 @@ import { createElement as h, useEffect, useState } from 'react' import { isMainOf } from './sources.js' import { PlusIcon, ChevronRightIcon } from './icons.js' -import { AdminStar, BotAvatar, BotMark, RoomAvatar, lookBotForRemote } from './mark.js' +import { AdminStar, BotAvatar, BotMark, PersonMark, RoomAvatar, lookBotForRemote } from './mark.js' import { Card, Row, InlineText, SwitchRow } from './bot-settings.js' import { Segmented } from './motion.js' import { FolderMenu } from './folder-menu.js' import { t } from './i18n.js' -import { ownerNameOf, remoteBotTitle } from '../shared/remote-label.js' +import { remoteBotTitle } from '../shared/remote-label.js' export const ROOM_MODES = [ ['admin', 'Admin leads', 'Plain messages go to the admin, who calls others with @Name.'], @@ -16,30 +16,36 @@ export const ROOM_MODES = [ // Without an admin the host answers as everyone, whatever the stored mode says. export const shownMode = (room, roster) => (roster.byId[room.admin] ? room.mode : 'everyone') -// faceSeats and faceSeatLabel live in face-seats.js so the chat bubble can name a mention the same way. -import { faceSeats, faceSeatLabel } from './face-seats.js' +// faceSeats, faceSeatLabel and facePeople live in face-seats.js so the chat bubble can name a mention the same way. +import { faceSeats, faceSeatLabel, facePeople, facePersonLabel } from './face-seats.js' -export { faceSeats, faceSeatLabel } - -const personKey = (seat) => { - const record = seat.record ?? {} - const home = String(record.ownerHome || record.home || '').trim().toLowerCase() - if (home) return `home:${home}` - const parts = String(record.handle || '').trim().toLowerCase().replace(/^@+/, '').split('.') - if (parts.length >= 3 && parts.at(-1) === 'home') return `home:${parts.slice(-2).join('.')}` - return `name:${(ownerNameOf(record) || seat.person || seat.key).toLowerCase()}` -} +export { faceSeats, faceSeatLabel, facePeople } // A face room says what it counts, people and Bots, not an ambiguous "2 members" (#306). export function faceRoomCount(room, roster) { - const seats = faceSeats(room, roster) - // People are told apart by home, not by display name: two owners may both be "Alex". - const people = new Set(seats.map(seat => (seat.mine ? ':you' : personKey(seat)))) - const persons = people.size === 1 ? t('1 person') : t('{count} people', { count: people.size }) - const bots = seats.length === 1 ? t('1 Bot') : t('{count} Bots', { count: seats.length }) + const people = facePeople(room, roster).length + const seats = faceSeats(room, roster).length + const persons = people === 1 ? t('1 person') : t('{count} people', { count: people }) + const bots = seats === 1 ? t('1 Bot') : t('{count} Bots', { count: seats }) return `${persons} · ${bots}` } +// One row per person (#457): "Ann (you) · Chief, Researcher", my own Bots each opening its chat. +export function FacePersonRow({ person, onOpenBot, className = 'bt-set-row bt-bs-row bt-gs-member', size = 26 }) { + 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) + return index === 0 ? [name] : [h('span', { key: `${seat.key}-sep` }, t(', ')), name] + }) + return h('div', { className, role: 'listitem', 'data-flybot': 'face-person', 'data-mine': person.mine || undefined }, + h('span', { className: 'bt-gs-who bt-face-person' }, + h(PersonMark, { size, name: person.name }), + h('span', { className: 'bt-face-person-text' }, + h('span', { className: 'bt-row-title' }, facePersonLabel(person)), + h('span', { className: 'bt-face-bots' }, bots)))) +} + export function roomSubtitle(room, roster) { if (room.origin === 'face-to-face') return faceRoomCount(room, roster) const admin = roster.byId[room.admin] @@ -62,7 +68,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const remotes = live.filter(p => p.remote && p.leftSeq === undefined) const members = room.members.map(id => roster.byId[id]).filter(Boolean) const face = room.origin === 'face-to-face' - const seats = face ? faceSeats(room, roster) : [] + const people = face ? facePeople(room, roster) : [] const shared = humans.length > 1 const chiefs = new Set((roster.humans ?? []).map(person => person.designatedChiefId).filter(Boolean)) const outside = roster.bots.filter((bot) => { @@ -105,11 +111,8 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { h('div', { className: 'bt-set-row bt-bs-row' }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Folder'))), h(FolderMenu, { value: room.workDir, group: true, actions, run, onPick: workDir => update({ workDir }) }))), - face ? h(Card, { title: t('Members · {count}', { count: seats.length }) }, - seats.map(seat => h('div', { key: seat.key, className: 'bt-set-row bt-bs-row bt-gs-member', 'data-flybot': 'face-seat' }, - h('span', { className: 'bt-gs-who' }, - h(BotAvatar, { bot: seat.local ?? lookBotForRemote(seat.record, roster), size: 26, badge: false, live: false }), - h('span', { className: 'bt-row-title' }, faceSeatLabel(seat)))))) : null, + face ? h(Card, { title: t('Members · {count}', { count: people.length }) }, + people.map(person => h(FacePersonRow, { key: person.key, person, onOpenBot: openBot }))) : null, face ? null : h(Card, { title: t('Members · {count}', { count: members.length + humans.length + remotes.length }) }, humans.map((person) => h('div', { key: person.id, className: 'bt-set-row bt-bs-row bt-gs-member' }, h('span', { className: 'bt-gs-who' }, @@ -226,6 +229,13 @@ export const GROUP_SETTINGS_CSS = ` .bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px} .bt-gs-add{gap:8px;color:var(--bt-ink-2)} +.bt-face-person{cursor:default} +.bt-face-person:hover .bt-row-title{text-decoration:none} +.bt-face-person-text{display:flex;flex-direction:column;min-width:0} +.bt-face-bots{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:16px;color:var(--bt-ink-3)} +.bt-face-bot{padding:0;border:0;background:none;color:inherit;font:inherit} +button.bt-face-bot{cursor:pointer} +button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-underline-offset:2px} .bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out} .bt-bs-stack .bt-seg{align-self:stretch} @media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}} diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 0e2338d4..e635d3b7 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -282,11 +282,10 @@ const ZH = { 'Admin leads': '管理员主持', 'Plain messages go to the admin, who calls others with @Name.': '普通消息先给管理员,由管理员用 @名字 点名其他成员。', Everyone: '所有人', - 'Every Bot in this room': '房间里的所有 Bot', 'Every member answers in turn, admin first.': '每位成员轮流回答,管理员先说。', 'Members · {count}': '成员 · {count}', '{name} (you)': '{name}(你)', - '{bot} (you)': '{bot}(你的)', + Someone: '某人', 'Leave the group without an admin': '让群聊不设管理员', Unset: '取消', 'Make admin': '设为管理员', diff --git a/core/src/client/styles.js b/core/src/client/styles.js index c452235b..ca825b08 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -1011,6 +1011,7 @@ div:has(> nav [class*="_marks"]){display:none!important} .bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} .bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} .bt-urow{display:flex;justify-content:flex-end} +.bt-urow>.bt-lead{align-self:flex-end;margin-left:6px} .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} diff --git a/core/src/client/user-message.js b/core/src/client/user-message.js index 470deb61..f7a23446 100644 --- a/core/src/client/user-message.js +++ b/core/src/client/user-message.js @@ -4,8 +4,8 @@ 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 { faceAliases, remoteBotTitle } from '../shared/remote-label.js' -import { faceSeatLabel, faceSeats } from './face-seats.js' +import { cleanPeerName, faceAliases, remoteBotTitle } from '../shared/remote-label.js' +import { facePeople, faceSeatLabel, faceSeats } from './face-seats.js' // ------------------------------------------------------------------------- // The user's own messages. DSH draws every "@word" in them as a file reference, and @@ -54,8 +54,11 @@ export function mentionableBots(roster, sessionId, { fromFriend = false } = {}) function faceMentionables(room, roster, fromFriend) { const humans = (room.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined) .map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role })) - const seats = faceSeats(room, roster) - const ordered = [...seats.filter(seat => seat.mine !== fromFriend), ...seats.filter(seat => seat.mine === fromFriend)] + // The seats of people who left come last: a line sent before the leave still names the Bot it + // named, "@Chief (Bob)" stays Bob's Chief and never turns into mine (#457). + const all = faceSeats(room, roster, { left: true }) + const seats = all.filter(seat => !seat.left) + const ordered = [...seats.filter(seat => seat.mine !== fromFriend), ...seats.filter(seat => seat.mine === fromFriend), ...all.filter(seat => seat.left)] const bots = ordered.flatMap((seat) => { const target = seat.local ?? { id: seat.key, name: seat.bot, kind: 'bot', remote: seat.record } const record = { ...seat.record, ownerName: seat.record.ownerName || seat.person } @@ -144,18 +147,24 @@ export function UserMessageCell({ node, sessionId, useRoster, actions, renderMes // A person in this home (the host stamps the signed-in member as the author); a friend's Bot // keeps its own label, so the person mark is never drawn from message text (#229). const person = incoming && author.authorKind === 'human' && !author.ownerName - const held = Boolean(room && room.origin === 'face-to-face' && !incoming && (room.pendingCount ?? 0) > 0) + // In a face-to-face room my own line is a person's line like everyone else's: my name and my + // person mark beside it, in the sent colour always. Typing it is the approval (#179), so it is + // never drawn as held, whatever else in the room waits for approval (#457). + const faceMine = Boolean(room?.origin === 'face-to-face' && !incoming) + const myName = faceMine ? (facePeople(room, roster).find(entry => entry.mine)?.name || cleanPeerName(roster.self?.displayName, '') || author?.name || '') : '' return h('div', { className: incoming ? 'bt-umsg bt-umsg-other' : 'bt-umsg', 'data-steering': steering || undefined, - 'data-held': held || undefined, + 'data-face-mine': faceMine || undefined, 'data-author': author?.authorId, 'data-author-kind': author?.authorKind, 'data-author-name': author?.name, }, person ? h('span', { className: 'bt-lead', title: author.name }, h(PersonMark, { name: author.name })) : null, + faceMine ? h('span', { className: 'bt-lead', title: myName || undefined }, h(PersonMark, { name: myName || undefined })) : null, h('div', { className: 'bt-ustack' }, - author && (incoming || multiHuman) ? h('span', { className: 'bt-uauthor' }, author.name, author.ownerName ? h('span', { className: 'bt-chip' }, t('Friend\'s Bot')) : null) : null, + faceMine && myName ? h('span', { className: 'bt-uauthor' }, myName) + : author && (incoming || multiHuman) ? h('span', { className: 'bt-uauthor' }, author.name, author.ownerName ? h('span', { className: 'bt-chip' }, t('Friend\'s Bot')) : null) : null, attachments.length > 0 ? h(Attachments, { attachments, renderMessageImages }) : null, text !== '' || rest.length > 0 ? h('div', { className: 'bt-ubody', title: clock(data.time) }, h(UserText, { text, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), @@ -174,7 +183,7 @@ export const USER_MESSAGE_CSS = ` .bt-composer-as{font-size:12px;line-height:16px;color:var(--bt-ink-3);padding:0 8px;align-self:center;white-space:nowrap} .bt-ustack{min-width:0;max-width:min(calc(var(--dsh-chat-content-width,748px) * .702),82%);display:flex;flex-direction:column;align-items:flex-end;gap:8px} .bt-ubody{max-width:100%;box-sizing:border-box;background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);white-space:pre-wrap;word-break:break-word} -.bt-umsg[data-held] .bt-ubody{background:color-mix(in srgb,var(--bt-user) 28%,var(--bt-card,#fff));color:var(--bt-ink);border:.5px dashed var(--bt-line-2,rgba(20,20,20,.22));opacity:.88} +.bt-umsg[data-face-mine]>.bt-lead{align-self:flex-end;margin-left:4px} .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} diff --git a/core/test/at-menu.test.js b/core/test/at-menu.test.js index 643ab76f..c7b406e4 100644 --- a/core/test/at-menu.test.js +++ b/core/test/at-menu.test.js @@ -115,7 +115,7 @@ test('#305: a face room offers each of its Bots once, named Bot (Person), and no }) const rows = mentionRows(face, 'face') assert.equal(rows.filter(row => row.remote).length, 0, 'no published-friend rows in a face room') - assert.deepEqual(rows.map(row => row.bot?.label ?? 'everyone'), ['Chief (you)', 'Chief (SelfA)', 'everyone']) + assert.deepEqual(rows.map(row => row.bot?.label ?? 'everyone'), ['Chief (SelfB)', 'Chief (SelfA)', 'everyone']) // What is inserted is still the name the room routes on, never a handle. assert.deepEqual(rows.map(row => row.text), ['@Chief ', '@Chief (SelfA) ', '@Everyone ']) assert.ok(rows.every(row => !/@[\w-]+\.[\w-]+\.home/.test(row.text))) @@ -129,13 +129,14 @@ test('#305: a face room offers each of its Bots once, named Bot (Person), and no }) const listed = await bots.candidates({ sessionId: 'face' }, { query: '' }) assert.deepEqual(listed.map(row => [row.name, row.description]), [ - ['Chief (you)', undefined], + // "(you)" is a person's, never a Bot's; Everyone counts the room's Bots, mine and theirs (#457). + ['Chief (SelfB)', undefined], ['Chief (SelfA)', undefined], - ['Everyone', 'Every Bot in this room'], + ['Everyone', '2 Bots'], ]) // Without a member-session actor (roster.self is null) the seat's own owner name is used, as Members does. const legacy = indexRoster({ ...face, self: null, rooms: [{ ...face.rooms[0], participants: [{ ...face.rooms[0].participants[0], ownerName: 'SelfB' }, ...face.rooms[0].participants.slice(1)] }] }) - assert.equal(mentionRows(legacy, 'face')[0].bot.label, 'Chief (you)') + assert.equal(mentionRows(legacy, 'face')[0].bot.label, 'Chief (SelfB)') // Outside a face room the published friends' Bots still join the menu. const group = indexRoster({ ...face, rooms: [{ ...face.rooms[0], origin: undefined }] }) // A friend's Bot already seated in the group is offered once, by its seat (#384). diff --git a/core/test/face-at-filter.test.js b/core/test/face-at-filter.test.js index 5eedc5e7..4efc3ca6 100644 --- a/core/test/face-at-filter.test.js +++ b/core/test/face-at-filter.test.js @@ -3,7 +3,7 @@ import { test } from 'node:test' import { faceMenuCss, planFaceMenu, watchFaceMenu } from '../src/client/face-at-filter.js' const bots = (status = 'ready') => ({ source: 'ds-bot', status, items: [ - { name: 'Chief (you)', icon: 'bot', section: 'Bots', value: '@Chief ' }, + { name: 'Chief (SelfB)', icon: 'bot', section: 'Bots', value: '@Chief ' }, { name: 'Helper (SelfB)', icon: 'bot', section: 'Bots', value: '@Helper ' }, ] }) const reference = (status = 'ready', files = 1) => ({ source: 'reference', status, showGroupTitle: false, items: [ diff --git a/core/test/face-people.test.js b/core/test/face-people.test.js index fa2738b4..64a90cf0 100644 --- a/core/test/face-people.test.js +++ b/core/test/face-people.test.js @@ -80,7 +80,7 @@ test('a face room lists people, each with the Bot they brought, named one way', ], } const seats = faceSeats(room, roster) - assert.deepEqual(seats.map(faceSeatLabel), ['Chief (you)', 'Chief (SelfA0333)']) + assert.deepEqual(seats.map(faceSeatLabel), ['Chief (SelfB0333)', 'Chief (SelfA0333)']) assert.deepEqual(seats.map(seat => seat.mine), [true, false]) }) diff --git a/core/test/face-person-first.test.js b/core/test/face-person-first.test.js new file mode 100644 index 00000000..4696ace1 --- /dev/null +++ b/core/test/face-person-first.test.js @@ -0,0 +1,133 @@ +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' + +// #457: a face-to-face room is person-first. One entry per person (count = people, "(you)" only on +// the person), every Bot named "Bot (Person)" (mine too), a sent mention stays bound to the Bot it +// named after that person leaves, and my own line keeps one colour and has my person mark. + +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 expand = (node, names) => { + if (Array.isArray(node)) return node.flatMap(child => expand(child, names)) + if (node === null || typeof node !== 'object') return node + if (names.includes(node.type?.name)) return expand(node.type({ ...node.props, children: node.children }), names) + return { ...node, children: (node.children ?? []).map(child => expand(child, names)) } +} +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, facePersonLabel, faceBotLabel } = await load('face-seats.js') +const { FacePersonRow, roomSubtitle } = await load('group-settings.js') +const { mentionableBots, userTextParts, UserMessageCell } = await load('user-message.js') +const { RemoteMessage, remoteSpeaker } = await load('cells.js') + +// The self-test room: A (SelfA0056) brought Chief and Researcher, B (SelfB0056, me) brought Chief. +const chiefB = { id: 'b1', name: 'Chief' } +const seatB = { kind: 'bot', id: 'b1', handle: '@chief.selfb0056.home', displayName: 'Chief', ownerName: 'SelfB0056' } +const chiefA = { kind: 'bot', remote: true, id: '@chief.selfa0056.home', handle: '@chief.selfa0056.home', displayName: 'Chief', ownerName: 'SelfA0056' } +const researcherA = { kind: 'bot', remote: true, id: '@researcher.selfa0056.home', handle: '@researcher.selfa0056.home', displayName: 'Researcher', ownerName: 'SelfA0056', label: 'Researcher · SelfA0056' } +const onB = (participants = [seatB, chiefA, researcherA], extra = {}) => ({ + self: { memberId: 'm1', displayName: 'SelfB0056' }, + bots: [chiefB], + byId: { b1: chiefB }, + roomsById: { r: { id: 'r', origin: 'face-to-face', members: ['b1'], participants, ...extra } }, +}) + +test('one entry per person, mine first, "(you)" only on the person, count = people', () => { + const roster = onB() + const people = facePeople(roster.roomsById.r, roster) + assert.deepEqual(plain(people.map(facePersonLabel)), ['SelfB0056 (you)', 'SelfA0056']) + assert.deepEqual(plain(people.map(person => person.seats.map(seat => seat.bot))), [['Chief'], ['Chief', 'Researcher']]) + assert.equal(roomSubtitle(roster.roomsById.r, roster), '2 people · 3 Bots') + + const opened = [] + const row = expand(FacePersonRow({ person: people[1], onOpenBot: id => opened.push(id) }), ['PersonMark']) + assert.equal(row.props['data-flybot'], 'face-person') + assert.equal(find(row, n => String(n.props?.className ?? '').split(' ').includes('bt-person-mark')).length, 1, 'a person mark, not a Bot avatar') + const words = node => text(find(node, n => n.props?.className === 'bt-face-person-text')[0]) + assert.match(words(row), /^SelfA0056Chief, Researcher$/) + assert.equal(find(row, n => n.type === 'button').length, 0, "another person's Bots are not my chats") + const mine = expand(FacePersonRow({ person: people[0], onOpenBot: id => opened.push(id) }), ['PersonMark']) + assert.equal(mine.props['data-mine'], true) + assert.match(words(mine), /^SelfB0056 \(you\)Chief$/) + find(mine, n => n.type === 'button')[0].props.onClick() + assert.deepEqual(opened, ['b1'], 'my own Bot opens its chat') + // No Bot is ever "(you)". + assert.doesNotMatch(JSON.stringify(people.flatMap(person => person.seats.map(seat => seat.bot))), /\(you\)/) +}) + +test('my own Bot reads "Chief (SelfB0056)" in the room, a plain name elsewhere', () => { + const roster = onB() + assert.equal(faceBotLabel(roster.roomsById.r, roster, chiefB), 'Chief (SelfB0056)') + assert.equal(faceBotLabel({ origin: undefined, members: ['b1'], participants: [] }, roster, chiefB), 'Chief') +}) + +test('a sent mention stays bound to the Bot it named after that person leaves', () => { + const sent = '@Chief (SelfA0056) what noodle dish?' + const live = onB() + assert.equal(userTextParts(sent, mentionableBots(live, 'r'))[0].bot.id, '@chief.selfa0056.home') + // A left: its seats keep leftSeq. The chip is still A's Chief, never my own Chief. + const gone = onB([seatB, { ...chiefA, leftSeq: 7 }, { ...researcherA, leftSeq: 7 }]) + const parts = userTextParts(sent, mentionableBots(gone, 'r')) + assert.equal(parts[0].bot.id, '@chief.selfa0056.home') + assert.equal(parts[0].bot.mentionLabel, 'Chief (SelfA0056)') + assert.equal(parts[1].text, ' what noodle dish?') + // A bare "@Chief" is still mine, as before. + assert.equal(userTextParts('@Chief hi', mentionableBots(gone, 'r'))[0].bot.id, 'b1') +}) + +test('a Bot bubble header in a face room is "Bot (Person)", no handle and no lowercase id', () => { + const roster = onB() + const source = { remote: { handle: '@researcher.selfa0056.home' }, senderName: 'Researcher · SelfA0056' } + assert.equal(remoteSpeaker(source, roster, 'r').name, 'Researcher (SelfA0056)') + const drawn = RemoteMessage({ source, text: 'Try the noodles.', roster, actions: {}, sessionId: 'r' }) + const header = find(drawn, n => n.props?.className === 'bt-group-name')[0] + assert.equal(text(header), 'Researcher (SelfA0056)') + assert.equal(header.props.title, '@researcher.selfa0056.home', 'the handle is on hover only') + assert.equal(find(drawn, n => n.props?.className === 'bt-remote-handle').length, 0) + // Still named after the person left. + const gone = onB([seatB, { ...researcherA, leftSeq: 3 }]) + assert.equal(remoteSpeaker(source, gone, 'r').name, 'Researcher (SelfA0056)') +}) + +test('my own face-room line keeps one colour and has my name and person mark', () => { + const roster = onB(undefined, { pendingCount: 2 }) + const node = { kind: 'user', data: { content: [{ type: 'text', text: 'hello' }], source: { authorId: 'm1', authorKind: 'human', authorName: 'Danyang' } } } + const cell = expand(UserMessageCell({ node, sessionId: 'r', useRoster: select => select(roster), actions: {} }), ['PersonMark']) + assert.equal(cell.props['data-held'], undefined, 'a Bot reply waiting elsewhere in the room does not grey my line') + assert.equal(cell.props['data-face-mine'], true) + assert.equal(find(cell, n => String(n.props?.className ?? '').split(' ').includes('bt-person-mark')).length, 1) + assert.equal(text(find(cell, n => n.props?.className === 'bt-uauthor')[0]), 'SelfB0056') +}) diff --git a/core/test/mention-rule.test.js b/core/test/mention-rule.test.js index b608da0d..288d4fad 100644 --- a/core/test/mention-rule.test.js +++ b/core/test/mention-rule.test.js @@ -129,7 +129,7 @@ test('#305: a face-room mention chips on both homes under one name, and a typed const partsA = userTextParts(sent, mentionableBots(onA, 'r', { fromFriend: true })) assert.equal(partsA[0].bot.id, 'a1') assert.equal(partsA[0].label, "SelfA's Chief") - assert.equal(partsA[0].bot.mentionLabel, 'Chief (you)') + assert.equal(partsA[0].bot.mentionLabel, 'Chief (SelfA)') // A friend's bare "@Chief" is the friend's own Chief, not mine. assert.equal(userTextParts('@Chief hi', mentionableBots(onA, 'r', { fromFriend: true }))[0].bot.id, '@chief.selfb.home') // A friend's Bot that is not in this room is not offered here. diff --git a/core/test/user-bubble.test.js b/core/test/user-bubble.test.js index 7d6105dd..e7d9660a 100644 --- a/core/test/user-bubble.test.js +++ b/core/test/user-bubble.test.js @@ -111,9 +111,9 @@ test('the surface wins over the host bubble rule that reads the skin variable', assert.equal(declared(body, 'color'), 'var(--bt-user-ink)!important') }) -test('a held face-room outgoing line uses pending styles, not the sent user ink', () => { +test('#457: my own face-room line is never drawn as held, so its colour does not flip', () => { const src = readFileSync(new URL('../src/client/user-message.js', import.meta.url), 'utf8') - assert.match(src, /'data-held': held/) - assert.match(src, /\.bt-umsg\[data-held\] \.bt-ubody\{[^}]*color:var\(--bt-ink\)/) - assert.doesNotMatch(src, /\.bt-umsg\[data-held\] \.bt-ubody\{[^}]*color:var\(--bt-user-ink\)/) + assert.doesNotMatch(src, /'data-held'/) + assert.doesNotMatch(src, /\.bt-umsg\[data-held\]/) + assert.doesNotMatch(src, /pendingCount/) }) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index eb9818fc..63f44841 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -118,10 +118,11 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - Group bubbles, notices, and failed-reply lines never show a raw `` wrap or a developer error (`llm-deepseek: no API key`). `remoteMessageText` unwraps stored fences; `failureLabel` maps missing-key / unknown model failures to a short friendly line. Details stay in Settings → Feedback. - `GroupReplies` draws a `user` reply with `other: true` (another member's face-room line, #180) as `PersonMessage` (`cells.js`): the person's name, the person mark (`PersonMark` in `mark.js`) and a received `--bt-bubble` bubble, not a centred `bt-event` notice and not the sent `--bt-user` style; own lines keep the right-hand bubble (#229). `other` comes only from the host's person-line post (`hooks.js` `postHuman`), never from text. Another home member's line in a shared group (`UserMessageCell`, author stamped by the host) gets the same person mark and the received surface (`.bt-umsg-other .bt-ubody`). `PersonMark({ name })` draws the person's initial on a color picked from the name (`personColor`, the core default-color hash of `person:`), so one person looks the same on every home and two people are told apart; without a name it is the plain silhouette (#182). - 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 lists people first (`faceSeats` / `faceSeatLabel` in `face-seats.js`, re-exported by `group-settings.js`, used by the overview, group settings and the bubble's @ chips): one row per live Bot, ` · `, with `(you)` on my own (`SelfB (you) · Chief`, `SelfA · Chief`), built from participant fields and the roster, never message text (#229). -- **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 (you)`) 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). +- 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). - 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 (you)`), plus **Everyone** (`Every Bot in this room`; the room's local member list holds only this home's Bots, so it shows no count). 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). +- 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 `