Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/face-room-auto-send-avatars.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Face-to-face Auto-send and friend avatars.** Auto-send of room messages is read and saved through Bot Social MCP (`whoami` / `set_auto_approve` with the owner's click as consent), so a connector token no longer hits REST `/v1/me` (403). Face-room join/list/messages stay on the REST routes that allow that token. A friend's Bot in the room member list, message headers, waiting grid, and Friends' Bots uses its chat look or a stable handle-seeded avatar — never the shared letter badge. Bot Social does not store profile images today; `avatar` on a member payload is used when present.
27 changes: 25 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1500,6 +1500,17 @@ function defaultShape(id) {
}
var remoteSeed = (handle) => `remote:${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`;
var lookSeed = (bot) => typeof bot?.remote?.handle === "string" && bot.remote.handle.trim() !== "" ? remoteSeed(bot.remote.handle) : bot?.id !== void 0 ? String(bot.id) : void 0;
function lookBotForRemote(person, roster) {
const handle = String(person?.handle || person?.remote?.handle || "").trim();
const chat = handle ? (roster?.bots ?? []).find((bot) => bot.remote?.handle === handle) : void 0;
if (chat) return chat;
const extra = {};
if (typeof person?.color === "string") extra.color = person.color;
if (person?.avatar && typeof person.avatar === "object") extra.avatar = person.avatar;
const name = person?.label || person?.displayName || person?.name || handle || "Bot";
if (handle) return { id: remoteSeed(handle), name, remote: { handle }, ...extra };
return { id: person?.id || "remote", name, ...extra };
}
var colorOf = (bot) => isColor(bot?.color) ? bot.color : lookSeed(bot) !== void 0 ? defaultColor(lookSeed(bot)) : "black";
function lookOf(bot) {
const avatar = bot?.avatar ?? {};
Expand Down Expand Up @@ -4570,7 +4581,12 @@ function remoteSpeaker(source, roster) {
handle,
isChief: member?.isChief ?? chat?.remote?.isChief
});
const bot = chat ?? { id: remoteSeed(handle), name, color: member?.color, shape: member?.shape };
const bot = chat ?? lookBotForRemote({
handle,
displayName: name,
color: member?.color,
avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : void 0)
}, roster);
return { bot, member, name, handle, chatId: chat ? currentPart(chat) : void 0 };
}
function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) {
Expand Down Expand Up @@ -9816,6 +9832,7 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
(0, import_react24.createElement)(
"span",
{ className: "bt-gs-who" },
(0, import_react24.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
(0, import_react24.createElement)("span", { className: "bt-row-title" }, person.label || remoteBotTitle(person)),
room2.origin === "face-to-face" ? null : (0, import_react24.createElement)("span", { className: "bt-tag" }, t("Friend's Bot"))
),
Expand Down Expand Up @@ -9847,6 +9864,7 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
setAdding(false);
void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] });
} },
(0, import_react24.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }),
(0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))),
(0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot"))
)),
Expand Down Expand Up @@ -12301,7 +12319,12 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti
(0, import_react32.createElement)(
"div",
{ className: "bt-member" },
(0, import_react32.createElement)("span", { className: "bt-member-name" }, (0, import_react32.createElement)("span", null, person.label || remoteBotTitle(person))),
(0, import_react32.createElement)(
"span",
{ className: "bt-member-name" },
(0, import_react32.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
(0, import_react32.createElement)("span", null, person.label || remoteBotTitle(person))
),
face ? null : (0, import_react32.createElement)("span", { className: "bt-chip" }, t("Friend's Bot"))
)
)),
Expand Down
7 changes: 5 additions & 2 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts } from './text.js'
import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js'
import { colorOf, remoteSeed, toolState, BotMark, RoomAvatar } from './mark.js'
import { colorOf, lookBotForRemote, toolState, BotMark, RoomAvatar } from './mark.js'
import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js'
import { SecretToolCell } from './secret-card.js'
import { dateLocale, t } from './i18n.js'
Expand Down Expand Up @@ -201,7 +201,10 @@ 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 ?? { id: remoteSeed(handle), name, color: member?.color, shape: member?.shape }
const bot = chat ?? lookBotForRemote({
handle, displayName: name, color: member?.color,
avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : undefined),
}, roster)
return { bot, member, name, handle, chatId: chat ? currentPart(chat) : undefined }
}

Expand Down
14 changes: 14 additions & 0 deletions core/src/client/characters.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions core/src/client/details-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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'
import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js'
import { botState, BotMark, BotAvatar, RoomAvatar, lookBotForRemote } from './mark.js'
import { GlobeIcon, Favicon } from './browser-pane.js'
import { useEscape } from './overlay-hooks.js'
import { BotSettingsForm } from './bot-settings.js'
Expand Down Expand Up @@ -208,7 +208,9 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings })
person.role === 'admin' ? h('span', { className: 'bt-chip bt-chip-admin' }, t('Admin')) : null))),
...remotes.map(person => h('div', { key: person.handle || person.id, role: 'listitem' },
h('div', { className: 'bt-member' },
h('span', { className: 'bt-member-name' }, h('span', null, person.label || remoteBotTitle(person))),
h('span', { className: 'bt-member-name' },
h(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
h('span', null, person.label || remoteBotTitle(person))),
face ? null : h('span', { className: 'bt-chip' }, t('Friend\'s Bot'))))),
...members.map((member) => {
const entry = entries.find(item => item.id === member.id)
Expand Down
4 changes: 3 additions & 1 deletion core/src/client/group-settings.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createElement as h, useEffect, useState } from 'react'
import { isMainOf } from './sources.js'
import { PlusIcon, ChevronRightIcon } from './icons.js'
import { AdminStar, BotAvatar, BotMark, RoomAvatar } from './mark.js'
import { AdminStar, BotAvatar, BotMark, RoomAvatar, lookBotForRemote } from './mark.js'
import { Card, Row, InlineText, SwitchRow } from './bot-settings.js'
import { Segmented } from './motion.js'
import { FolderMenu } from './folder-menu.js'
Expand Down Expand Up @@ -102,6 +102,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
}),
remotes.map((person) => h('div', { key: person.handle || person.id, className: 'bt-set-row bt-bs-row bt-gs-member' },
h('span', { className: 'bt-gs-who' },
h(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
h('span', { className: 'bt-row-title' }, person.label || remoteBotTitle(person)),
room.origin === 'face-to-face' ? null : h('span', { className: 'bt-tag' }, t('Friend\'s Bot'))),
room.origin === 'face-to-face' || members.length + remotes.length <= 2
Expand All @@ -116,6 +117,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, bot.name)),
h('span', { className: 'bt-set-hint' }, t('Add')))),
...outsideRemotes.map(bot => h('button', { key: bot.handle, type: 'button', className: 'bt-set-row bt-bs-row bt-gs-pick', onClick: () => { setAdding(false); void update({ addParticipants: [{ kind: 'bot', remote: true, handle: bot.handle }] }) } },
h(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }),
h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))),
h('span', { className: 'bt-set-hint' }, t('Add friend\'s Bot')))),
h('button', { key: 'cancel', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => setAdding(false) }, t('Cancel'))]
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createElement as h, useEffect, useRef, useState } from 'react'
import { fnv1a, lookOf, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js'
import { t } from './i18n.js'

export { colorOf, lookOf, remoteSeed } from './characters.js'
export { colorOf, lookOf, remoteSeed, lookBotForRemote } from './characters.js'

// -------------------------------------------------------------------------
// Bot mark: the Bot's character (or its uploaded photo), looked up by lookOf.
Expand Down
11 changes: 11 additions & 0 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,13 @@ export function install(rt) {
return bot?.fields?.social?.member?.handle || p?.handle
}

function faceLookFields(member) {
const out = {}
if (typeof member?.color === 'string' && member.color.trim()) out.color = member.color.trim().slice(0, 32)
if (member?.avatar && typeof member.avatar === 'object') out.avatar = member.avatar
return out
}

function faceRemoteRecord(member, joinedSeq, at) {
const handle = String(member.handle || member.id || '')
const displayName = cleanPeerName(member.displayName || member.name, handle)
Expand All @@ -881,6 +888,7 @@ export function install(rt) {
role: 'member',
joinedSeq,
joinedAt: at,
...faceLookFields(member),
}
}

Expand Down Expand Up @@ -920,6 +928,9 @@ export function install(rt) {
existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName)
existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName)
existing.roomOnly = member.roomOnly === true || member.published !== true
const look = faceLookFields(member)
if (look.color) existing.color = look.color
if (look.avatar) existing.avatar = look.avatar
} else if (member.ownerName) {
existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName)
}
Expand Down
27 changes: 24 additions & 3 deletions core/test/inbound-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from './src/client/cells.js'\nexport { lookOf } from './src/client/characters.js'",
contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'",
resolveDir: fileURLToPath(new URL('..', import.meta.url)),
},
bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent',
Expand All @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, {
throw new Error(`Unexpected platform module ${name}`)
},
})
const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf } = module.exports
const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf, lookBotForRemote, remoteSeed } = 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.')
Expand Down Expand Up @@ -59,7 +59,8 @@ test("an inbound friend's message uses the friend's sidebar Bot (avatar, name),
assert.equal(got.chatId, 'session-x')
const other = remoteSpeaker({ remote: { handle: '@x.home' }, senderName: 'X' }, { bots: [], remoteBots: [] })
assert.equal(other.name, "X · x's Bot")
assert.notEqual(other.bot.color, 'gray')
assert.equal(other.bot.remote.handle, '@x.home')
assert.notEqual(lookOf(other.bot).color, 'gray')
assert.equal(other.chatId, undefined)
})

Expand Down Expand Up @@ -94,6 +95,26 @@ test("a friend Bot's generated look is seeded by its handle: with or without its
assert.equal(kept.color, fallback.color)
})

test('lookBotForRemote seeds a distinct look from the handle when there is no chat record', () => {
const person = { handle: '@bot.friend.home', displayName: '助手', label: "助手 · Friend's Bot" }
const synthetic = lookBotForRemote(person, { bots: [] })
assert.equal(synthetic.remote.handle, '@bot.friend.home')
assert.equal(synthetic.id, remoteSeed('@bot.friend.home'))
const look = lookOf(synthetic)
assert.notEqual(look.shape, undefined)
assert.notEqual(look.color, 'gray')
assert.notEqual(look.color, 'black')
const other = lookOf(lookBotForRemote({ handle: '@other.friend.home', displayName: 'Other' }, { bots: [] }))
assert.notEqual(look.shape + look.color, other.shape + other.color)
const pictured = lookBotForRemote({
handle: '@bot.friend.home', displayName: '助手',
avatar: { image: 'https://example.test/friend.png' },
}, { bots: [] })
assert.equal(lookOf(pictured).image, 'https://example.test/friend.png')
const chat = { id: 'session-f', name: '助手', remote: { handle: '@bot.friend.home' }, color: 'green' }
assert.equal(lookBotForRemote(person, { bots: [chat] }), chat)
})

test('handles equal modulo @ and case share one look; a stored look still wins', () => {
const at = lookOf({ id: 'a', remote: { handle: '@Bot.X.home' } })
const bare = lookOf({ id: 'b', remote: { handle: 'bot.x.home' } })
Expand Down
4 changes: 3 additions & 1 deletion core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async
expiresAt: '2026-10-15T10:10:00.000Z',
members: [
{ handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false },
{ handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false },
{ handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false, color: 'green', avatar: { image: 'https://example.test/coder.png' } },
],
localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }],
})
Expand All @@ -270,6 +270,8 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async
const remote = live.find(p => p.handle === '@coder.bob.home')
assert.equal(remote.remote, true)
assert.equal(remote.roomOnly, true)
assert.equal(remote.color, 'green')
assert.equal(remote.avatar.image, 'https://example.test/coder.png')
const shown = presentRoom(room, { memberId: 'owner-1' }, rt.state)
assert.equal(shown.viewerRole, 'member')
assert.doesNotMatch(shown.displayName || '', /Shared room/)
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t
### Remote Bot display

- A friend's remote Bot is stored and delivered by its handle (`@<bot>.<home>` — what the @ menu inserts and `sendUserMentions` matches). Where the user reads it — mention chips (`src/client/user-message.js`) and sidebar previews (`nameMentions` in `src/client/text.js`) — the client rewrites the handle to the display name taken from the local roster (`roster.bots[].remote`, `roster.remoteBots`), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9).
- A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id: the chat record (`src/host/remote-chat.js`) carries no look, and every consumer funnels through `lookOf`/`colorOf`, so the record and `remoteSpeaker`'s no-record fallback (`src/client/cells.js`) render one stable look per handle — across surfaces, after a delete/re-add, and identically in the friend's home. A stored `color`/`avatar` on the record still wins. Carrying the friend side's real look over the wire needs Bot Social member-profile fields (#84).
- A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id: the chat record (`src/host/remote-chat.js`) carries no look, and every consumer funnels through `lookOf`/`colorOf` / `lookBotForRemote`, so the record, `remoteSpeaker`'s no-record fallback (`src/client/cells.js`), and the group overview / settings member rows (`details-drawer.js`, `group-settings.js`) render one stable look per handle — across surfaces, after a delete/re-add, and identically in the friend's home. A stored `color`/`avatar` on the record still wins. Bot Social `publicBot` / `faceMemberPublic` / `get_bot_profile` do not store an image today; when a member payload carries `avatar`/`color` the client passes them through. Persistently carrying the friend side's uploaded photo still needs Bot Social member-profile fields (#84).
- A friend's Bot the owner hid, a remote chat marked `gone`/`hidden`, and a home-level handle (`@friend.home`, not `@bot.friend.home`) must not reappear in the sidebar, the **发给 / 和谁聊…** picker, `@` mentions, or `list_bots` friends lines (`isListedRemoteChat`). `flybot.remote.publish` skips those handles so remote-chat sync marks the persisted session gone/hidden instead of opening it again; history is kept. Handle comparison is case-insensitive (`normRemoteHandle`).

### FlyBot product chrome
Expand Down
Loading
Loading