diff --git a/changelog.d/182-face-room-looks.fixed.md b/changelog.d/182-face-room-looks.fixed.md new file mode 100644 index 00000000..3f44c51a --- /dev/null +++ b/changelog.d/182-face-room-looks.fixed.md @@ -0,0 +1 @@ +Face-to-face rooms: a Bot now looks the same on every home and in every place (waiting tiles, chat bubbles, Members, sidebar). A Bot with no look of its own sends the look its home generates for it to Bot Social, before it joins, so the other home no longer invents a different one; your own Bot in the waiting dialog uses its roster look right after joining; a friend's Bot speaks in the room with the look its owner gave it. Other people show their initial on a color picked from their name instead of a grey silhouette ([#182](https://github.com/ThinkFlowLab/FlyBot/issues/182)). diff --git a/core/client.js b/core/client.js index 9881364c..823ad1b4 100644 --- a/core/client.js +++ b/core/client.js @@ -1905,7 +1905,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 }) { +var personInitial = (name) => (Array.from(String(name ?? "").trim())[0] ?? "").toUpperCase(); +var personColor = (name) => defaultColor(`person:${String(name ?? "").trim().toLowerCase()}`); +function PersonMark({ size = 22, name }) { + const initial = personInitial(name); + if (initial) { + return (0, import_react.createElement)("span", { + className: "bt-person-mark bt-person-initial", + "data-initial": initial, + style: { width: size, height: size, background: inkFill(personColor(name)), fontSize: Math.max(9, Math.round(size * 0.46)) }, + "aria-hidden": true + }, initial); + } return (0, import_react.createElement)( "span", { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, @@ -3791,6 +3802,7 @@ var CONVERSATION_CSS = ` .bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1} .bt-think[open] .bt-think-caret{transform:rotate(90deg)} .bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))} +.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none} .bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))} .bt-remote-handle{display:inline-flex;align-items:center;gap:3px;margin-left:6px;font-weight:400;color:var(--bt-ink-3,rgba(20,20,20,.36))} .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} @@ -5261,7 +5273,7 @@ function HeldReply({ held, roster, sessionId, actions, closing, children }) { ); } function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { - const lead = (0, import_react8.createElement)("span", { className: "bt-lead", title: name }, (0, import_react8.createElement)(PersonMark)); + const lead = (0, import_react8.createElement)("span", { className: "bt-lead", title: name }, (0, import_react8.createElement)(PersonMark, { name })); return (0, import_react8.createElement)( "div", { className: "bt-group-msg bt-person-msg", "data-person": name }, @@ -5349,17 +5361,18 @@ function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi, (0, import_react8.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ); } -function remoteSpeaker(source, roster) { +function remoteSpeaker(source, roster, sessionId) { const handle = source.remote.handle; const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); const chat = (roster.bots ?? []).find((bot2) => bot2.remote?.handle === handle); + const seat = faceSeatLook(roster.roomsById?.[sessionId], handle); const name = remoteBotTitle({ displayName: chat?.name ?? member?.name ?? source.senderName ?? handle, ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, handle, isChief: member?.isChief ?? chat?.remote?.isChief }); - const bot = chat ?? lookBotForRemote({ + const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({ handle, displayName: name, color: member?.color, @@ -5368,9 +5381,18 @@ function remoteSpeaker(source, roster) { const carrier = cleanCarrier(chat?.name ?? member?.name); return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : void 0 }; } +function faceSeatLook(room2, handle) { + if (room2?.origin !== "face-to-face" || !handle) return void 0; + const seats = (room2.participants ?? []).filter((p) => p?.remote && (p.handle === handle || p.id === handle)); + const seat = seats.find((p) => p.leftSeq === void 0) ?? seats.at(-1); + const look = {}; + if (typeof seat?.color === "string" && seat.color) look.color = seat.color; + if (seat?.avatar && typeof seat.avatar === "object") look.avatar = seat.avatar; + return Object.keys(look).length ? look : void 0; +} var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { - const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster); + const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId); const files = Array.isArray(source.attachments) ? source.attachments : []; const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; if (person) { @@ -5384,7 +5406,7 @@ function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi person, carrier ? (0, import_react8.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null ), - (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react8.createElement)("span", { className: "bt-lead", title: person }, (0, import_react8.createElement)(PersonMark)) }), + (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react8.createElement)("span", { className: "bt-lead", title: person }, (0, import_react8.createElement)(PersonMark, { name: person })) }), files.length ? (0, import_react8.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null ); } @@ -15896,7 +15918,7 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma "data-author-kind": author?.authorKind, "data-author-name": author?.name }, - person ? (0, import_react39.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react39.createElement)(PersonMark)) : null, + person ? (0, import_react39.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react39.createElement)(PersonMark, { name: author.name })) : null, (0, import_react39.createElement)( "div", { className: "bt-ustack" }, diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 0dfa500c..95d23c5f 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -206,7 +206,7 @@ function HeldReply({ held, roster, sessionId, actions, closing, children }) { // 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)) + const lead = h('span', { className: 'bt-lead', title: name }, h(PersonMark, { name })) return h('div', { className: 'bt-group-msg bt-person-msg', 'data-person': name }, h('span', { className: 'bt-group-name' }, name), h(Bubbles, { texts, roster, selfId, actions, sessionId, msgKey, useUi, lead })) @@ -288,10 +288,13 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq }) // A friend's remote Bot speaking in this chat, laid out like any chat partner: its own // avatar (the one its sidebar row shows), its name, a small globe and its @handle. The // words are shown as data; the Bot reading them still gets them fenced as untrusted. -export function remoteSpeaker(source, roster) { +export function remoteSpeaker(source, roster, sessionId) { const handle = source.remote.handle const member = (roster.remoteBots ?? []).find(bot => bot.handle === handle) const chat = (roster.bots ?? []).find(bot => bot.remote?.handle === handle) + // In a face-to-face room the Bot's seat carries its owner's look (Bot Social members[]): the + // bubble draws that look, as the Members panel and the waiting tiles do (#182). + const seat = faceSeatLook(roster.roomsById?.[sessionId], handle) const name = remoteBotTitle({ displayName: chat?.name ?? member?.name ?? source.senderName ?? handle, ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, @@ -300,7 +303,7 @@ export function remoteSpeaker(source, roster) { }) // The sidebar's Bot record when there is one; else a synthetic one whose id seeds the // same handle-derived look the record would get (characters.js remoteSeed). - const bot = chat ?? lookBotForRemote({ + const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({ handle, displayName: name, color: member?.color, avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : undefined), }, roster) @@ -310,10 +313,21 @@ export function remoteSpeaker(source, roster) { return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : undefined } } +// The look a face room's remote seat holds for this handle (the live seat first), or undefined. +function faceSeatLook(room, handle) { + if (room?.origin !== 'face-to-face' || !handle) return undefined + const seats = (room.participants ?? []).filter(p => p?.remote && (p.handle === handle || p.id === handle)) + const seat = seats.find(p => p.leftSeq === undefined) ?? seats.at(-1) + const look = {} + if (typeof seat?.color === 'string' && seat.color) look.color = seat.color + if (seat?.avatar && typeof seat.avatar === 'object') look.avatar = seat.avatar + return Object.keys(look).length ? look : undefined +} + const cleanCarrier = value => (typeof value === 'string' && value.trim() && !/[@()]/.test(value) ? value.trim() : '') export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { - const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster) + const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId) const files = Array.isArray(source.attachments) ? source.attachments : [] // The friend typed this line themselves (Bot Social attested it; the host set `typedBy`): it is the // person's, with their name and a person mark, and the Bot that carried it only named as the route. @@ -323,7 +337,7 @@ export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey // One short header: the person, and the Bot that carried the line only when it is known (#275). h('span', { className: 'bt-group-name', title: handle }, person, carrier ? h('span', { className: 'bt-remote-handle' }, t('via {name}', { name: carrier })) : null), - h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark)) }), + h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark, { name: person })) }), files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) } const mark = h(BotMark, { bot, size: 22 }) diff --git a/core/src/client/mark.js b/core/src/client/mark.js index 80b599c5..dc323aad 100644 --- a/core/src/client/mark.js +++ b/core/src/client/mark.js @@ -1,5 +1,6 @@ import { createElement as h, useEffect, useRef, useState } from 'react' -import { fnv1a, lookOf, lookAvoidingLocals, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js' +import { fnv1a, lookOf, lookAvoidingLocals, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup, defaultColor } from './characters.js' +import { inkFill } from './inks.js' import { t } from './i18n.js' export { colorOf, lookOf, lookKey, lookKeys, lookAvoidingLocals, remoteSeed, lookBotForRemote } from './characters.js' @@ -87,7 +88,20 @@ function StarIcon({ size = 8 }) { } // 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 }) { +// With a name it is that person's initial on a color picked from the name, so each person looks +// the same on every home and two people are told apart (#182); without one, a plain silhouette. +export const personInitial = name => (Array.from(String(name ?? '').trim())[0] ?? '').toUpperCase() +export const personColor = name => defaultColor(`person:${String(name ?? '').trim().toLowerCase()}`) +export function PersonMark({ size = 22, name }) { + const initial = personInitial(name) + if (initial) { + return h('span', { + className: 'bt-person-mark bt-person-initial', + 'data-initial': initial, + style: { width: size, height: size, background: inkFill(personColor(name)), fontSize: Math.max(9, Math.round(size * 0.46)) }, + 'aria-hidden': true, + }, initial) + } 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' }))) diff --git a/core/src/client/styles.js b/core/src/client/styles.js index cbe3502f..f3a0f0a2 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -821,6 +821,7 @@ export const CONVERSATION_CSS = ` .bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1} .bt-think[open] .bt-think-caret{transform:rotate(90deg)} .bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))} +.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none} .bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))} .bt-remote-handle{display:inline-flex;align-items:center;gap:3px;margin-left:6px;font-weight:400;color:var(--bt-ink-3,rgba(20,20,20,.36))} .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} diff --git a/core/src/client/user-message.js b/core/src/client/user-message.js index add348b4..470deb61 100644 --- a/core/src/client/user-message.js +++ b/core/src/client/user-message.js @@ -153,7 +153,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, + person ? h('span', { className: 'bt-lead', title: author.name }, h(PersonMark, { name: author.name })) : 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, diff --git a/core/test/face-people.test.js b/core/test/face-people.test.js index 80031a00..89c7a964 100644 --- a/core/test/face-people.test.js +++ b/core/test/face-people.test.js @@ -57,7 +57,9 @@ test('another person\'s face-room line is their own received bubble with name an 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 => /\bbt-person-mark\b/.test(node.props?.className ?? '')).length, 1, 'with an avatar') + // Their initial, not a grey silhouette (#182). + assert.equal(find(person, node => node.props?.['data-initial'] === 'S').length, 1) 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') @@ -105,9 +107,9 @@ test('another home member\'s line shows their person mark and a received surface 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) + assert.equal(find(drawn, n => /\bbt-person-mark\b/.test(n.props?.className ?? '')).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.equal(find(expand(mine, ['PersonMark']), n => /\bbt-person-mark\b/.test(n.props?.className ?? '')).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/core/test/inbound-ui.test.js b/core/test/inbound-ui.test.js index 76316afa..a3465f8a 100644 --- a/core/test/inbound-ui.test.js +++ b/core/test/inbound-ui.test.js @@ -7,7 +7,7 @@ import { build } from 'esbuild' // The inbound friend conversation's display: trace lines, the thinking row, the remote speaker. const { outputFiles } = await build({ stdin: { - contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker, RemoteMessage, FirstContact } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople } from './src/client/mark.js'", + contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker, RemoteMessage, FirstContact } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople, PersonMark } from './src/client/mark.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { stripTrace, previewOf, nameMentions, remoteSpeaker, RemoteMessage, FirstContact, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople } = module.exports +const { stripTrace, previewOf, nameMentions, remoteSpeaker, RemoteMessage, FirstContact, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople, PersonMark } = module.exports test('a reply opening with a delegation trace shows without it', () => { assert.equal(stripTrace('Chief → 助手\n\nHi Chief! Good to hear from you.'), 'Hi Chief! Good to hear from you.') @@ -212,3 +212,44 @@ test("#420: a sender's first line in a Bot's own chat gets one quiet line naming const bot = FirstContact({ roster: known, sessionId: 's', source: { remote: { handle: '@helper.selfb1238.home', firstContact: true }, senderName: 'Helper' } }) assert.equal(text(bot), 'Helper (SelfB1238) is talking to Chief through Bot Social') }) + +test("a face room's remote Bot speaks with its seat's look, the one Members and the waiting tiles draw (#182)", () => { + const handle = '@chief-9a.bob.home' + const seat = { kind: 'bot', remote: true, id: handle, handle, displayName: 'Chief', ownerName: 'Bob', color: '#2a92fe', avatar: { shape: 'whale' } } + const room = { id: 'room-1', origin: 'face-to-face', participants: [seat] } + const source = { remote: { handle }, senderName: 'Chief' } + const roster = { bots: [], remoteBots: [], roomsById: { 'room-1': room } } + const spoke = remoteSpeaker(source, roster, 'room-1') + assert.equal(lookOf(spoke.bot).shape, 'whale') + assert.equal(lookOf(spoke.bot).color, '#2a92fe') + // The Members panel draws the seat through lookBotForRemote: the same look. + const members = lookOf(lookBotForRemote(seat, roster)) + assert.equal(lookOf(spoke.bot).shape, members.shape) + assert.equal(lookOf(spoke.bot).color, members.color) + // A chat record for the same handle keeps its id (its chat opens from the bubble) but wears the seat's look. + const chat = { id: 'session-c', name: 'Chief', color: 'orange', avatar: { shape: 'star' }, remote: { handle } } + const withChat = remoteSpeaker(source, { ...roster, bots: [chat] }, 'room-1') + assert.equal(withChat.bot.id, 'session-c') + assert.equal(withChat.chatId, 'session-c') + assert.equal(lookOf(withChat.bot).shape, 'whale') + // Outside a face room (or a seat with no look) nothing changes: the chat record as before. + assert.equal(remoteSpeaker(source, { ...roster, bots: [chat] }).bot, chat) + const bare = { ...room, participants: [{ ...seat, color: undefined, avatar: undefined }] } + assert.equal(remoteSpeaker(source, { bots: [chat], remoteBots: [], roomsById: { 'room-1': bare } }, 'room-1').bot, chat) +}) + +test("a person's mark is their initial on a color from their name, the same on every home (#182)", () => { + const bob = PersonMark({ name: 'Bob', size: 22 }) + assert.equal(bob.props['data-initial'], 'B') + assert.deepEqual(bob.props.children, ['B']) + assert.match(bob.props.className, /bt-person-initial/) + assert.equal(PersonMark({ name: ' bob ' }).props.style.background, bob.props.style.background) + // Two people are told apart by initial or color. + const ann = PersonMark({ name: 'Ann' }) + assert.notEqual(`${ann.props['data-initial']}${ann.props.style.background}`, `${bob.props['data-initial']}${bob.props.style.background}`) + assert.equal(PersonMark({ name: '小明' }).props['data-initial'], '小') + // No name: the plain silhouette, as before. + const plain = PersonMark({}) + assert.equal(plain.props['data-initial'], undefined) + assert.equal(plain.props.className, 'bt-person-mark') +}) diff --git a/core/test/turn-tail.test.js b/core/test/turn-tail.test.js index d631cf2b..a6d10b70 100644 --- a/core/test/turn-tail.test.js +++ b/core/test/turn-tail.test.js @@ -109,7 +109,7 @@ test('a reply to a line the friend typed themselves is drawn as the person, not const drawn = expand(AssistantCell(typed), ['PersonMessage', 'PersonMark', 'Bubbles']) const [person] = find(drawn, node => node.props?.['data-person'] !== undefined) assert.equal(person.props['data-person'], 'Bob') - assert.equal(find(drawn, node => node.props?.className === 'bt-person-mark').length, 1) + assert.equal(find(drawn, node => /\bbt-person-mark\b/.test(node.props?.className ?? '')).length, 1) assert.equal(find(drawn, node => node.props?.className === 'bt-group-name' && node.children.includes('Bob')).length, 1) assert.equal(find(drawn, node => node.type === MarkdownText).length, 1, 'the text is still rendered as markdown') // Another turn, or no mark at all: the Bot\'s own bubble. diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 89fde1f0..7e80c794 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -116,7 +116,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - Slot `flybot.new-chat.action` (`0003-flybot-api-client`, types in `flybot.d.ts`) is a row on the sidebar **+** new-chat menu, after New 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 room 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 **New 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**; every row has its own mark (`ChatIcon`, `BotIcon`, `PeopleIcon`, a plugin row's `icon`, drawn through `SlotIcon` behind its own error boundary with the people mark as fallback; a function or memo/forwardRef component, `TerminalIcon`), one verb style (New … / Add …) and one short hint with no closing period (#385: *Message one of your Bots*, *Set up a Bot with its own role*, *Chat with several of your Bots*, *Meet people nearby with a 6-digit number*, *Paste an invite link or code*, *A coding session without a Bot*). A Bot's right-click menu has **Edit Bot** and no separate **Rename Bot** (the name is edited there); **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 `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`). +- `GroupReplies` draws a `user` reply with `other: true` (another member's face-room line, #180) as `PersonMessage` (`cells.js`): the person's name, the person mark (`PersonMark` in `mark.js`) and a received `--bt-bubble` bubble, not a centred `bt-event` notice and not the sent `--bt-user` style; own lines keep the right-hand bubble (#229). `other` comes only from the host's person-line post (`hooks.js` `postHuman`), never from text. Another home member's line in a shared group (`UserMessageCell`, author stamped by the host) gets the same person mark and the received surface (`.bt-umsg-other .bt-ubody`). `PersonMark({ name })` draws the person's initial on a color picked from the name (`personColor`, the core default-color hash of `person:`), so one person looks the same on every home and two people are told apart; without a name it is the plain silhouette (#182). - A face-to-face room lists people first (`faceSeats` / `faceSeatLabel` in `face-seats.js`, re-exported by `group-settings.js`, used by the overview, group settings and the bubble's @ chips): one row per live Bot, ` · `, with `(you)` on my own (`SelfB (you) · Chief`, `SelfA · Chief`), built from participant fields and the roster, never message text (#229). - **One name for a friend's Bot (#384, #305, owner decision 2026-10-19).** `remoteBotTitle` (`shared/remote-label.js`) is the only format: the Bot's name, then its owner in parentheses (`ownerNameOf`: the human owner, `ownerName` / `owner`, before the home's label `homeName`), `Helper (Bob)`, and a friend's Chief the same, `Chief (Bob)` (no `'s Chief` special case). The chat header, tab title, composer placeholder, row tooltip, approval overlay (`flybot-slots.js` `recipientName`), the `@` rows (a friend's Bot by that name with its handle as the description; the inserted text is unchanged; a friend's Bot is offered once: in a group by its seat, in a Bot's chat by its remote member row, which inserts the handle, not also by its chat row) and in a face room `faceSeatLabel` / `at-menu.js` `faceLabel` (`Chief (Bob)`, my own Bot `Chief (you)`) all use it. `faceAliases` keeps the older labels (`Bob · Chief`, `Chief · Bob's Bot`, `Bob's Chief`) as aliases, so a line typed before the change still calls the same Bot. DSH's own Sessions rows in a face room's `@` menu are filtered in FlyBot (`face-at-filter.js`, below) until DSH has an API for it (#422). - A face-to-face room's subtitle (`roomSubtitle` → `faceRoomCount` in `group-settings.js`) names what it counts, `1 person · 1 Bot` / `2 people · 4 Bots`: Bots are the live Bot seats, people the distinct owners among them (me once, a friend by owner name); human seats and Bots that left do not count (#306). @@ -150,6 +150,6 @@ A discarded `.catch(() => {})` in the client sources either starts with `void` ( `npm test` in `core/` (it also fails when `client.js` is stale). - A friend's line that Bot Social attests the friend typed themselves (#227) is drawn as the person: `AssistantCell` looks the turn up in `roster.remoteTypedTurns` (`typedBy` in `src/client/text.js`) and renders `PersonMessage` — the person's name and a person mark (`.bt-person-mark`) — instead of the Bot's `Bubbles`. The name comes from the roster (the authenticated Bot Social `from`), never from message text. -- The same attested line sent to one of my own Bots outside that friend's chat (a friend's Chief typing to my Helper) arrives as a remote message whose source has `remote.typedBy`; `RemoteMessage` (`cells.js`) then draws it as the person (name, person mark, `via `) instead of the friend's Bot avatar and handle. Without `typedBy` it keeps the Bot rendering (#233 follow-up). The `via` name is the carrying Bot's plain name when this home knows that Bot (its chat or `roster.remoteBots`), and is left out when it does not; the model-facing sender label (`X (typed via @…, Bot Social)`) and the handle-derived `· owner's Bot` never reach that header (#275). Settings → Bots offers only the user's own Bots as a Main Bot, in the first-Main-Bot and Add a Main Bot selects (#276). +- The same attested line sent to one of my own Bots outside that friend's chat (a friend's Chief typing to my Helper) arrives as a remote message whose source has `remote.typedBy`; `RemoteMessage` (`cells.js`) then draws it as the person (name, person mark, `via `) instead of the friend's Bot avatar and handle. Without `typedBy` it keeps the Bot rendering (#233 follow-up). In a face-to-face room `remoteSpeaker(source, roster, sessionId)` draws the Bot with its room seat's look (the participant's `color` / `avatar` from Bot Social `members[]`), so the bubble matches the Members panel, the room avatar and the waiting tiles; a chat record for that handle keeps its id and wears the seat's look (#182). The `via` name is the carrying Bot's plain name when this home knows that Bot (its chat or `roster.remoteBots`), and is left out when it does not; the model-facing sender label (`X (typed via @…, Bot Social)`) and the handle-derived `· owner's Bot` never reach that header (#275). Settings → Bots offers only the user's own Bots as a Main Bot, in the first-Main-Bot and Add a Main Bot selects (#276). - A friend's line in my Bot's own chat (#420): `friend-lines.js` `friendLineNews` compares each Bot's host stamp `friendLineAt` with the last stamp this browser has shown (`localStorage` `ds-bot:friend-lines`; stamps against stamps, never this browser's clock) and, when a newer one arrives while that chat is not visited, `index.js` marks the Bot unread the way **Mark as Unread** does, so the row shows the blue dot until the chat is opened. A browser with no record yet takes what is there as shown; another member's shared Bot and a friend's Bot chat are skipped. The Main Bot tile shows the same dot (`.bt-tile-unread`; a wait still shows on its avatar). A line whose source has `remote.firstContact` opens with one muted `FirstContact` line (`cells.js`): ` is talking to through Bot Social`. Covered by `core/test/friend-lines.test.js` and `core/test/inbound-ui.test.js`. - The **NEW** divider (`cells.js` `TurnTailCell` / `TurnProcessCell` / the unread opening) compares turn times with the visit's `seen` (when the chat was last left) and `entered` (when this visit opened). Once the divider has been on screen (`NewMessagePills` in `header.js`, `view.divider === 'in-view'`) and a shown turn begins after the visit opened (`movedOnSince`: a friend's next line, the user's own message; hidden exchange turns do not count), `actions.settleVisit` sets `seen` to `entered` (`settledVisit`) and every divider of the visit goes, as on leaving and coming back. A divider still scrolled out of view, or a chat where nothing new happened, keeps it until the visit ends. Same rule in every chat, including a friend's typed line in the Bot's own chat (#275). Covered by `core/test/turn-tail.test.js`. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index dfca6665..dedf7b84 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -122,7 +122,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### Onboarding, grants, hide, and approval (#136–#140) - **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Your handle is shown in Add friend (#181). -- **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes, on startup if not yet synced, and again for every new identity of that Bot (the synced record names the identity: a room-only identity is released after its last face room and the next room registers a new one, #182). `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged. +- **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes, on startup if not yet synced, and again for every new identity of that Bot (the synced record names the identity: a room-only identity is released after its last face room and the next room registers a new one, #182). `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. What is sent is the look the owner's home draws (`shownLook` in `look.js`): a Bot with no stored shape or color gets the id-picked default of core `characters.js` (`defaultShapeOf` / `defaultColorOf`, kept equal to core by a test), so the other home shows that look and not a handle-seeded one; a stored image or emoji is sent as is, image before emoji before shape as `BotMark` draws them. A new identity gets its look before `registerOnce` returns, so a face-room join never shows peers a seeded look first (#182). In the waiting dialog my own Bot is found by its room record (`remote: false`, local id), the Bot list handle, or the `joinedAs` of this join (`mineBots`), and always uses the roster look (#182). Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged. - **#138 / #161 / #164:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`; a label `grantPersonView` already made keeps its display name when `syncOutgoingGrants` reads the normalized grant again (#275), and only a bare handle falls back to the lowercase home. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles`) and room-only remotes, but a handle the joiner explicitly received (`inboundBots[handle]`) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (`isTalkThroughGrant`: grantee in `inboundBots`, and either a relationship this home joined or, for joins from before that record, the home's first relationship with the Bot in `via`). `social/join-invite` records the joined relationship (`settings.joinedRelationships[rid]`), because Bot Social lists a friendship's relationships (`relationship_id` = the first, `relationship_ids` = all) without saying who joined, and both sides of a relationship hold a grant naming the other's Bot: matching any of `relationship_ids` would hide a real share when a Bot shared with us later joins our invite (#383: an already-shared Chief that joined the friend's Helper invite listed the friend twice; a join made before the record, in a second relationship, still shows twice until it is joined again). `social/access` returns **one row per person** (`accessRows`, keyed by the person's home): a row carries `grantIds`, and `social/revoke-grant` with `grantIds` revokes each of them, so two shares of one Bot to one person are one row and one Revoke. A grant to a Bot shared with us in a relationship not recorded as joined (possibly the talk-through side of an older join) keeps a row of its own and is never in another row's `grantIds`, so the person's Revoke cannot break that chat. Every revoke runs in a per-Bot fence: a `syncOutgoingGrants` whose `list_bot_grants` overlapped it does not store that list and resolves `false` (fail closed), and the roster refreshes once after the fence. The joined relationship and the offered Bot are recorded right after `join_invite`, before the owner-approval follow-up. Join copy names the person (`owner_display_name`), not the shared Bot. `social/join-invite` marks the offered Bot inbound **before** its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). Bot Social's real `join_invite` answer has no `bot` field: `inviter` is the invited Bot itself (a member handle such as `@chief.bob.home`; `joined.bot`, `inviter.bot` and `offered_bot` are the older shapes, with `inviter` at the home). A member-level `inviter` counts as the offered Bot, so it is marked inbound before the refresh and the toast names it (#342; without it #303's awaited sync stored the server's talk-through grant on the joiner's Bot as the joiner sharing back, and the re-filter hid the inviter's Bot and listed the inviter in the joiner's People with access). A talk-through grant is never sharing: the joiner's Bots stay default-deny after a join. A Bot that appears on a roster this home already fetched and is not in `inboundBots` waits for the grant sync before the roster is published (#303; "already fetched" is the `rosterFetched` flag, so an empty roster of a home with no friends yet counts and the first friend's Bot waits too; the very first fetch does not wait, #92, as `registerAll` synced grants at start): the Bot that just joined our invite would otherwise be published for a moment, get a chat record on this home and be marked gone seconds later (the next load opened that dead chat). The awaited sync classifies talk-through grants against the fresh roster (`syncOutgoingGrants({ roster })`), not the previous `remote`, so a refresh that also brings another new Bot cannot store a talk-through grant as a core grant. `syncOutgoingGrants` resolves `false` when any Bot's grants could not be listed; the publish then fails closed: the unseen Bots are withheld from this publish and from the background re-filter (`withheldRemotes`), stay unseen for the next fetch (which syncs again), and appear only after a sync succeeds. Bots already seen and Bots shared with us never wait, so the usual refresh still re-filters in the background. A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): a `list_remote_bots` answer that lands after a newer one (the background refresh `hide-remote` starts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open **People with access** re-reads `social/access` every 5 s while visible, and `social/access` syncs only that Bot's grants (`syncOutgoingGrants({ only })`). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (`list_bot_grants`); FlyBot filters it on both sides (#161, #178). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. - **#139:** **Remove this Bot** asks once, inline in the friend Bot's settings (`Remove Helper? Bob can share it again.` · **Remove** · **Keep**, #230; never the browser `confirm()`), and the friend's **Auto-send** checkbox says what it covers (`Auto-send my Bots' replies to Bob` with a one-line hint, #417) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (“You're already friends…”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. - **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You. diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 0a8bf52c..5d8a8030 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -517,14 +517,14 @@ diff --git a/src/client/cells.js b/src/client/cells.js +// 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)) ++ const lead = h('span', { className: 'bt-lead', title: name }, h(PersonMark, { name })) + return h('div', { className: 'bt-group-msg bt-person-msg', 'data-person': name }, + h('span', { className: 'bt-group-name' }, name), + h(Bubbles, { texts, roster, selfId, actions, sessionId, msgKey, useUi, lead })) } function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { -@@ -170,16 +170,157 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { +@@ -170,16 +170,171 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { return h('div', { className: 'bt-event' }, parts) } @@ -592,10 +592,13 @@ diff --git a/src/client/cells.js b/src/client/cells.js +// A friend's remote Bot speaking in this chat, laid out like any chat partner: its own +// avatar (the one its sidebar row shows), its name, a small globe and its @handle. The +// words are shown as data; the Bot reading them still gets them fenced as untrusted. -+export function remoteSpeaker(source, roster) { ++export function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle + const member = (roster.remoteBots ?? []).find(bot => bot.handle === handle) + const chat = (roster.bots ?? []).find(bot => bot.remote?.handle === handle) ++ // In a face-to-face room the Bot's seat carries its owner's look (Bot Social members[]): the ++ // bubble draws that look, as the Members panel and the waiting tiles do (#182). ++ const seat = faceSeatLook(roster.roomsById?.[sessionId], handle) + const name = remoteBotTitle({ + displayName: chat?.name ?? member?.name ?? source.senderName ?? handle, + ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, @@ -604,7 +607,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + }) + // The sidebar's Bot record when there is one; else a synthetic one whose id seeds the + // same handle-derived look the record would get (characters.js remoteSeed). -+ const bot = chat ?? lookBotForRemote({ ++ const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({ + handle, displayName: name, color: member?.color, + avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : undefined), + }, roster) @@ -614,10 +617,21 @@ diff --git a/src/client/cells.js b/src/client/cells.js + return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : undefined } +} + ++// The look a face room's remote seat holds for this handle (the live seat first), or undefined. ++function faceSeatLook(room, handle) { ++ if (room?.origin !== 'face-to-face' || !handle) return undefined ++ const seats = (room.participants ?? []).filter(p => p?.remote && (p.handle === handle || p.id === handle)) ++ const seat = seats.find(p => p.leftSeq === undefined) ?? seats.at(-1) ++ const look = {} ++ if (typeof seat?.color === 'string' && seat.color) look.color = seat.color ++ if (seat?.avatar && typeof seat.avatar === 'object') look.avatar = seat.avatar ++ return Object.keys(look).length ? look : undefined ++} ++ +const cleanCarrier = value => (typeof value === 'string' && value.trim() && !/[@()]/.test(value) ? value.trim() : '') + +export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { -+ const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster) ++ const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId) + const files = Array.isArray(source.attachments) ? source.attachments : [] + // The friend typed this line themselves (Bot Social attested it; the host set `typedBy`): it is the + // person's, with their name and a person mark, and the Bot that carried it only named as the route. @@ -627,7 +641,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js + // One short header: the person, and the Bot that carried the line only when it is known (#275). + h('span', { className: 'bt-group-name', title: handle }, person, + carrier ? h('span', { className: 'bt-remote-handle' }, t('via {name}', { name: carrier })) : null), -+ h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark)) }), ++ h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark, { name: person })) }), + files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) + } + const mark = h(BotMark, { bot, size: 22 }) @@ -2247,13 +2261,14 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -782,6 +782,11 @@ export const CONVERSATION_CSS = ` +@@ -782,6 +782,12 @@ export const CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} .bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} .bt-think summary::-webkit-details-marker{display:none} +.bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1} +.bt-think[open] .bt-think-caret{transform:rotate(90deg)} +.bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))} ++.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none} +.bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))} +.bt-remote-handle{display:inline-flex;align-items:center;gap:3px;margin-left:6px;font-weight:400;color:var(--bt-ink-3,rgba(20,20,20,.36))} .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} @@ -2427,7 +2442,7 @@ 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, ++ person ? h('span', { className: 'bt-lead', title: author.name }, h(PersonMark, { name: author.name })) : 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, @@ -2692,7 +2707,7 @@ diff --git a/test/turn-tail.test.js b/test/turn-tail.test.js + const drawn = expand(AssistantCell(typed), ['PersonMessage', 'PersonMark', 'Bubbles']) + const [person] = find(drawn, node => node.props?.['data-person'] !== undefined) + assert.equal(person.props['data-person'], 'Bob') -+ assert.equal(find(drawn, node => node.props?.className === 'bt-person-mark').length, 1) ++ assert.equal(find(drawn, node => /\bbt-person-mark\b/.test(node.props?.className ?? '')).length, 1) + assert.equal(find(drawn, node => node.props?.className === 'bt-group-name' && node.children.includes('Bob')).length, 1) + assert.equal(find(drawn, node => node.type === MarkdownText).length, 1, 'the text is still rendered as markdown') + // Another turn, or no mark at all: the Bot\'s own bubble. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index ce5d58fe..f46fb854 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -101,10 +101,11 @@ diff --git a/src/client/characters.js b/src/client/characters.js diff --git a/src/client/mark.js b/src/client/mark.js --- a/src/client/mark.js +++ b/src/client/mark.js -@@ -1,8 +1,8 @@ +@@ -1,8 +1,9 @@ import { createElement as h, useEffect, useRef, useState } from 'react' -import { fnv1a, lookOf, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js' -+import { fnv1a, lookOf, lookAvoidingLocals, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js' ++import { fnv1a, lookOf, lookAvoidingLocals, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup, defaultColor } from './characters.js' ++import { inkFill } from './inks.js' import { t } from './i18n.js' -export { colorOf, lookOf } from './characters.js' @@ -144,12 +145,25 @@ 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 }) { +@@ -79,6 +79,26 @@ 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 }) { ++// With a name it is that person's initial on a color picked from the name, so each person looks ++// the same on every home and two people are told apart (#182); without one, a plain silhouette. ++export const personInitial = name => (Array.from(String(name ?? '').trim())[0] ?? '').toUpperCase() ++export const personColor = name => defaultColor(`person:${String(name ?? '').trim().toLowerCase()}`) ++export function PersonMark({ size = 22, name }) { ++ const initial = personInitial(name) ++ if (initial) { ++ return h('span', { ++ className: 'bt-person-mark bt-person-initial', ++ 'data-initial': initial, ++ style: { width: size, height: size, background: inkFill(personColor(name)), fontSize: Math.max(9, Math.round(size * 0.46)) }, ++ 'aria-hidden': true, ++ }, initial) ++ } + 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' }))) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index bdbbfe12..ec76c2aa 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -938,7 +938,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,162 +1242,508 @@ function maskOf(shape) { +@@ -1242,162 +1242,519 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -960,7 +960,6 @@ diff --git a/client.js b/client.js - value = resolved; - for (const listener of [...listeners2]) listener(); - } -- }; +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -997,13 +996,7 @@ diff --git a/client.js b/client.js + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [enabled]); - } --var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; --var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); --function indexRoster(value) { -- const byId = {}; -- for (const bot of value.bots) { -- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; ++} +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -1029,24 +1022,23 @@ diff --git a/client.js b/client.js + onAnimationEnd: poked ? (event) => { + if (/^bt-poke/.test(event.animationName)) setPoked(false); + } : void 0 -+ }; + }; + if (look.image) return (0, import_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); + if (look.emoji) { + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("span", { + className: "bt-mark-emoji", + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); - } -- const roomsById = {}; -- for (const room2 of value.rooms) roomsById[room2.id] = room2; -- const agentsById = {}; -- const agentOf = {}; -- for (const agent of value.agents ?? []) { -- agentsById[agent.id] = agent; -- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; ++ } + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); -+} + } +-var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +-var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +-function indexRoster(value) { +- const byId = {}; +- for (const bot of value.bots) { +- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", @@ -1054,7 +1046,25 @@ 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 }) { ++var personInitial = (name) => (Array.from(String(name ?? "").trim())[0] ?? "").toUpperCase(); ++var personColor = (name) => defaultColor(`person:${String(name ?? "").trim().toLowerCase()}`); ++function PersonMark({ size = 22, name }) { ++ const initial = personInitial(name); ++ if (initial) { ++ return (0, import_react.createElement)("span", { ++ className: "bt-person-mark bt-person-initial", ++ "data-initial": initial, ++ style: { width: size, height: size, background: inkFill(personColor(name)), fontSize: Math.max(9, Math.round(size * 0.46)) }, ++ "aria-hidden": true ++ }, initial); + } +- const roomsById = {}; +- for (const room2 of value.rooms) roomsById[room2.id] = room2; +- const agentsById = {}; +- const agentOf = {}; +- for (const agent of value.agents ?? []) { +- agentsById[agent.id] = agent; +- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + return (0, import_react.createElement)( + "span", + { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, @@ -2162,13 +2172,14 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2599,6 +2599,11 @@ var CONVERSATION_CSS = ` +@@ -2599,6 +2599,12 @@ var CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} .bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} .bt-think summary::-webkit-details-marker{display:none} +.bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1} +.bt-think[open] .bt-think-caret{transform:rotate(90deg)} +.bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))} ++.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none} +.bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))} +.bt-remote-handle{display:inline-flex;align-items:center;gap:3px;margin-left:6px;font-weight:400;color:var(--bt-ink-3,rgba(20,20,20,.36))} .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} @@ -3574,7 +3585,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,215 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +3940,225 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -3596,7 +3607,7 @@ diff --git a/client.js b/client.js + ); +} +function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { -+ const lead = (0, import_react8.createElement)("span", { className: "bt-lead", title: name }, (0, import_react8.createElement)(PersonMark)); ++ const lead = (0, import_react8.createElement)("span", { className: "bt-lead", title: name }, (0, import_react8.createElement)(PersonMark, { name })); + return (0, import_react8.createElement)( + "div", + { className: "bt-group-msg bt-person-msg", "data-person": name }, @@ -3699,17 +3710,18 @@ diff --git a/client.js b/client.js + (0, import_react8.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); +} -+function remoteSpeaker(source, roster) { ++function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); + const chat = (roster.bots ?? []).find((bot2) => bot2.remote?.handle === handle); ++ const seat = faceSeatLook(roster.roomsById?.[sessionId], handle); + const name = remoteBotTitle({ + displayName: chat?.name ?? member?.name ?? source.senderName ?? handle, + ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, + handle, + isChief: member?.isChief ?? chat?.remote?.isChief + }); -+ const bot = chat ?? lookBotForRemote({ ++ const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({ + handle, + displayName: name, + color: member?.color, @@ -3718,9 +3730,18 @@ diff --git a/client.js b/client.js + const carrier = cleanCarrier(chat?.name ?? member?.name); + return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : void 0 }; +} ++function faceSeatLook(room2, handle) { ++ if (room2?.origin !== "face-to-face" || !handle) return void 0; ++ const seats = (room2.participants ?? []).filter((p) => p?.remote && (p.handle === handle || p.id === handle)); ++ const seat = seats.find((p) => p.leftSeq === void 0) ?? seats.at(-1); ++ const look = {}; ++ if (typeof seat?.color === "string" && seat.color) look.color = seat.color; ++ if (seat?.avatar && typeof seat.avatar === "object") look.avatar = seat.avatar; ++ return Object.keys(look).length ? look : void 0; ++} +var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : ""; +function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { -+ const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster); ++ const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster, sessionId); + const files = Array.isArray(source.attachments) ? source.attachments : []; + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { @@ -3734,7 +3755,7 @@ diff --git a/client.js b/client.js + person, + carrier ? (0, import_react8.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null + ), -+ (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react8.createElement)("span", { className: "bt-lead", title: person }, (0, import_react8.createElement)(PersonMark)) }), ++ (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react8.createElement)("span", { className: "bt-lead", title: person }, (0, import_react8.createElement)(PersonMark, { name: person })) }), + files.length ? (0, import_react8.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null + ); + } @@ -12354,7 +12375,7 @@ diff --git a/client.js b/client.js + "data-author-kind": author?.authorKind, + "data-author-name": author?.name + }, -+ person ? (0, import_react39.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react39.createElement)(PersonMark)) : null, ++ person ? (0, import_react39.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react39.createElement)(PersonMark, { name: author.name })) : null, + (0, import_react39.createElement)( "div", { className: "bt-ustack" }, diff --git a/patches/ds-bot/files/test/face-people.test.js b/patches/ds-bot/files/test/face-people.test.js index 80031a00..89c7a964 100644 --- a/patches/ds-bot/files/test/face-people.test.js +++ b/patches/ds-bot/files/test/face-people.test.js @@ -57,7 +57,9 @@ test('another person\'s face-room line is their own received bubble with name an 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 => /\bbt-person-mark\b/.test(node.props?.className ?? '')).length, 1, 'with an avatar') + // Their initial, not a grey silhouette (#182). + assert.equal(find(person, node => node.props?.['data-initial'] === 'S').length, 1) 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') @@ -105,9 +107,9 @@ test('another home member\'s line shows their person mark and a received surface 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) + assert.equal(find(drawn, n => /\bbt-person-mark\b/.test(n.props?.className ?? '')).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.equal(find(expand(mine, ['PersonMark']), n => /\bbt-person-mark\b/.test(n.props?.className ?? '')).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/patches/ds-bot/files/test/inbound-ui.test.js b/patches/ds-bot/files/test/inbound-ui.test.js index 76316afa..a3465f8a 100644 --- a/patches/ds-bot/files/test/inbound-ui.test.js +++ b/patches/ds-bot/files/test/inbound-ui.test.js @@ -7,7 +7,7 @@ import { build } from 'esbuild' // The inbound friend conversation's display: trace lines, the thinking row, the remote speaker. const { outputFiles } = await build({ stdin: { - contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker, RemoteMessage, FirstContact } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople } from './src/client/mark.js'", + contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker, RemoteMessage, FirstContact } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople, PersonMark } from './src/client/mark.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { stripTrace, previewOf, nameMentions, remoteSpeaker, RemoteMessage, FirstContact, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople } = module.exports +const { stripTrace, previewOf, nameMentions, remoteSpeaker, RemoteMessage, FirstContact, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople, PersonMark } = module.exports test('a reply opening with a delegation trace shows without it', () => { assert.equal(stripTrace('Chief → 助手\n\nHi Chief! Good to hear from you.'), 'Hi Chief! Good to hear from you.') @@ -212,3 +212,44 @@ test("#420: a sender's first line in a Bot's own chat gets one quiet line naming const bot = FirstContact({ roster: known, sessionId: 's', source: { remote: { handle: '@helper.selfb1238.home', firstContact: true }, senderName: 'Helper' } }) assert.equal(text(bot), 'Helper (SelfB1238) is talking to Chief through Bot Social') }) + +test("a face room's remote Bot speaks with its seat's look, the one Members and the waiting tiles draw (#182)", () => { + const handle = '@chief-9a.bob.home' + const seat = { kind: 'bot', remote: true, id: handle, handle, displayName: 'Chief', ownerName: 'Bob', color: '#2a92fe', avatar: { shape: 'whale' } } + const room = { id: 'room-1', origin: 'face-to-face', participants: [seat] } + const source = { remote: { handle }, senderName: 'Chief' } + const roster = { bots: [], remoteBots: [], roomsById: { 'room-1': room } } + const spoke = remoteSpeaker(source, roster, 'room-1') + assert.equal(lookOf(spoke.bot).shape, 'whale') + assert.equal(lookOf(spoke.bot).color, '#2a92fe') + // The Members panel draws the seat through lookBotForRemote: the same look. + const members = lookOf(lookBotForRemote(seat, roster)) + assert.equal(lookOf(spoke.bot).shape, members.shape) + assert.equal(lookOf(spoke.bot).color, members.color) + // A chat record for the same handle keeps its id (its chat opens from the bubble) but wears the seat's look. + const chat = { id: 'session-c', name: 'Chief', color: 'orange', avatar: { shape: 'star' }, remote: { handle } } + const withChat = remoteSpeaker(source, { ...roster, bots: [chat] }, 'room-1') + assert.equal(withChat.bot.id, 'session-c') + assert.equal(withChat.chatId, 'session-c') + assert.equal(lookOf(withChat.bot).shape, 'whale') + // Outside a face room (or a seat with no look) nothing changes: the chat record as before. + assert.equal(remoteSpeaker(source, { ...roster, bots: [chat] }).bot, chat) + const bare = { ...room, participants: [{ ...seat, color: undefined, avatar: undefined }] } + assert.equal(remoteSpeaker(source, { bots: [chat], remoteBots: [], roomsById: { 'room-1': bare } }, 'room-1').bot, chat) +}) + +test("a person's mark is their initial on a color from their name, the same on every home (#182)", () => { + const bob = PersonMark({ name: 'Bob', size: 22 }) + assert.equal(bob.props['data-initial'], 'B') + assert.deepEqual(bob.props.children, ['B']) + assert.match(bob.props.className, /bt-person-initial/) + assert.equal(PersonMark({ name: ' bob ' }).props.style.background, bob.props.style.background) + // Two people are told apart by initial or color. + const ann = PersonMark({ name: 'Ann' }) + assert.notEqual(`${ann.props['data-initial']}${ann.props.style.background}`, `${bob.props['data-initial']}${bob.props.style.background}`) + assert.equal(PersonMark({ name: '小明' }).props['data-initial'], '小') + // No name: the plain silhouette, as before. + const plain = PersonMark({}) + assert.equal(plain.props['data-initial'], undefined) + assert.equal(plain.props.className, 'bt-person-mark') +}) diff --git a/plugins/social/client.js b/plugins/social/client.js index 75c2b69c..cc743e43 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -265,13 +265,20 @@ window.__ModuleLoader__.load({ // Local picker rows use the team roster record (configured image / shape / color). // Waiting-room "mine" matches the same local Bot; a friend's Bot uses its chat // record or a handle-seeded remote look, plus any avatar the member payload carries. - function faceLook({ roster, remoteChats, bots, local, member } = {}) { + // My own Bot is found by its room record (remote false, local id), by the handle the + // Bot list knows, or by the handle a join just returned (`mine`: { botId, handle }): + // a Bot that got its room-only identity at this join is not in the list yet, and must + // not be drawn with a look seeded from that new handle (#182). + function faceLook({ roster, remoteChats, bots, local, member, mine: joined } = {}) { if (local) { const full = rosterBot(roster, local.id) return full ? { ...local, ...full } : local } if (!member) return undefined - const mine = (bots ?? []).find(bot => bot.handle && bot.handle === member.handle) + const recorded = member.remote === false && member.id ? rosterBot(roster, member.id) : undefined + const listed = (bots ?? []).find(bot => bot.handle && bot.handle === member.handle) + const justJoined = member.handle ? (joined ?? []).find(item => item?.botId && item.handle === member.handle) : undefined + const mine = recorded || listed || (justJoined ? rosterBot(roster, justJoined.botId) || { id: justJoined.botId, name: justJoined.botName || member.displayName } : undefined) if (mine) return faceLook({ roster, bots, local: mine }) const extra = {} if (member.color) extra.color = member.color @@ -635,6 +642,7 @@ window.__ModuleLoader__.load({ setStatus(null) setFailures([]) const mine = [] + const joined = [] const failed = [] let last for (const botId of selected) { @@ -642,7 +650,10 @@ window.__ModuleLoader__.load({ try { const result = await call('social/face-join', { code: digits, botId }) last = result - if (result?.joinedAs?.handle) mine.push(result.joinedAs.handle) + if (result?.joinedAs?.handle) { + mine.push(result.joinedAs.handle) + joined.push(result.joinedAs) + } } catch (e) { failed.push({ botId, name: bot?.name || 'Bot', text: errorText(e) }) if (shouldStopJoin(e) && !last) { @@ -660,7 +671,7 @@ window.__ModuleLoader__.load({ setBusy(false) return } - setRoom({ ...last, mineHandles: mine, joinCode: last.joinCode || digits }) + setRoom({ ...last, mineHandles: mine, mineBots: joined, joinCode: last.joinCode || digits }) setStep('waiting') setBusy(false) // The main pane (and its title) moves to the new room at once, not the room before it (#191). @@ -717,7 +728,7 @@ window.__ModuleLoader__.load({ h('ul', { className: 'fs-tiles fs-wait-roster', 'data-flybot': 'face-wait-roster', 'aria-label': 'Who has joined' }, members.map(member => h(FaceTile, { key: member.handle || member.id, - bot: faceLook({ roster, remoteChats, bots, member }), + bot: faceLook({ roster, remoteChats, bots, member, mine: room.mineBots }), member, handle: member.handle, mine: memberIsMine(member, bots, room), diff --git a/plugins/social/src/look.js b/plugins/social/src/look.js index 56e24485..d56b7a16 100644 --- a/plugins/social/src/look.js +++ b/plugins/social/src/look.js @@ -59,6 +59,7 @@ export function lookFromServer(row = {}) { return out } +// The order the owner's home draws a stored look in (core mark.js BotMark): image, emoji, shape. export function lookToServer(bot = {}) { const patch = {} const color = hexOfColor(bot.color) @@ -66,14 +67,52 @@ export function lookToServer(bot = {}) { const avatar = bot.avatar && typeof bot.avatar === 'object' ? bot.avatar : {} if (typeof avatar.image === 'string' && DATA_IMAGE.test(avatar.image) && avatar.image.length <= AVATAR_MAX) { patch.avatar = avatar.image - } else if (typeof avatar.shape === 'string' && PRESET.test(avatar.shape)) { - patch.avatar = avatar.shape } else if (typeof avatar.emoji === 'string' && isEmojiAvatar(avatar.emoji)) { patch.avatar = avatar.emoji.trim() + } else if (typeof avatar.shape === 'string' && PRESET.test(avatar.shape)) { + patch.avatar = avatar.shape } return patch } +// A Bot with no stored shape or color is drawn at home with a look picked from its id +// (core/src/client/characters.js defaultShape / defaultColor). That generated look is the +// owner's look too, so it is what Bot Social gets: without it the other home falls back to a +// look seeded by the handle, and the Bot looks different on each home (#182). Same functions +// and lists as core; plugins/social/test/social.test.js checks they agree. +export const DEFAULT_SHAPES = ['star', 'moon', 'gumdrop', 'peanut', 'bunny', 'kitty', 'acorn'] +export const DEFAULT_COLORS = ['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'violet', 'magenta'] +function fnv1a(text) { + let value = 2166136261 + for (let index = 0; index < text.length; index += 1) value = Math.imul(value ^ text.charCodeAt(index), 16777619) + return value >>> 0 +} +export function defaultColorOf(id) { + const t = (fnv1a(String(id)) + 1831565813) | 0 + let n = Math.imul(t ^ (t >>> 15), 1 | t) + n = (n + Math.imul(n ^ (n >>> 7), 61 | n)) ^ n + return DEFAULT_COLORS[Math.floor((((n ^ (n >>> 14)) >>> 0) / 4294967296) * DEFAULT_COLORS.length)] +} +export function defaultShapeOf(id) { + let value = fnv1a(String(id)) | 0 + value = Math.imul(value ^ (value >>> 16), 73244475) + value = Math.imul(value ^ (value >>> 13), 3266489909) + return DEFAULT_SHAPES[((value ^ (value >>> 16)) >>> 0) % DEFAULT_SHAPES.length] +} + +// The look the owner's home draws for a local Bot: what it stores, with the id-picked shape +// and color filling what it does not. A stored image or emoji is left alone. +export function shownLook(bot = {}) { + if (bot?.id === undefined || bot?.id === null || bot.id === '') return bot + const avatar = bot.avatar && typeof bot.avatar === 'object' ? bot.avatar : {} + const own = lookToServer(bot) + return { + ...bot, + color: own.color ?? defaultColorOf(bot.id), + avatar: own.avatar !== undefined ? avatar : { ...avatar, shape: defaultShapeOf(bot.id) }, + } +} + export function lookFingerprint(bot) { const patch = lookToServer(bot) return JSON.stringify({ avatar: patch.avatar ?? null, color: patch.color ?? null }) diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 06c887c8..9bd839d1 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -32,7 +32,7 @@ import { readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, extname, isAbsolute, join, sep } from 'node:path' import { createSocialApi, untrustedText, mapPool, GRANT_CONCURRENCY, grantPersonView, memberHandleOf, personHomeOf } from './mcp-client.js' -import { lookFromServer, lookToServer, lookFingerprint } from './look.js' +import { lookFromServer, lookToServer, lookFingerprint, shownLook } from './look.js' import { createPresence } from './presence.js' import { createFaceRoomApi, @@ -605,7 +605,9 @@ export function createSocial(service, config = {}, { client: given, api: givenAp handle: member.handle, ...(forFace ? { roomOnly: true } : {}), }) - void syncBotLook(bot).catch(error => warn('look not synced', error)) + // Before the identity joins anything, so the other home's first members[] already carries + // the owner's look and never paints a handle-seeded one first (#182). + await syncBotLook(bot).catch(error => warn('look not synced', error)) return memberOf(bot.id) } // Existing identities and granted Bots at startup, once ds-bot's team is loaded. @@ -652,11 +654,13 @@ export function createSocial(service, config = {}, { client: given, api: givenAp async function syncBotLook(bot) { const member = memberOf(bot?.id) if (!member?.bot_id || typeof api.updateBotProfile !== 'function') return - const patch = lookToServer(bot) + // The look as the owner's home draws it, a generated one included (#182). + const look = shownLook(bot) + const patch = lookToServer(look) // The record names the Bot Social identity it was sent to: a room-only identity is released after // its last face room and the next room registers a new one, which has no look until it is sent // again (#182). A record from before this (look only) sends once more. - const fingerprint = `${member.bot_id}|${lookFingerprint(bot)}` + const fingerprint = `${member.bot_id}|${lookFingerprint(look)}` const synced = settings.lookSynced?.[bot.id] if (synced === fingerprint) return const empty = !('avatar' in patch) && !('color' in patch) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 65ea7357..79fcf8b5 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -10,7 +10,7 @@ import { fakeContext } from './fake-ctx.js' import { apply as applySocial, inject, name } from '../index.js' import { approvalDraftText, approvalView, createSocial, draftText, homeHandleOf, onboard, onboardHome, nextOnboardDisplayName, mapJoinInviteError, parseInviteInput, inviteLink, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js' import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions, grantPersonView, memberHandleOf, personHomeOf, normalizeGrant, isUnknownTool, isGrantNotFound, isMissingEndpoint } from '../src/mcp-client.js' -import { lookFromServer, lookToServer, lookFingerprint, hexOfColor } from '../src/look.js' +import { lookFromServer, lookToServer, lookFingerprint, hexOfColor, shownLook, defaultShapeOf, defaultColorOf, DEFAULT_SHAPES as LOOK_DEFAULT_SHAPES, DEFAULT_COLORS as LOOK_DEFAULT_COLORS } from '../src/look.js' import { encodeRoomShare } from '../../../core/src/shared/room-share.js' import { encodeFaceHuman } from '../src/face-rooms.js' import { @@ -5734,8 +5734,10 @@ test('#137: owner look is sent with update_bot_profile; old servers are skipped' await new Promise(resolve => setTimeout(resolve, 40)) const sent = server.calls.filter(c => c.name === 'update_bot_profile') assert.ok(sent.length > probed) - assert.equal(sent.at(-1).args.avatar, 'whale') - assert.equal(sent.at(-1).args.color, '#4d6bfe') + // Each Bot's own call: Writer (no stored look) gets its generated look too (#182). + const chief = sent.filter(c => c.args.bot_id === ctx.flybot.scope('social').fields.get('a', 'member')?.bot_id).at(-1) + assert.equal(chief.args.avatar, 'whale') + assert.equal(chief.args.color, '#4d6bfe') assert.ok(JSON.parse(readFileSync(settingsPath, 'utf8')).lookSynced?.a) }) @@ -7409,3 +7411,84 @@ test('#442: message.not_delivered shows a Not sent line in the owner\'s chat wit await social.pollInbox() assert.equal(delivered.length, 1) }) + +test('#182: the generated look matches core, so a Bot with no look of its own is sent as its home draws it', async () => { + const core = await import('../../../core/src/client/characters.js') + assert.deepEqual(LOOK_DEFAULT_SHAPES, core.DEFAULT_SHAPES) + assert.deepEqual(LOOK_DEFAULT_COLORS, core.DEFAULT_COLORS) + for (const id of ['a', 'b', 'chief', 'writer-2', 'bot_9f3a', '会议助手', ...Array.from({ length: 200 }, (_, n) => `s${n * 7919}`)]) { + assert.equal(defaultShapeOf(id), core.defaultShape(id), id) + assert.equal(defaultColorOf(id), core.defaultColor(id), id) + // What Bot Social gets is what core draws for that Bot. + const drawn = core.lookOf({ id }) + const sent = lookToServer(shownLook({ id, name: 'X' })) + assert.equal(sent.avatar, drawn.shape, id) + assert.equal(sent.color, hexOfColor(drawn.color), id) + } + // A stored look wins; only what is missing is filled. + assert.deepEqual(lookToServer(shownLook({ id: 'a', color: 'deepseek', avatar: { shape: 'whale' } })), { color: '#4d6bfe', avatar: 'whale' }) + assert.equal(lookToServer(shownLook({ id: 'a', color: 'green' })).avatar, defaultShapeOf('a')) + assert.equal(lookToServer(shownLook({ id: 'a', avatar: { shape: 'whale' } })).color, hexOfColor(defaultColorOf('a'))) + // An emoji or image is kept as it is (no generated shape over it), and an emoji beats a stray shape, + // in the order the home draws them. + assert.equal(lookToServer(shownLook({ id: 'a', avatar: { emoji: '🦊' } })).avatar, '🦊') + assert.equal(lookToServer({ avatar: { emoji: '🦊', shape: 'star' } }).avatar, '🦊') + const data = 'data:image/png;base64,AAAA' + assert.equal(lookToServer(shownLook({ id: 'a', avatar: { image: data } })).avatar, data) +}) + +test('#182: a Bot with no stored look gets its generated look on Bot Social, before it joins a face room', async (t) => { + const { ctx, home, call, stop } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + server.tools.update_bot_profile = a => ({ bot_id: a.bot_id, avatar: a.avatar ?? null, avatar_kind: 'preset', color: a.color ?? null, avatar_updated_at: '2026-10-20T00:00:00.000Z' }) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false, settingsPath: join(home, 'flybot-social-settings.json') }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + // Writer (`b`) has no identity yet and no stored look: the join registers it room-only. + assert.equal(ctx.flybot.bot.get('b').avatar, undefined) + const joined = await call('flybot/social/face-join', { code: '123456', botId: 'b' }) + assert.equal(joined.ok, true, joined.error?.message) + const member = ctx.flybot.scope('social').fields.get('b', 'member') + assert.ok(member?.bot_id) + const names = server.calls.map(c => c.name) + const look = server.calls.findIndex(c => c.name === 'update_bot_profile' && c.args.bot_id === member.bot_id) + assert.ok(look >= 0, 'the look is sent to the new identity') + assert.ok(look < names.indexOf('REST POST /join'), 'and before the join, so the other home never sees a seeded look first') + assert.equal(server.calls[look].args.avatar, defaultShapeOf('b')) + assert.equal(server.calls[look].args.color, hexOfColor(defaultColorOf('b'))) +}) + +test('#182: the waiting dialog draws my own Bot with its roster look, also right after a join gave it a new identity', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const roster = { + bots: [{ id: 'chief', name: 'Chief', color: 'violet', avatar: { shape: 'acorn' } }], + byId: { chief: { id: 'chief', name: 'Chief', color: 'violet', avatar: { shape: 'acorn' } } }, + } + // The Bot list was read before the join: Chief had no handle then. + const bots = [{ id: 'chief', name: 'Chief' }] + const peer = { handle: '@chief-9a.bob.home', displayName: 'Chief', ownerName: 'Bob', color: '#2a92fe', avatar: { shape: 'whale' } } + const tiles = (room) => walk(pluginOf(['waiting', bots, ['chief'], '482913', false, null, [], room, 0]).FaceToFaceGroup({ roster, actions: {} })) + .filter(n => n.props['data-flybot'] === 'bot-avatar') + // The join's answer: Bot Social members[] only (no local id), my handle only in joinedAs. + const fresh = tiles({ + roomId: 'g1', faceRoomId: 'frm_1', status: 'candidate', joinCode: '482913', expiresAt: '2099-01-01T00:00:00.000Z', + members: [{ handle: '@chief-x1.danyang.home', displayName: 'Chief', ownerName: 'Danyang', botId: 'bot_x1' }, peer], + mineHandles: ['@chief-x1.danyang.home'], mineBots: [{ botId: 'chief', botName: 'Chief', handle: '@chief-x1.danyang.home' }], + }) + assert.equal(fresh[0].props['data-shape'], 'acorn') + assert.equal(fresh[0].props['data-color'], 'violet') + // The friend's Bot keeps the look its owner gave it. + assert.equal(fresh[1].props['data-shape'], 'whale') + assert.equal(fresh[1].props['data-color'], '#2a92fe') + // A later poll: the hosted room's record (remote false, local id) finds the same Bot. + const polled = tiles({ + roomId: 'g1', faceRoomId: 'frm_1', status: 'candidate', joinCode: '482913', expiresAt: '2099-01-01T00:00:00.000Z', + members: [{ id: 'chief', handle: '@chief-x1.danyang.home', displayName: 'Chief', remote: false }, { ...peer, id: peer.handle, remote: true }], + }) + assert.equal(polled[0].props['data-shape'], 'acorn') + assert.equal(polled[0].props['data-bot-id'], 'chief') + assert.equal(polled[1].props['data-shape'], 'whale') +})