From fa1dadf4dd516c24d2c6d720b61b5c3e2d88903c Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 18 Oct 2026 00:25:52 +0000 Subject: [PATCH] fix(ui): face rooms put people first: received person lines, person-first members and tiles (#229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Another person's line in a face room was drawn with the sent-bubble style and no avatar, the members list named my Bot bare and a friend's Bot as "X's Chief" with no people, and waiting tiles all read Chief. - GroupReplies draws an other-person line as PersonMessage (name, person mark, received bubble), the same as an attested friend line. - Another home member's UserMessageCell line gets the person mark and the received surface. - faceSeats/faceSeatLabel list one row per live Bot as " · ", "(you)" on mine, in the overview and group settings. - Joined waiting tiles lead with the person, the Bot underneath. Names come from participant records and the roster, never message text. --- changelog.d/229-face-room-people.fixed.md | 1 + core/client.js | 101 +++++++++--- core/src/client/cells.js | 23 +-- core/src/client/details-drawer.js | 21 ++- core/src/client/group-settings.js | 40 ++++- core/src/client/i18n.js | 1 + core/src/client/mark.js | 7 + core/src/client/user-message.js | 8 +- core/test/face-people.test.js | 97 ++++++++++++ docs/subsystems/core-client.md | 3 +- docs/subsystems/social.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 8 + patches/ds-bot/0003-flybot-api-client.patch | 52 +++--- patches/ds-bot/0005-social-seams.patch | 14 ++ patches/ds-bot/0008-client-bundle.patch | 148 ++++++++++++------ patches/ds-bot/0012-room-membership.patch | 75 ++++++++- patches/ds-bot/files/test/face-people.test.js | 97 ++++++++++++ plugins/social/client.js | 18 ++- plugins/social/test/social.test.js | 20 ++- 19 files changed, 604 insertions(+), 132 deletions(-) create mode 100644 changelog.d/229-face-room-people.fixed.md create mode 100644 core/test/face-people.test.js create mode 100644 patches/ds-bot/files/test/face-people.test.js diff --git a/changelog.d/229-face-room-people.fixed.md b/changelog.d/229-face-room-people.fixed.md new file mode 100644 index 00000000..3e6b1d21 --- /dev/null +++ b/changelog.d/229-face-room-people.fixed.md @@ -0,0 +1 @@ +Face-to-face rooms put people first: another person's line shows their name and a person avatar on a received bubble, no longer styled like your own; the members list names everyone the same way, the person with the Bot they brought (`SelfA · Chief`, `SelfB (you) · Chief`); and waiting-room tiles lead with the person, so two Chiefs are told apart. ([#229](https://github.com/ThinkFlowLab/FlyBot/issues/229)) diff --git a/core/client.js b/core/client.js index ec8620f5..94c91525 100644 --- a/core/client.js +++ b/core/client.js @@ -233,6 +233,7 @@ var ZH = { Everyone: "所有人", "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", "Members · {count}": "成员 · {count}", + "{name} (you)": "{name}(你)", "Leave the group without an admin": "让群聊不设管理员", Unset: "取消", "Make admin": "设为管理员", @@ -1701,6 +1702,18 @@ function StarIcon({ size = 8 }) { (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) ); } +function PersonMark({ size = 22 }) { + return (0, import_react.createElement)( + "span", + { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, + (0, import_react.createElement)( + "svg", + { width: size * 0.64, height: size * 0.64, viewBox: "0 0 24 24", fill: "currentColor" }, + (0, import_react.createElement)("circle", { cx: 12, cy: 8.5, r: 4.2 }), + (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) + ) + ); +} function AdminStar() { return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); } @@ -4664,18 +4677,6 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use texts2.length ? person ? (0, import_react7.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react7.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null ); } -function PersonMark({ size = 22 }) { - return (0, import_react7.createElement)( - "span", - { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, - (0, import_react7.createElement)( - "svg", - { width: size * 0.64, height: size * 0.64, viewBox: "0 0 24 24", fill: "currentColor" }, - (0, import_react7.createElement)("circle", { cx: 12, cy: 8.5, r: 4.2 }), - (0, import_react7.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) - ) - ); -} function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { const lead = (0, import_react7.createElement)("span", { className: "bt-lead", title: name }, (0, import_react7.createElement)(PersonMark)); return (0, import_react7.createElement)( @@ -5214,16 +5215,7 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi if (reply.kind === "user") { const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; if (reply.other) { - return (0, import_react7.createElement)( - "div", - { key: `user-${index}`, className: "bt-umsg bt-umsg-other", "data-author": reply.authorId, "data-author-kind": "human", "data-author-name": who }, - (0, import_react7.createElement)( - "div", - { className: "bt-ustack" }, - who ? (0, import_react7.createElement)("span", { className: "bt-uauthor" }, who) : null, - (0, import_react7.createElement)("div", { className: "bt-ubody" }, remoteMessageText(reply.text)) - ) - ); + return (0, import_react7.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); } return (0, import_react7.createElement)( "div", @@ -9998,6 +9990,30 @@ var ROOM_MODES = [ ["everyone", "Everyone", "Every member answers in turn, admin first."] ]; var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone"; +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.person) return seat.bot; + return `${seat.mine ? t("{name} (you)", { name: seat.person }) : seat.person} · ${seat.bot}`; +} function roomSubtitle(room2, roster) { if (room2.origin === "face-to-face") { const live = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.leftSeq === void 0); @@ -10018,6 +10034,8 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0); 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 shared2 = humans.length > 1; const chiefs = new Set((roster.humans ?? []).map((person) => person.designatedChiefId).filter(Boolean)); const outside = roster.bots.filter((bot) => { @@ -10102,7 +10120,21 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { (0, import_react24.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) ) ), - (0, import_react24.createElement)( + face ? (0, import_react24.createElement)( + Card, + { title: t("Members · {count}", { count: seats.length }) }, + seats.map((seat) => (0, import_react24.createElement)( + "div", + { key: seat.key, className: "bt-set-row bt-bs-row bt-gs-member", "data-flybot": "face-seat" }, + (0, import_react24.createElement)( + "span", + { className: "bt-gs-who" }, + (0, import_react24.createElement)(BotAvatar, { bot: seat.local ?? lookBotForRemote(seat.record, roster), size: 26, badge: false, live: false }), + (0, import_react24.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(seat)) + ) + )) + ) : null, + face ? null : (0, import_react24.createElement)( Card, { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, humans.map((person) => (0, import_react24.createElement)( @@ -12566,6 +12598,23 @@ function SharedRoomApprovals({ room: room2 }) { )) ); } +function FaceMembers({ room: room2, roster, actions }) { + const seats = faceSeats(room2, roster); + return (0, import_react32.createElement)( + "div", + null, + (0, import_react32.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: seats.length })), + (0, import_react32.createElement)("div", { className: "bt-members", role: "list" }, seats.map((seat) => { + const label = faceSeatLabel(seat); + const avatar = (0, import_react32.createElement)(BotAvatar, { bot: seat.local ?? lookBotForRemote(seat.record, roster), size: 26, badge: false, live: false }); + return (0, import_react32.createElement)( + "div", + { key: seat.key, role: "listitem", "data-flybot": "face-seat", "data-mine": seat.mine || void 0 }, + (0, import_react32.createElement)("div", { className: "bt-member" }, seat.local ? (0, import_react32.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_react32.createElement)("span", null, label)) : (0, import_react32.createElement)("span", { className: "bt-member-name" }, avatar, (0, import_react32.createElement)("span", null, label))) + ); + })) + ); +} function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) { const live = room2.presentedParticipants ?? room2.participants ?? []; const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0); @@ -12590,7 +12639,7 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti (0, import_react32.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) ) : face ? null : room2.notice ? (0, import_react32.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react32.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), face ? null : (0, import_react32.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), - face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : (0, import_react32.createElement)( + face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react32.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react32.createElement)( "div", { key: "members" }, (0, import_react32.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })), @@ -14489,6 +14538,7 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma const room2 = roster.roomsById?.[sessionId]; 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); return (0, import_react36.createElement)( "div", @@ -14500,6 +14550,7 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma "data-author-kind": author?.authorKind, "data-author-name": author?.name }, + person ? (0, import_react36.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react36.createElement)(PersonMark)) : null, (0, import_react36.createElement)( "div", { className: "bt-ustack" }, @@ -14520,6 +14571,8 @@ var USER_MESSAGE_CSS = ` .bt-umsg{display:flex;flex-direction:row-reverse;align-items:center;justify-content:flex-start;gap:4px} .bt-umsg-other{flex-direction:row} .bt-umsg-other .bt-ustack{align-items:flex-start} +.bt-umsg-other>.bt-lead{align-self:flex-end;margin-right:4px} +.bt-umsg-other .bt-ubody{background:var(--bt-bubble);color:var(--bt-ink)} .bt-uauthor{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-weight:600} .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} diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 554ba6f1..ae231121 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -4,7 +4,7 @@ import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { inkText } from './inks.js' import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts } from './text.js' import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js' -import { colorOf, lookBotForRemote, toolState, BotMark, RoomAvatar } from './mark.js' +import { colorOf, lookBotForRemote, toolState, BotMark, PersonMark, RoomAvatar } from './mark.js' import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js' import { SecretToolCell } from './secret-card.js' import { dateLocale, t } from './i18n.js' @@ -163,14 +163,9 @@ export function AssistantCell({ node, groupPart, sessionId, openFile, fileMentio : h(Bubbles, { texts, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi, mentions })) : null) } -// A friend's line that Bot Social attests the friend typed themselves: the person's name and a -// person mark, not the Bot's (the host marks the turn, remote-chat.js / turns.js). -function PersonMark({ size = 22 }) { - return h('span', { className: 'bt-person-mark', style: { width: size, height: size }, 'aria-hidden': true }, - h('svg', { width: size * 0.64, height: size * 0.64, viewBox: '0 0 24 24', fill: 'currentColor' }, - h('circle', { cx: 12, cy: 8.5, r: 4.2 }), h('path', { d: 'M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z' }))) -} - +// A friend's line that Bot Social attests the friend typed themselves, or another person's own +// line in a face-to-face room: the person's name and a person mark, not the Bot's (the host marks +// the turn, remote-chat.js / turns.js, or posts it as a person line, hooks.js postHuman). function PersonMessage({ name, texts, roster, selfId, actions, sessionId, msgKey, useUi }) { const lead = h('span', { className: 'bt-lead', title: name }, h(PersonMark)) return h('div', { className: 'bt-group-msg bt-person-msg', 'data-person': name }, @@ -667,7 +662,7 @@ function useLiveNote(actions, sessionId, id, entry) { }, [sessionId, id, key]) } -function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { +export function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { const [live, setLive] = useState({ replies: [], speaking: null }) useEffect(() => { if (done) return undefined @@ -699,12 +694,10 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' } if (reply.kind === 'user') { const who = reply.name && reply.name !== 'User' ? reply.name : reply.authorId - // Another member's line: a left-hand bubble with their name, like any other person's message (#180). + // Another person's own line: their name and person mark beside a received bubble, + // never the sent-bubble style of my own lines (#180, #229). if (reply.other) { - return h('div', { key: `user-${index}`, className: 'bt-umsg bt-umsg-other', 'data-author': reply.authorId, 'data-author-kind': 'human', 'data-author-name': who }, - h('div', { className: 'bt-ustack' }, - who ? h('span', { className: 'bt-uauthor' }, who) : null, - h('div', { className: 'bt-ubody' }, remoteMessageText(reply.text)))) + return h(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi }) } 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, diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index 402dd854..965a9ef2 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, roomSubtitle, shownMode } from './group-settings.js' +import { GroupSettingsForm, ROOM_MODES, faceSeatLabel, faceSeats, 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,6 +149,21 @@ 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). +function FaceMembers({ room, roster, actions }) { + const seats = faceSeats(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)))) + }))) +} + function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) { const live = room.presentedParticipants ?? room.participants ?? [] const humans = live.filter(p => p.kind === 'human' && p.leftSeq === undefined) @@ -179,7 +194,9 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) face ? null : h(SharedRoomApprovals, { key: 'approvals', room }), face && (room.face?.status === 'candidate' || room.face?.status === 'joining') ? null - : h('div', { key: 'members' }, + : face + ? h(FaceMembers, { key: 'members', room, roster, actions }) + : h('div', { key: 'members' }, h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: members.length + humans.length + remotes.length })), h('div', { className: 'bt-members', role: 'list' }, [ ...humans.map(person => h('div', { key: person.id, role: 'listitem' }, diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index 8fd32666..cf66a6b3 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -6,7 +6,7 @@ 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 { remoteBotTitle } from '../shared/remote-label.js' +import { cleanPeerName, ownerNameOf, 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,6 +16,35 @@ 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') +// A face-to-face room lists people, each with the Bot they brought, named one way for every +// member: "Bob · Chief", "Ann (you) · Chief" (#229). 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) => { + const local = p.remote ? undefined : 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 room.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 +} + +export function faceSeatLabel(seat) { + if (!seat.person) return seat.bot + return `${seat.mine ? t('{name} (you)', { name: seat.person }) : seat.person} · ${seat.bot}` +} + export function roomSubtitle(room, roster) { if (room.origin === 'face-to-face') { const live = (room.presentedParticipants ?? room.participants ?? []).filter(p => p.leftSeq === undefined) @@ -40,6 +69,8 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const humans = live.filter(p => p.kind === 'human' && p.leftSeq === undefined) 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 shared = humans.length > 1 const chiefs = new Set((roster.humans ?? []).map(person => person.designatedChiefId).filter(Boolean)) const outside = roster.bots.filter((bot) => { @@ -82,7 +113,12 @@ 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 }) }))), - h(Card, { title: t('Members · {count}', { count: members.length + humans.length + remotes.length }) }, + 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 ? 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' }, h('span', { className: 'bt-row-title' }, person.displayName || person.id), diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index d4570317..554b2fb6 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -200,6 +200,7 @@ const ZH = { Everyone: '所有人', 'Every member answers in turn, admin first.': '每位成员轮流回答,管理员先说。', 'Members · {count}': '成员 · {count}', + '{name} (you)': '{name}(你)', 'Leave the group without an admin': '让群聊不设管理员', Unset: '取消', 'Make admin': '设为管理员', diff --git a/core/src/client/mark.js b/core/src/client/mark.js index d516bc20..74fafd45 100644 --- a/core/src/client/mark.js +++ b/core/src/client/mark.js @@ -85,6 +85,13 @@ function StarIcon({ size = 8 }) { h('path', { d: 'M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z', fill: '#fff' })) } // The small star in a disc that marks a group admin, beside a name. +// A person, not a Bot: a friend's own attested line, or another person in a face-to-face room. +export function PersonMark({ size = 22 }) { + return h('span', { className: 'bt-person-mark', style: { width: size, height: size }, 'aria-hidden': true }, + h('svg', { width: size * 0.64, height: size * 0.64, viewBox: '0 0 24 24', fill: 'currentColor' }, + h('circle', { cx: 12, cy: 8.5, r: 4.2 }), h('path', { d: 'M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z' }))) +} + export function AdminStar() { return h('span', { className: 'bt-admin-star', role: 'img', 'aria-label': t('Admin') }, h(StarIcon, { size: 7 })) } diff --git a/core/src/client/user-message.js b/core/src/client/user-message.js index bb3bd1f6..f694c912 100644 --- a/core/src/client/user-message.js +++ b/core/src/client/user-message.js @@ -1,7 +1,7 @@ import { createElement as h, Fragment, useState } from 'react' import * as ui from '@deepseek-ai/dsh-client-ui-primitives' import { mentionSpans } from '../shared/mentions.js' -import { BotMark } from './mark.js' +import { BotMark, PersonMark } from './mark.js' import { CheckIcon, CopyIcon } from './icons.js' import { dateLocale, t } from './i18n.js' import { remoteBotTitle } from '../shared/remote-label.js' @@ -117,6 +117,9 @@ export function UserMessageCell({ node, sessionId, useRoster, actions, renderMes const room = roster.roomsById?.[sessionId] const multiHuman = (room?.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined).length > 1 const incoming = author && !author.mine + // 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) return h('div', { className: incoming ? 'bt-umsg bt-umsg-other' : 'bt-umsg', @@ -126,6 +129,7 @@ export function UserMessageCell({ node, sessionId, useRoster, actions, renderMes 'data-author-kind': author?.authorKind, 'data-author-name': author?.name, }, + person ? h('span', { className: 'bt-lead', title: author.name }, h(PersonMark)) : 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, attachments.length > 0 ? h(Attachments, { attachments, renderMessageImages }) : null, @@ -140,6 +144,8 @@ export const USER_MESSAGE_CSS = ` .bt-umsg{display:flex;flex-direction:row-reverse;align-items:center;justify-content:flex-start;gap:4px} .bt-umsg-other{flex-direction:row} .bt-umsg-other .bt-ustack{align-items:flex-start} +.bt-umsg-other>.bt-lead{align-self:flex-end;margin-right:4px} +.bt-umsg-other .bt-ubody{background:var(--bt-bubble);color:var(--bt-ink)} .bt-uauthor{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-weight:600} .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} diff --git a/core/test/face-people.test.js b/core/test/face-people.test.js new file mode 100644 index 00000000..e4cd2803 --- /dev/null +++ b/core/test/face-people.test.js @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #229: in a face-to-face room people come first. Another person's line is their own +// received bubble (name + person mark), and the members list names every member the same way. + +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 { GroupReplies } = await load('cells.js') +const { faceSeats, faceSeatLabel } = await load('group-settings.js') +const { UserMessageCell, USER_MESSAGE_CSS } = await load('user-message.js') + +test('another person\'s face-room line is their own received bubble with name and person mark', () => { + const roster = { bots: [], byId: {}, roomsById: { g1: { id: 'g1', origin: 'face-to-face', members: [], participants: [] } } } + const content = [{ type: 'text', text: JSON.stringify({ replies: [{ kind: 'user', other: true, name: 'SelfA0333', authorId: '@chief.selfa0333.home', text: 'hello from A' }] }) }] + const drawn = expand(GroupReplies({ sessionId: 'g1', callId: 'c1', done: true, content, roster, actions: {}, useUi: () => null }), ['PersonMessage', 'PersonMark', 'Bubbles']) + const [person] = find(drawn, node => node.props?.['data-person'] !== undefined) + assert.ok(person, 'drawn as a person') + assert.equal(person.props['data-person'], 'SelfA0333') + assert.equal(find(person, node => node.props?.className === 'bt-person-mark').length, 1, 'with an avatar') + assert.equal(find(person, node => node.props?.className === 'bt-group-name' && node.children.includes('SelfA0333')).length, 1) + assert.equal(find(person, node => node.props?.className === 'bt-bubble').length, 1, 'a received bubble') + assert.equal(find(drawn, node => node.props?.className === 'bt-ubody').length, 0, 'not the sent-bubble style') +}) + +test('a face room lists people, each with the Bot they brought, named one way', () => { + const roster = { + self: { memberId: 'm1', displayName: 'SelfB0333' }, + byId: { chief: { id: 'chief', name: 'Chief' } }, + } + const room = { + origin: 'face-to-face', + members: ['chief'], + presentedParticipants: [ + { kind: 'bot', id: 'chief', handle: '@chief.selfb0333.home', displayName: 'Chief', label: "SelfB0333's Chief" }, + { kind: 'bot', remote: true, id: '@chief.selfa0333.home', handle: '@chief.selfa0333.home', displayName: 'Chief', ownerName: 'SelfA0333', label: "SelfA0333's Chief" }, + { kind: 'bot', remote: true, id: '@coder.gone.home', handle: '@coder.gone.home', displayName: 'Coder', ownerName: 'Gone', leftSeq: 3 }, + ], + } + const seats = faceSeats(room, roster) + assert.deepEqual(seats.map(faceSeatLabel), ['SelfB0333 (you) · Chief', 'SelfA0333 · Chief']) + assert.deepEqual(seats.map(seat => seat.mine), [true, false]) +}) + +test('another home member\'s line shows their person mark and a received surface', () => { + const roster = { self: { memberId: 'm1' }, bots: [], byId: {}, roomsById: { g1: { id: 'g1', members: [], participants: [] } } } + const node = { kind: 'user', data: { content: [{ type: 'text', text: 'hi' }], source: { authorId: 'm2', authorKind: 'human', authorName: 'Ann' } } } + const cell = UserMessageCell({ node, sessionId: 'g1', useRoster: select => select(roster), actions: {} }) + const drawn = expand(cell, ['PersonMark']) + assert.match(drawn.props.className, /bt-umsg-other/) + assert.equal(find(drawn, n => n.props?.className === 'bt-person-mark').length, 1) + const mine = UserMessageCell({ node: { ...node, data: { ...node.data, source: { ...node.data.source, authorId: 'm1' } } }, sessionId: 'g1', useRoster: select => select(roster), actions: {} }) + assert.equal(find(expand(mine, ['PersonMark']), n => n.props?.className === 'bt-person-mark').length, 0, 'my own line has no mark') + assert.match(USER_MESSAGE_CSS, /\.bt-umsg-other \.bt-ubody\{background:var\(--bt-bubble\);color:var\(--bt-ink\)\}/) + // The bundle carries the same rule. + assert.match(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), /\.bt-umsg-other \.bt-ubody\{background:var\(--bt-bubble\)/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 584f0d7b..6229e69c 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -95,7 +95,8 @@ The default card (`flybot-slots.js`) shows where a held request came from: `send - Slot `flybot.new-chat.action` (`0003-flybot-api-client`, types in `flybot.d.ts`) is a row on the sidebar **+** new-chat menu, next to Create group chat. Plugins register `{ id, order, label, hint?, visible?, run }`; the picker looks `label` / `hint` up with `t()` like the neighbouring rows. A string returned from `run` is shown as a toast (`actions.notify`). Social registers Face-to-face group and Add friend; see [plugin-runtime](plugin-runtime.md) and [social](social.md). - Sidebar **+** (`mode === 'new'`) opens a short action menu (`data-menu`, no To:/composer) until **New chat** or **Create group chat**. That menu does not list individual Bots (Chief and the rest stay behind **New chat**). The Agent row in that FlyBot menu is **Add coding agent**; **Add DSH Agent** stays the DSH product string on Agent rows elsewhere (#126, #181). - 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 a left-hand `bt-umsg-other` bubble with the person's name, not as a centred `bt-event` notice; own lines keep the right-hand bubble. +- `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`). +- A face-to-face room lists people first (`faceSeats` / `faceSeatLabel` in `group-settings.js`, used by the overview and group settings): 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). ### Hidden DSH tool-removal notices diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index c3ba4423..e5c8441d 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -69,7 +69,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### Face-to-face waiting grid, leave, and social + (#124, #125, #126) -- Waiting UI (join dialog and `flybot.room.overview` while `candidate`) is a WeChat-like tile grid: the 6-digit room number at the top, avatar above the Bot name, owner on a second line (`data-flybot=face-owner`). **Leave** and **Enter chat** share one footer row. Join copy uses owner + Bot name (`Chief · BoxA` / `BoxA's Chief`), never `BoxA's BoxA` or generic `BoxA's Bot`. Setup copy says you wait together until someone enters the chat. Ended joining says **Joining has ended**, not “matching window”. The group subtitle is live participant count (no “0 members · No admin”). +- Waiting UI (join dialog and `flybot.room.overview` while `candidate`) is a WeChat-like tile grid: the 6-digit room number at the top, avatar on top; a joined member leads with the person (`data-flybot=face-owner`, the tile name) and the Bot they brought on a second line (`data-flybot=face-bot`), so two Chiefs are told apart (#229); picker tiles show the Bot name only. **Leave** and **Enter chat** share one footer row. Join copy uses owner + Bot name (`Chief · BoxA` / `BoxA's Chief`), never `BoxA's BoxA` or generic `BoxA's Bot`. Setup copy says you wait together until someone enters the chat. Ended joining says **Joining has ended**, not “matching window”. The group subtitle is live participant count (no “0 members · No admin”). - **Leave** after the number is void (`bot_not_found` / room already gone) still drops the room off the active list onto **Past rooms**. Past rows are unique by `faceRoomId` and have one primary action (**Open**); live rows are the main-list RoomRow (people mark) with **Leave** on the open room overview. - #192: **Leave** keeps the hosted room, marked `left` (`upsertFace`), so **Open** in Past rooms finds it already hosted and shows the read-only transcript with `You left this room.` (before, `dropFace` removed it and Open built an empty room the browser did not have yet). A past room that falls off the 20 kept entries is dropped then. - #193: the account-menu handle row overrides the menu's full-width button rule so the handle text stays visible next to **Copy**. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 694d8af2..ef9de301 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -136,6 +136,14 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Header 'Message {name}': '给 {name} 发消息', +@@ -154,6 +154,7 @@ const ZH = { + Everyone: '所有人', + 'Every member answers in turn, admin first.': '每位成员轮流回答,管理员先说。', + 'Members · {count}': '成员 · {count}', ++ '{name} (you)': '{name}(你)', + 'Leave the group without an admin': '让群聊不设管理员', + Unset: '取消', + 'Make admin': '设为管理员', @@ -238,6 +238,7 @@ const ZH = { 'Kept above the other chats.': '排在其他对话上面。', 'Search still finds it.': '仍然可以搜索到。', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index de4d3f82..41316499 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -135,7 +135,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js +import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts } from './text.js' import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js' -import { colorOf, toolState, BotMark, RoomAvatar } from './mark.js' -+import { colorOf, lookBotForRemote, toolState, BotMark, RoomAvatar } from './mark.js' ++import { colorOf, lookBotForRemote, toolState, BotMark, PersonMark, RoomAvatar } from './mark.js' import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js' import { SecretToolCell } from './secret-card.js' import { dateLocale, t } from './i18n.js' @@ -168,7 +168,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js const running = data.status === 'running' // A streaming reply stands in for the activity row. useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts.length > 0 ? { text: true } : null) -@@ -151,7 +151,24 @@ export function AssistantCell({ node, groupPart, sessionId, openFile, fileMentio +@@ -151,7 +151,19 @@ export function AssistantCell({ node, groupPart, sessionId, openFile, fileMentio }) }), reasoning ? h(ThinkingRow, { text: reasoning }) : null, @@ -178,14 +178,9 @@ diff --git a/src/client/cells.js b/src/client/cells.js + : h(Bubbles, { texts, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi, mentions })) : null) +} + -+// A friend's line that Bot Social attests the friend typed themselves: the person's name and a -+// person mark, not the Bot's (the host marks the turn, remote-chat.js / turns.js). -+function PersonMark({ size = 22 }) { -+ return h('span', { className: 'bt-person-mark', style: { width: size, height: size }, 'aria-hidden': true }, -+ h('svg', { width: size * 0.64, height: size * 0.64, viewBox: '0 0 24 24', fill: 'currentColor' }, -+ h('circle', { cx: 12, cy: 8.5, r: 4.2 }), h('path', { d: 'M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z' }))) -+} -+ ++// A friend's line that Bot Social attests the friend typed themselves, or another person's own ++// line in a face-to-face room: the person's name and a person mark, not the Bot's (the host marks ++// the turn, remote-chat.js / turns.js, or posts it as a person line, hooks.js postHuman). +function PersonMessage({ name, texts, roster, selfId, actions, sessionId, msgKey, useUi }) { + const lead = h('span', { className: 'bt-lead', title: name }, h(PersonMark)) + return h('div', { className: 'bt-group-msg bt-person-msg', 'data-person': name }, @@ -330,19 +325,26 @@ diff --git a/src/client/cells.js b/src/client/cells.js // The time separator: "Today 1:12 AM", "Yesterday 9:03 PM", "Thu, Sep 3 4:20 PM". const SEPARATOR_GAP_MS = 30 * 60 * 1000 export function clockLabel(time, now = Date.now()) { -@@ -571,11 +571,21 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -540,7 +540,7 @@ function useLiveNote(actions, sessionId, id, entry) { + }, [sessionId, id, key]) + } + +-function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { ++export function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { + const [live, setLive] = useState({ replies: [], speaking: null }) + useEffect(() => { + if (done) return undefined +@@ -571,11 +571,19 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi replies.map((reply, index) => { const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' } if (reply.kind === 'user') { - return h('div', { key: `user-${index}`, className: 'bt-urow' }, h('div', { className: 'bt-ububble' }, - h(MarkdownText, { text: reply.text, labels: mdLabels(), variant: 'compact' }))) + const who = reply.name && reply.name !== 'User' ? reply.name : reply.authorId -+ // Another member's line: a left-hand bubble with their name, like any other person's message (#180). ++ // Another person's own line: their name and person mark beside a received bubble, ++ // never the sent-bubble style of my own lines (#180, #229). + if (reply.other) { -+ return h('div', { key: `user-${index}`, className: 'bt-umsg bt-umsg-other', 'data-author': reply.authorId, 'data-author-kind': 'human', 'data-author-name': who }, -+ h('div', { className: 'bt-ustack' }, -+ who ? h('span', { className: 'bt-uauthor' }, who) : null, -+ h('div', { className: 'bt-ubody' }, remoteMessageText(reply.text)))) ++ return h(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi }) + } + 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, @@ -1346,8 +1348,12 @@ diff --git a/src/client/styles.js b/src/client/styles.js diff --git a/src/client/user-message.js b/src/client/user-message.js --- a/src/client/user-message.js +++ b/src/client/user-message.js -@@ -4,6 +4,7 @@ import { mentionSpans } from '../shared/mentions.js' - import { BotMark } from './mark.js' +@@ -1,9 +1,10 @@ + import { createElement as h, Fragment, useState } from 'react' + import * as ui from '@deepseek-ai/dsh-client-ui-primitives' + import { mentionSpans } from '../shared/mentions.js' +-import { BotMark } from './mark.js' ++import { BotMark, PersonMark } from './mark.js' import { CheckIcon, CopyIcon } from './icons.js' import { dateLocale, t } from './i18n.js' +import { remoteBotTitle } from '../shared/remote-label.js' @@ -1401,7 +1407,7 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js : h(Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, 'skill', references)))) } -@@ -87,8 +87,21 @@ export function UserMessageCell({ node, sessionId, useRoster, actions, renderMes +@@ -87,8 +87,25 @@ export function UserMessageCell({ node, sessionId, useRoster, actions, renderMes const referenceLabels = data.referenceLabels ?? [] const references = openFile && openSkill ? { openFile, openSkill } : undefined const steering = node.kind === 'steering' @@ -1410,6 +1416,9 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js + const room = roster.roomsById?.[sessionId] + const multiHuman = (room?.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined).length > 1 + const incoming = author && !author.mine ++ // 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) + return h('div', { + className: incoming ? 'bt-umsg bt-umsg-other' : 'bt-umsg', @@ -1419,17 +1428,20 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js + 'data-author-kind': author?.authorKind, + 'data-author-name': author?.name, + }, ++ person ? h('span', { className: 'bt-lead', title: author.name }, h(PersonMark)) : 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, 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), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), -@@ -99,8 +99,13 @@ export function UserMessageCell({ node, sessionId, useRoster, actions, renderMes +@@ -99,8 +99,15 @@ export function UserMessageCell({ node, sessionId, useRoster, actions, renderMes export const USER_MESSAGE_CSS = ` .bt-umsg{display:flex;flex-direction:row-reverse;align-items:center;justify-content:flex-start;gap:4px} +.bt-umsg-other{flex-direction:row} +.bt-umsg-other .bt-ustack{align-items:flex-start} ++.bt-umsg-other>.bt-lead{align-self:flex-end;margin-right:4px} ++.bt-umsg-other .bt-ubody{background:var(--bt-bubble);color:var(--bt-ink)} +.bt-uauthor{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-weight:600} +.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} diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 7c840501..515501e7 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -82,6 +82,20 @@ diff --git a/src/client/mark.js b/src/client/mark.js const box = shapeOf(look.shape).size return h('span', common, h('svg', { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })) } +@@ -79,6 +79,13 @@ function StarIcon({ size = 8 }) { + h('path', { d: 'M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z', fill: '#fff' })) + } + // The small star in a disc that marks a group admin, beside a name. ++// A person, not a Bot: a friend's own attested line, or another person in a face-to-face room. ++export function PersonMark({ size = 22 }) { ++ return h('span', { className: 'bt-person-mark', style: { width: size, height: size }, 'aria-hidden': true }, ++ h('svg', { width: size * 0.64, height: size * 0.64, viewBox: '0 0 24 24', fill: 'currentColor' }, ++ h('circle', { cx: 12, cy: 8.5, r: 4.2 }), h('path', { d: 'M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z' }))) ++} ++ + export function AdminStar() { + return h('span', { className: 'bt-admin-star', role: 'img', 'aria-label': t('Admin') }, h(StarIcon, { size: 7 })) + } @@ -130,10 +130,42 @@ function clusterSeats(count, frame) { return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]] } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 90e83145..5decbfed 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -87,6 +87,14 @@ diff --git a/client.js b/client.js // Header "Message {name}": "给 {name} 发消息", Message: "发消息", +@@ -187,6 +187,7 @@ var ZH = { + Everyone: "所有人", + "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", + "Members · {count}": "成员 · {count}", ++ "{name} (you)": "{name}(你)", + "Leave the group without an admin": "让群聊不设管理员", + Unset: "取消", + "Make admin": "设为管理员", @@ -270,6 +270,7 @@ var ZH = { "Kept above the other chats.": "排在其他对话上面。", "Search still finds it.": "仍然可以搜索到。", @@ -596,7 +604,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,6 +1242,368 @@ function maskOf(shape) { +@@ -1242,6 +1242,380 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -680,6 +688,18 @@ diff --git a/client.js b/client.js + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); +} ++function PersonMark({ size = 22 }) { ++ return (0, import_react.createElement)( ++ "span", ++ { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, ++ (0, import_react.createElement)( ++ "svg", ++ { width: size * 0.64, height: size * 0.64, viewBox: "0 0 24 24", fill: "currentColor" }, ++ (0, import_react.createElement)("circle", { cx: 12, cy: 8.5, r: 4.2 }), ++ (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) ++ ) ++ ); ++} +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); +} @@ -2441,7 +2461,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +3940,157 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +3940,145 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -2451,18 +2471,6 @@ diff --git a/client.js b/client.js + texts2.length ? person ? (0, import_react7.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react7.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null + ); +} -+function PersonMark({ size = 22 }) { -+ return (0, import_react7.createElement)( -+ "span", -+ { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, -+ (0, import_react7.createElement)( -+ "svg", -+ { width: size * 0.64, height: size * 0.64, viewBox: "0 0 24 24", fill: "currentColor" }, -+ (0, import_react7.createElement)("circle", { cx: 12, cy: 8.5, r: 4.2 }), -+ (0, import_react7.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) -+ ) -+ ); -+} +function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { + const lead = (0, import_react7.createElement)("span", { className: "bt-lead", title: name }, (0, import_react7.createElement)(PersonMark)); + return (0, import_react7.createElement)( @@ -2898,7 +2906,7 @@ diff --git a/client.js b/client.js if (done) return void 0; let stop = false; let timer; -@@ -4366,66 +4366,100 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -4366,66 +4366,91 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; if (replies.length === 0 && pending.length === 0) return null; const room2 = roster.roomsById[sessionId]; @@ -2912,16 +2920,7 @@ diff --git a/client.js b/client.js - return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( + const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; + if (reply.other) { -+ return (0, import_react7.createElement)( -+ "div", -+ { key: `user-${index}`, className: "bt-umsg bt-umsg-other", "data-author": reply.authorId, "data-author-kind": "human", "data-author-name": who }, -+ (0, import_react7.createElement)( -+ "div", -+ { className: "bt-ustack" }, -+ who ? (0, import_react7.createElement)("span", { className: "bt-uauthor" }, who) : null, -+ (0, import_react7.createElement)("div", { className: "bt-ubody" }, remoteMessageText(reply.text)) -+ ) -+ ); ++ return (0, import_react7.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + } + return (0, import_react7.createElement)( "div", @@ -2988,8 +2987,7 @@ diff --git a/client.js b/client.js + } + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react7.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react7.createElement)(RemoteMessage, { @@ -3002,7 +3000,8 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react7.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); }), pending.map((id) => { @@ -6668,15 +6667,15 @@ diff --git a/client.js b/client.js - (0, import_react20.createElement)( + { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : t("New chat"), "data-menu": menuOnly || void 0 }, + menuOnly ? (0, import_react21.createElement)( -+ "div", -+ { className: "bt-to" }, -+ (0, import_react21.createElement)("span", { className: "bt-to-label" }, t("New…")), -+ (0, import_react21.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×") -+ ) : (0, import_react21.createElement)( "div", { className: "bt-to" }, - (0, import_react20.createElement)("span", { className: "bt-to-label" }, t("To:")), - adminBot ? (0, import_react20.createElement)( ++ (0, import_react21.createElement)("span", { className: "bt-to-label" }, t("New…")), ++ (0, import_react21.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×") ++ ) : (0, import_react21.createElement)( ++ "div", ++ { className: "bt-to" }, + (0, import_react21.createElement)("span", { className: "bt-to-label" }, t("To:")), + adminBot ? (0, import_react21.createElement)( "span", @@ -6954,7 +6953,7 @@ diff --git a/client.js b/client.js const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); const level = speech ? serviceLevel : micLevel; const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -@@ -8875,69 +8875,85 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8875,69 +8875,111 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -7024,6 +7023,30 @@ diff --git a/client.js b/client.js ["everyone", "Everyone", "Every member answers in turn, admin first."] ]; var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone"; ++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.person) return seat.bot; ++ return `${seat.mine ? t("{name} (you)", { name: seat.person }) : seat.person} · ${seat.bot}`; ++} function roomSubtitle(room2, roster) { + if (room2.origin === "face-to-face") { + const live = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.leftSeq === void 0); @@ -7048,6 +7071,8 @@ diff --git a/client.js b/client.js + const remotes = live.filter((p) => p.remote && p.leftSeq === void 0); const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); - const outside = roster.bots.filter((bot) => !room2.members.includes(bot.id)); ++ const face = room2.origin === "face-to-face"; ++ const seats = face ? faceSeats(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) => { @@ -7117,7 +7142,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": mode === id, -@@ -8999,86 +8999,130 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +@@ -8999,86 +8999,144 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (mode !== id) void update({ mode: id }); } }, t(label)))), @@ -7140,9 +7165,23 @@ diff --git a/client.js b/client.js ) ), - (0, import_react23.createElement)( -+ (0, import_react24.createElement)( ++ face ? (0, import_react24.createElement)( Card, - { title: t("Members · {count}", { count: members.length }) }, ++ { title: t("Members · {count}", { count: seats.length }) }, ++ seats.map((seat) => (0, import_react24.createElement)( ++ "div", ++ { key: seat.key, className: "bt-set-row bt-bs-row bt-gs-member", "data-flybot": "face-seat" }, ++ (0, import_react24.createElement)( ++ "span", ++ { className: "bt-gs-who" }, ++ (0, import_react24.createElement)(BotAvatar, { bot: seat.local ?? lookBotForRemote(seat.record, roster), size: 26, badge: false, live: false }), ++ (0, import_react24.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(seat)) ++ ) ++ )) ++ ) : null, ++ face ? null : (0, import_react24.createElement)( ++ Card, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react24.createElement)( + "div", @@ -9208,7 +9247,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +11367,177 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,87 +11367,194 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -9238,11 +9277,8 @@ diff --git a/client.js b/client.js useMorph(body, open); - return (0, import_react31.createElement)( + return (0, import_react32.createElement)( - "div", - null, -- (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), -- (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), -- long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null ++ "div", ++ null, + (0, import_react32.createElement)("div", { className: "bt-section-title" }, t(title)), + (0, import_react32.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), + long ? (0, import_react32.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null @@ -9289,6 +9325,26 @@ diff --git a/client.js b/client.js + (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), + (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) + )) ++ ); ++} ++function FaceMembers({ room: room2, roster, actions }) { ++ const seats = faceSeats(room2, roster); ++ return (0, import_react32.createElement)( + "div", + null, +- (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), +- (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), +- long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null ++ (0, import_react32.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: seats.length })), ++ (0, import_react32.createElement)("div", { className: "bt-members", role: "list" }, seats.map((seat) => { ++ const label = faceSeatLabel(seat); ++ const avatar = (0, import_react32.createElement)(BotAvatar, { bot: seat.local ?? lookBotForRemote(seat.record, roster), size: 26, badge: false, live: false }); ++ return (0, import_react32.createElement)( ++ "div", ++ { key: seat.key, role: "listitem", "data-flybot": "face-seat", "data-mine": seat.mine || void 0 }, ++ (0, import_react32.createElement)("div", { className: "bt-member" }, seat.local ? (0, import_react32.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_react32.createElement)("span", null, label)) : (0, import_react32.createElement)("span", { className: "bt-member-name" }, avatar, (0, import_react32.createElement)("span", null, label))) ++ ); ++ })) ); } function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) { @@ -9317,7 +9373,7 @@ diff --git a/client.js b/client.js + (0, import_react32.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) + ) : face ? null : room2.notice ? (0, import_react32.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react32.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), + face ? null : (0, import_react32.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), -+ face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : (0, import_react32.createElement)( ++ face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react32.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react32.createElement)( "div", { key: "members" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length })), @@ -10136,7 +10192,7 @@ diff --git a/client.js b/client.js } var clock3 = (time) => typeof time === "number" ? new Date(time).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }) : void 0; function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageImages, openFile, openSkill }) { -@@ -13178,28 +13178,46 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma +@@ -13178,28 +13178,50 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma const referenceLabels = data.referenceLabels ?? []; const references = openFile && openSkill ? { openFile, openSkill } : void 0; const steering = node.kind === "steering"; @@ -10145,6 +10201,7 @@ diff --git a/client.js b/client.js + const room2 = roster.roomsById?.[sessionId]; + 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); + return (0, import_react36.createElement)( "div", @@ -10158,6 +10215,7 @@ diff --git a/client.js b/client.js + "data-author-kind": author?.authorKind, + "data-author-name": author?.name + }, ++ person ? (0, import_react36.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react36.createElement)(PersonMark)) : null, + (0, import_react36.createElement)( "div", { className: "bt-ustack" }, @@ -10184,6 +10242,8 @@ diff --git a/client.js b/client.js .bt-umsg{display:flex;flex-direction:row-reverse;align-items:center;justify-content:flex-start;gap:4px} +.bt-umsg-other{flex-direction:row} +.bt-umsg-other .bt-ustack{align-items:flex-start} ++.bt-umsg-other>.bt-lead{align-self:flex-end;margin-right:4px} ++.bt-umsg-other .bt-ubody{background:var(--bt-bubble);color:var(--bt-ink)} +.bt-uauthor{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-weight:600} +.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} diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 5bdc63dc..53f96ce3 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -47,7 +47,7 @@ diff --git a/src/client/composer.js b/src/client/composer.js diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js --- a/src/client/details-drawer.js +++ b/src/client/details-drawer.js -@@ -2,7 +2,7 @@ import { createElement as h, Fragment, useEffect, useRef, useState } from 'react +@@ -2,11 +2,11 @@ import { createElement as h, Fragment, useEffect, useRef, useState } from 'react import { isMainOf } from './sources.js' import { HEADER, currentPart, stripHeader, usageOf } from './text.js' import { PlusIcon, ShareIcon, CloseIcon, ChevronLeftIcon, GearIcon } from './icons.js' @@ -56,6 +56,11 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js import { GlobeIcon, Favicon } from './browser-pane.js' import { useEscape } from './overlay-hooks.js' import { BotSettingsForm } from './bot-settings.js' +-import { GroupSettingsForm, ROOM_MODES, roomSubtitle, shownMode } from './group-settings.js' ++import { GroupSettingsForm, ROOM_MODES, faceSeatLabel, faceSeats, 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' @@ -14,6 +14,9 @@ import { ScheduleSummary } from './schedules.js' import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' @@ -66,7 +71,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js function transcriptOf(list, id, name, hiddenTurns = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline -@@ -113,16 +113,84 @@ function About({ text, title = 'About' }) { +@@ -113,16 +113,101 @@ function About({ text, title = 'About' }) { // A group's overview: what members read, who is in it, and who answers. Changes are // made in its settings. @@ -102,6 +107,21 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'approve') }, t('Approve')), + 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). ++function FaceMembers({ room, roster, actions }) { ++ const seats = faceSeats(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)))) ++ }))) ++} + function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) { + const live = room.presentedParticipants ?? room.participants ?? [] @@ -139,7 +159,9 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + face ? null : h(SharedRoomApprovals, { key: 'approvals', room }), + face && (room.face?.status === 'candidate' || room.face?.status === 'joining') + ? null -+ : h('div', { key: 'members' }, ++ : face ++ ? h(FaceMembers, { key: 'members', room, roster, actions }) ++ : h('div', { key: 'members' }, + h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: members.length + humans.length + remotes.length })), + h('div', { className: 'bt-members', role: 'list' }, [ + ...humans.map(person => h('div', { key: person.id, role: 'listitem' }, @@ -259,13 +281,43 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js import { Segmented } from './motion.js' import { FolderMenu } from './folder-menu.js' import { t } from './i18n.js' -+import { remoteBotTitle } from '../shared/remote-label.js' ++import { cleanPeerName, ownerNameOf, 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,6 +16,10 @@ export const ROOM_MODES = [ +@@ -15,7 +15,40 @@ 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') ++// A face-to-face room lists people, each with the Bot they brought, named one way for every ++// member: "Bob · Chief", "Ann (you) · Chief" (#229). 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) => { ++ const local = p.remote ? undefined : 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 room.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 ++} ++ ++export function faceSeatLabel(seat) { ++ if (!seat.person) return seat.bot ++ return `${seat.mine ? t('{name} (you)', { name: seat.person }) : seat.person} · ${seat.bot}` ++} ++ export function roomSubtitle(room, roster) { + if (room.origin === 'face-to-face') { + const live = (room.presentedParticipants ?? room.participants ?? []).filter(p => p.leftSeq === undefined) @@ -274,7 +326,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js const admin = roster.byId[room.admin] return [t('{count} members', { count: room.members.length }), admin ? t('Admin {name}', { name: admin.name }) : t('No admin')].join(' · ') } -@@ -31,8 +31,20 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -31,8 +31,22 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) }, [confirming]) @@ -283,6 +335,8 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js + const remotes = live.filter(p => p.remote && p.leftSeq === undefined) const members = room.members.map(id => roster.byId[id]).filter(Boolean) - const outside = roster.bots.filter(bot => !room.members.includes(bot.id)) ++ const face = room.origin === 'face-to-face' ++ const seats = face ? faceSeats(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) => { @@ -296,12 +350,17 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js const admin = roster.byId[room.admin] const mode = shownMode(room, roster) const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? '' -@@ -65,26 +65,50 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -65,26 +65,55 @@ 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 }) }))), - h(Card, { title: t('Members · {count}', { count: members.length }) }, -+ h(Card, { title: t('Members · {count}', { count: members.length + humans.length + remotes.length }) }, ++ 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 ? 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' }, + h('span', { className: 'bt-row-title' }, person.displayName || person.id), diff --git a/patches/ds-bot/files/test/face-people.test.js b/patches/ds-bot/files/test/face-people.test.js new file mode 100644 index 00000000..e4cd2803 --- /dev/null +++ b/patches/ds-bot/files/test/face-people.test.js @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #229: in a face-to-face room people come first. Another person's line is their own +// received bubble (name + person mark), and the members list names every member the same way. + +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 { GroupReplies } = await load('cells.js') +const { faceSeats, faceSeatLabel } = await load('group-settings.js') +const { UserMessageCell, USER_MESSAGE_CSS } = await load('user-message.js') + +test('another person\'s face-room line is their own received bubble with name and person mark', () => { + const roster = { bots: [], byId: {}, roomsById: { g1: { id: 'g1', origin: 'face-to-face', members: [], participants: [] } } } + const content = [{ type: 'text', text: JSON.stringify({ replies: [{ kind: 'user', other: true, name: 'SelfA0333', authorId: '@chief.selfa0333.home', text: 'hello from A' }] }) }] + const drawn = expand(GroupReplies({ sessionId: 'g1', callId: 'c1', done: true, content, roster, actions: {}, useUi: () => null }), ['PersonMessage', 'PersonMark', 'Bubbles']) + const [person] = find(drawn, node => node.props?.['data-person'] !== undefined) + assert.ok(person, 'drawn as a person') + assert.equal(person.props['data-person'], 'SelfA0333') + assert.equal(find(person, node => node.props?.className === 'bt-person-mark').length, 1, 'with an avatar') + assert.equal(find(person, node => node.props?.className === 'bt-group-name' && node.children.includes('SelfA0333')).length, 1) + assert.equal(find(person, node => node.props?.className === 'bt-bubble').length, 1, 'a received bubble') + assert.equal(find(drawn, node => node.props?.className === 'bt-ubody').length, 0, 'not the sent-bubble style') +}) + +test('a face room lists people, each with the Bot they brought, named one way', () => { + const roster = { + self: { memberId: 'm1', displayName: 'SelfB0333' }, + byId: { chief: { id: 'chief', name: 'Chief' } }, + } + const room = { + origin: 'face-to-face', + members: ['chief'], + presentedParticipants: [ + { kind: 'bot', id: 'chief', handle: '@chief.selfb0333.home', displayName: 'Chief', label: "SelfB0333's Chief" }, + { kind: 'bot', remote: true, id: '@chief.selfa0333.home', handle: '@chief.selfa0333.home', displayName: 'Chief', ownerName: 'SelfA0333', label: "SelfA0333's Chief" }, + { kind: 'bot', remote: true, id: '@coder.gone.home', handle: '@coder.gone.home', displayName: 'Coder', ownerName: 'Gone', leftSeq: 3 }, + ], + } + const seats = faceSeats(room, roster) + assert.deepEqual(seats.map(faceSeatLabel), ['SelfB0333 (you) · Chief', 'SelfA0333 · Chief']) + assert.deepEqual(seats.map(seat => seat.mine), [true, false]) +}) + +test('another home member\'s line shows their person mark and a received surface', () => { + const roster = { self: { memberId: 'm1' }, bots: [], byId: {}, roomsById: { g1: { id: 'g1', members: [], participants: [] } } } + const node = { kind: 'user', data: { content: [{ type: 'text', text: 'hi' }], source: { authorId: 'm2', authorKind: 'human', authorName: 'Ann' } } } + const cell = UserMessageCell({ node, sessionId: 'g1', useRoster: select => select(roster), actions: {} }) + const drawn = expand(cell, ['PersonMark']) + assert.match(drawn.props.className, /bt-umsg-other/) + assert.equal(find(drawn, n => n.props?.className === 'bt-person-mark').length, 1) + const mine = UserMessageCell({ node: { ...node, data: { ...node.data, source: { ...node.data.source, authorId: 'm1' } } }, sessionId: 'g1', useRoster: select => select(roster), actions: {} }) + assert.equal(find(expand(mine, ['PersonMark']), n => n.props?.className === 'bt-person-mark').length, 0, 'my own line has no mark') + assert.match(USER_MESSAGE_CSS, /\.bt-umsg-other \.bt-ubody\{background:var\(--bt-bubble\);color:var\(--bt-ink\)\}/) + // The bundle carries the same rule. + assert.match(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), /\.bt-umsg-other \.bt-ubody\{background:var\(--bt-bubble\)/) +}) diff --git a/plugins/social/client.js b/plugins/social/client.js index f1630a9a..1f916d42 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -185,7 +185,7 @@ window.__ModuleLoader__.load({ .fs-tile-hit:hover,.fs-tile-on{background:var(--bt-hover,rgba(119,119,119,.09))} .fs-tile-mark{position:relative;flex:none;display:inline-flex} .fs-tile-name{display:block;width:100%;font-size:12px;line-height:16px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--bt-ink,#141414)} -.fs-tile-owner{display:block;width:100%;font-size:11px;line-height:14px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--bt-ink-2,rgba(20,20,20,.6))} +.fs-tile-sub{display:block;width:100%;font-size:11px;line-height:14px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--bt-ink-2,rgba(20,20,20,.6))} .fs-wait-overview{margin:0 0 8px;padding:8px 10px;border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:8px;min-width:0;max-width:100%} .fs-wait-overview .fs-wait-foot{margin-top:2px} .fs-tile-mine{position:absolute;right:-1px;bottom:-1px;width:8px;height:8px;border-radius:50%;background:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px var(--bt-card,#fff);pointer-events:none} @@ -325,7 +325,7 @@ window.__ModuleLoader__.load({ function FaceTile({ bot, name, owner, member, handle, mine, pick, selected, onToggle } = {}) { const parts = member ? faceTileParts(member) : { name: name || 'Bot', owner: owner || '' } - const title = [parts.name, parts.owner].filter(Boolean).join(' · ') + const title = (member ? [parts.owner, parts.name] : [parts.name, parts.owner]).filter(Boolean).join(' · ') const mark = h('span', { className: 'fs-tile-mark' }, h(FaceAvatar, { bot, size: 48 }), mine @@ -334,10 +334,16 @@ window.__ModuleLoader__.load({ pick && selected ? h('span', { className: 'fs-tile-check', 'data-flybot': 'face-pick-check', 'aria-hidden': true }, '✓') : null) - const caption = [ - h('span', { key: 'n', className: 'fs-tile-name', title }, parts.name), - parts.owner ? h('span', { key: 'o', className: 'fs-tile-owner', 'data-flybot': 'face-owner', title: parts.owner }, parts.owner) : null, - ] + // A joined member leads with the person, so two Chiefs are told apart at a glance (#229). + const caption = member && parts.owner + ? [ + h('span', { key: 'o', className: 'fs-tile-name', 'data-flybot': 'face-owner', title }, parts.owner), + h('span', { key: 'n', className: 'fs-tile-sub', 'data-flybot': 'face-bot', title }, parts.name), + ] + : [ + h('span', { key: 'n', className: 'fs-tile-name', title }, parts.name), + parts.owner ? h('span', { key: 'o', className: 'fs-tile-sub', 'data-flybot': 'face-owner', title: parts.owner }, parts.owner) : null, + ] if (pick) { return h('li', { className: selected ? 'fs-tile fs-tile-on' : 'fs-tile' }, h('label', { className: 'fs-tile-hit' }, diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 6b994f01..a47d4c68 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -2516,8 +2516,11 @@ test('Face-to-face waiting overview is a tile grid with Leave and Enter chat', ( })) assert.ok(tree.some(n => n.props['data-flybot'] === 'face-overview' && n.props['data-wait'])) assert.ok(tree.some(n => n.props['data-flybot'] === 'face-wait-roster' && /\bfs-tiles\b/.test(n.props.className))) - assert.ok(tree.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief'))) - assert.ok(tree.some(n => n.props['data-flybot'] === 'face-owner' && n.children.includes('Me'))) + // #229: a joined tile leads with the person, the Bot they brought underneath. + assert.ok(tree.some(n => n.props.className === 'fs-tile-name' && n.props['data-flybot'] === 'face-owner' && n.children.includes('Me'))) + assert.ok(tree.some(n => n.props['data-flybot'] === 'face-bot' && n.children.includes('Chief'))) + assert.ok(tree.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Bob'))) + assert.equal(tree.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief')), false) assert.ok(tree.some(n => n.props['data-flybot'] === 'face-enter' && n.children.includes('Enter chat'))) assert.ok(tree.some(n => n.props['data-flybot'] === 'face-leave' && n.children.includes('Leave'))) assert.ok(tree.some(n => n.props['data-flybot'] === 'face-wait-code')) @@ -2957,10 +2960,11 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch assert.equal(peerMark.props['data-image'], 'https://example.test/coder.png') assert.equal(peerMark.props['data-size'], 48) assert.notEqual(mineMark.props['data-bot-id'], peerMark.props['data-bot-id']) - assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief'))) - assert.ok(wait.some(n => n.props['data-flybot'] === 'face-owner' && n.children.includes('Me'))) - assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Coder'))) - assert.ok(wait.some(n => n.props['data-flybot'] === 'face-owner' && n.children.includes('Bob'))) + assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.props['data-flybot'] === 'face-owner' && n.children.includes('Me'))) + assert.ok(wait.some(n => n.props['data-flybot'] === 'face-bot' && n.children.includes('Chief'))) + assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.props['data-flybot'] === 'face-owner' && n.children.includes('Bob'))) + assert.ok(wait.some(n => n.props['data-flybot'] === 'face-bot' && n.children.includes('Coder'))) + assert.equal(peer.props.title, 'Bob · Coder') assert.equal(mine.children.some(n => n.props?.className === 'fs-sub' || (typeof n.props?.className === 'string' && n.props.className.includes('fs-tag'))), false) assert.equal(peer.children.some(n => n.props?.className === 'fs-sub'), false) assert.equal(wait.some(n => n.props.className === 'fs-wait-hint' || n.props.className === 'fs-wait-name'), false) @@ -3018,8 +3022,8 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch cursor = 0 const liveNodes = liveClient.walk(livePlugin.FaceToFaceGroup({ actions: {} })) assert.ok(liveNodes.some(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@writer.alice.home')) - assert.ok(liveNodes.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Writer'))) - assert.ok(liveNodes.some(n => n.props['data-flybot'] === 'face-owner' && n.children.includes('Alice'))) + assert.ok(liveNodes.some(n => n.props['data-flybot'] === 'face-bot' && n.children.includes('Writer'))) + assert.ok(liveNodes.some(n => n.props.className === 'fs-tile-name' && n.props['data-flybot'] === 'face-owner' && n.children.includes('Alice'))) const autoOpened = [] const autoPlugin = pluginOf(['waiting', bots, ['a'], '123456', false, null, [], { ...room, status: 'open' }, 0], true)