From 2868d5761613f1304bfb4826e082f37fb357607f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 02:24:22 +0000 Subject: [PATCH] Draw a friend's mention from the same look as its sidebar row The @ menu and the mention chip kept a friend's color and avatar only on remote. lookOf does not read that, so @chief.selfa1025.home drew a handle-seeded green moon beside the stored blue whale (#1088). The menu icon and the chip now resolve the chat, or the face-room seat's look, through markBotForMention, the same record the sidebar draws. Co-authored-by: Danyang Chen --- changelog.d/1088-friend-mention-look.fixed.md | 1 + core/client.js | 34 ++++- core/src/client/characters.js | 25 ++++ core/src/client/composer.js | 4 +- core/src/client/mark.js | 2 +- core/src/client/mention-style.js | 6 +- core/src/client/user-message.js | 18 ++- core/test/mention-look.test.js | 139 ++++++++++++++++++ docs/subsystems/core-client.md | 4 +- patches/ds-bot/0003-flybot-api-client.patch | 20 ++- patches/ds-bot/0005-social-seams.patch | 29 +++- patches/ds-bot/0008-client-bundle.patch | 38 ++++- patches/ds-bot/0012-room-membership.patch | 20 +++ .../ds-bot/files/src/client/mention-style.js | 6 +- .../ds-bot/files/test/mention-look.test.js | 139 ++++++++++++++++++ 15 files changed, 449 insertions(+), 36 deletions(-) create mode 100644 changelog.d/1088-friend-mention-look.fixed.md create mode 100644 core/test/mention-look.test.js create mode 100644 patches/ds-bot/files/test/mention-look.test.js diff --git a/changelog.d/1088-friend-mention-look.fixed.md b/changelog.d/1088-friend-mention-look.fixed.md new file mode 100644 index 000000000..6598e088f --- /dev/null +++ b/changelog.d/1088-friend-mention-look.fixed.md @@ -0,0 +1 @@ +A friend's Bot in the @ menu and in a mention chip uses the same picture as its sidebar row. A face-room Chief no longer shows a handle-seeded green moon there while the sidebar shows the stored blue whale ([#1088](https://github.com/ThinkFlowLab/FlyBot/issues/1088)). diff --git a/core/client.js b/core/client.js index 6bc534c08..1a0315ea5 100644 --- a/core/client.js +++ b/core/client.js @@ -1915,6 +1915,23 @@ function lookBotForRemote(person, roster) { if (handle) return { id: remoteSeed(handle), name, remote: { handle }, ...extra }; return { id: person?.id || "remote", name, ...extra }; } +function markBotForMention(bot, roster) { + if (!bot) return bot; + if (!bot.remote) { + const stored = bot.id != null && bot.id !== "" ? roster?.byId?.[bot.id] : void 0; + return stored && !stored.remote ? stored : bot; + } + const nested = bot.remote !== true && typeof bot.remote === "object" ? bot.remote : null; + const handle = String(bot.handle || nested?.handle || nested?.remote?.handle || "").trim(); + const color = typeof bot.color === "string" && bot.color ? bot.color : typeof nested?.color === "string" && nested.color ? nested.color : void 0; + const avatar = bot.avatar && typeof bot.avatar === "object" ? bot.avatar : nested?.avatar && typeof nested.avatar === "object" ? nested.avatar : void 0; + return lookBotForRemote({ + handle, + displayName: nested?.displayName || nested?.name, + ...color ? { color } : {}, + ...avatar ? { avatar } : {} + }, roster); +} var colorOf = (bot) => isColor(bot?.color) ? bot.color : lookSeed(bot) !== void 0 ? defaultColor(lookSeed(bot)) : "black"; function lookOf(bot) { const avatar = bot?.avatar ?? {}; @@ -3192,7 +3209,7 @@ function mentionCss(bots) { const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]`; const key = chipKey(bot); const chip = key !== null && owner.get(key) === bot ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, "\\$&")}" i]` : ""; - const look = lookOf(bot); + const look = bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot); const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`; const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ""; @@ -9334,7 +9351,7 @@ function menuIcons(roster) { return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; - if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react16.createElement)(BotMark, { bot: roster.getSnapshot().byId[id] ?? row.bot, size })); + if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react16.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })); return marks.get(id); }; } @@ -17559,6 +17576,11 @@ function contentParts(content) { return { text: texts2.join("\n"), attachments, rest }; } var normMentionHandle = (handle) => String(handle ?? "").replace(/^@+/, "").toLowerCase(); +function withMentionLook(bot, roster) { + if (!bot?.remote) return bot; + const draw = markBotForMention(bot, roster); + return draw && draw !== bot ? { ...bot, draw } : bot; +} function remoteMentionableBots(roster, chats = []) { const known = /* @__PURE__ */ new Map(); const add = (record) => { @@ -17571,7 +17593,7 @@ function remoteMentionableBots(roster, chats = []) { return all.flatMap((record) => { const handle = record.remote?.handle ?? record.handle; const chat = (Array.isArray(chats) ? chats : []).find((bot) => normMentionHandle(bot?.remote?.handle) === normMentionHandle(handle)); - const base = { id: chat?.id, mentionLabel: remoteMentionLabel(record, all), kind: "bot", remote: record, handle }; + const base = withMentionLook({ id: chat?.id, mentionLabel: remoteMentionLabel(record, all), kind: "bot", remote: record, handle }, roster); return [{ ...base, name: String(handle).replace(/^@+/, "") }, { ...base, name: base.mentionLabel }]; }); } @@ -17580,7 +17602,7 @@ function mentionableBots(roster, sessionId, { fromFriend = false } = {}) { if (room2?.origin === "face-to-face") return faceMentionables(room2, roster, fromFriend); const bots = room2 ? room2.members.map((id) => roster.byId[id]).filter(Boolean) : roster.byId[sessionId] ? roster.bots : []; const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })); - const remotes = (room2?.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, mentionLabel: remoteBotTitle(p) })); + const remotes = (room2?.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => withMentionLook({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, mentionLabel: remoteBotTitle(p) }, roster)); return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots)); } function faceMentionables(room2, roster, fromFriend) { @@ -17589,7 +17611,7 @@ function faceMentionables(room2, roster, fromFriend) { const seats = all.filter((seat) => !seat.left); const ordered = [...seats.filter((seat) => seat.mine !== fromFriend), ...seats.filter((seat) => seat.mine === fromFriend), ...all.filter((seat) => seat.left)]; const bots = ordered.flatMap((seat) => { - const target = seat.local ?? { id: seat.key, name: seat.bot, kind: "bot", remote: seat.record }; + const target = seat.local ?? withMentionLook({ id: seat.key, name: seat.bot, kind: "bot", remote: seat.record }, roster); const record = { ...seat.record, ownerName: seat.record.ownerName || seat.person }; const mentionLabel = faceSeatLabel(seat); return faceAliases(record, seat.bot).map((name) => ({ ...target, name, mentionLabel })); @@ -17625,7 +17647,7 @@ function userTextParts(text, bots, referenceLabels = []) { return parts; } function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { - const chip = (part) => [(0, import_react42.createElement)(BotMark, { bot: part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; + const chip = (part) => [(0, import_react42.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; return userTextParts(text, bots, referenceLabels).map((part, index) => { if (!part.bot) return (0, import_react42.createElement)(import_react42.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); const title = `@${part.bot.name}`; diff --git a/core/src/client/characters.js b/core/src/client/characters.js index ad7bdafdb..14c423ca9 100644 --- a/core/src/client/characters.js +++ b/core/src/client/characters.js @@ -86,6 +86,31 @@ export function lookBotForRemote(person, roster) { return { id: person?.id || 'remote', name, ...extra } } +// The bot a mention draws. A local Bot is its roster record. A friend's Bot is the same +// record the sidebar draws — its chat, or a face-room seat's color and avatar when the +// seat carries them. The @ menu and the mention chip used to draw a stand-in that kept +// that look only on `remote`, and lookOf does not read it, so the handle's seed (a green +// moon for @chief.selfa1025.home) showed beside the stored blue whale (#1088). +export function markBotForMention(bot, roster) { + if (!bot) return bot + if (!bot.remote) { + const stored = bot.id != null && bot.id !== '' ? roster?.byId?.[bot.id] : undefined + return stored && !stored.remote ? stored : bot + } + const nested = bot.remote !== true && typeof bot.remote === 'object' ? bot.remote : null + const handle = String(bot.handle || nested?.handle || nested?.remote?.handle || '').trim() + const color = typeof bot.color === 'string' && bot.color ? bot.color + : typeof nested?.color === 'string' && nested.color ? nested.color : undefined + const avatar = bot.avatar && typeof bot.avatar === 'object' ? bot.avatar + : nested?.avatar && typeof nested.avatar === 'object' ? nested.avatar : undefined + return lookBotForRemote({ + handle, + displayName: nested?.displayName || nested?.name, + ...(color ? { color } : {}), + ...(avatar ? { avatar } : {}), + }, roster) +} + // A stored shape or color wins; then the seed (a remote Bot's handle, else its id). // With no Bot it is a black gumdrop. export const colorOf = bot => (isColor(bot?.color) ? bot.color : lookSeed(bot) !== undefined ? defaultColor(lookSeed(bot)) : 'black') diff --git a/core/src/client/composer.js b/core/src/client/composer.js index 5305371e6..a78739699 100644 --- a/core/src/client/composer.js +++ b/core/src/client/composer.js @@ -1,7 +1,7 @@ import { createElement as h, Fragment, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { ChatsIcon, ClockIcon, MicIcon, PaperclipIcon, PeopleIcon, PlusIcon, WaveIcon } from './icons.js' -import { BotMark } from './mark.js' +import { BotMark, markBotForMention } from './mark.js' import { useLinger } from './motion.js' import { t } from './i18n.js' @@ -110,7 +110,7 @@ export function menuIcons(roster) { return (row) => { if (!row.bot) return PeopleIcon const id = row.bot.id - if (!marks.has(id)) marks.set(id, ({ size = 14 }) => h(BotMark, { bot: roster.getSnapshot().byId[id] ?? row.bot, size })) + if (!marks.has(id)) marks.set(id, ({ size = 14 }) => h(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })) return marks.get(id) } } diff --git a/core/src/client/mark.js b/core/src/client/mark.js index 7eaa0d024..4e22444d2 100644 --- a/core/src/client/mark.js +++ b/core/src/client/mark.js @@ -5,7 +5,7 @@ import { t } from './i18n.js' import { clusterOrder } from './cluster-order.js' import { rosterBot } from './sources.js' -export { colorOf, lookOf, lookKey, lookKeys, lookAvoidingLocals, remoteSeed, lookBotForRemote } from './characters.js' +export { colorOf, lookOf, lookKey, lookKeys, lookAvoidingLocals, remoteSeed, lookBotForRemote, markBotForMention } from './characters.js' // ------------------------------------------------------------------------- // Bot mark: the Bot's character (or its uploaded photo), looked up by lookOf. diff --git a/core/src/client/mention-style.js b/core/src/client/mention-style.js index ed4cf082e..dd2e33655 100644 --- a/core/src/client/mention-style.js +++ b/core/src/client/mention-style.js @@ -1,4 +1,4 @@ -import { lookOf, maskOf, DEFAULT_MASK } from './characters.js' +import { lookAvoidingLocals, lookOf, maskOf, DEFAULT_MASK } from './characters.js' import { inkText, paintCss } from './inks.js' import { MENTION_ORIGIN } from './text.js' @@ -26,7 +26,9 @@ export function mentionCss(bots) { const chip = key !== null && owner.get(key) === bot ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, '\\$&')}" i]` : '' - const look = lookOf(bot) + // The same decision BotMark makes, so a friend's anchor does not keep a raw look the + // sidebar has already stepped off (#1088). A local Bot still owns a shared @Name chip. + const look = bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot) const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before` const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` diff --git a/core/src/client/user-message.js b/core/src/client/user-message.js index fb5c921bb..cd7d7077e 100644 --- a/core/src/client/user-message.js +++ b/core/src/client/user-message.js @@ -1,7 +1,7 @@ import { createElement as h, Fragment, useState } from 'react' import * as ui from '@deepseek-ai/dsh-client-ui-primitives' import { mentionSpans } from '../shared/mentions.js' -import { BotMark, PersonMark } from './mark.js' +import { BotMark, PersonMark, markBotForMention } from './mark.js' import { CheckIcon, CopyIcon } from './icons.js' import { dateLocale, t } from './i18n.js' import { cleanPeerName, faceAliases, remoteBotTitle, remoteMentionLabel } from '../shared/remote-label.js' @@ -36,6 +36,14 @@ export function contentParts(content) { // carry), so both forms chip as the same mention. The click opens its chat when one exists. const normMentionHandle = handle => String(handle ?? '').replace(/^@+/, '').toLowerCase() +// The chip draws `draw` when a friend's stored look is not on this matching record. +// The name, id and mention label stay the ones the room routes on (#1088). +function withMentionLook(bot, roster) { + if (!bot?.remote) return bot + const draw = markBotForMention(bot, roster) + return draw && draw !== bot ? { ...bot, draw } : bot +} + function remoteMentionableBots(roster, chats = []) { const known = new Map() const add = (record) => { @@ -48,7 +56,7 @@ function remoteMentionableBots(roster, chats = []) { return all.flatMap((record) => { const handle = record.remote?.handle ?? record.handle const chat = (Array.isArray(chats) ? chats : []).find(bot => normMentionHandle(bot?.remote?.handle) === normMentionHandle(handle)) - const base = { id: chat?.id, mentionLabel: remoteMentionLabel(record, all), kind: 'bot', remote: record, handle } + const base = withMentionLook({ id: chat?.id, mentionLabel: remoteMentionLabel(record, all), kind: 'bot', remote: record, handle }, roster) return [{ ...base, name: String(handle).replace(/^@+/, '') }, { ...base, name: base.mentionLabel }] }) } @@ -61,7 +69,7 @@ export function mentionableBots(roster, sessionId, { fromFriend = false } = {}) const humans = (room?.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined) .map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role })) const remotes = (room?.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) - .map(p => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, mentionLabel: remoteBotTitle(p) })) + .map(p => withMentionLook({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, mentionLabel: remoteBotTitle(p) }, roster)) return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots)) } @@ -79,7 +87,7 @@ function faceMentionables(room, roster, fromFriend) { const seats = all.filter(seat => !seat.left) const ordered = [...seats.filter(seat => seat.mine !== fromFriend), ...seats.filter(seat => seat.mine === fromFriend), ...all.filter(seat => seat.left)] const bots = ordered.flatMap((seat) => { - const target = seat.local ?? { id: seat.key, name: seat.bot, kind: 'bot', remote: seat.record } + const target = seat.local ?? withMentionLook({ id: seat.key, name: seat.bot, kind: 'bot', remote: seat.record }, roster) const record = { ...seat.record, ownerName: seat.record.ownerName || seat.person } const mentionLabel = faceSeatLabel(seat) return faceAliases(record, seat.bot).map(name => ({ ...target, name, mentionLabel })) @@ -121,7 +129,7 @@ export function userTextParts(text, bots, referenceLabels = []) { } function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { - const chip = part => [h(BotMark, { bot: part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`] + const chip = part => [h(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`] return userTextParts(text, bots, referenceLabels).map((part, index) => { if (!part.bot) return h(Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, 'skill', references)) const title = `@${part.bot.name}` diff --git a/core/test/mention-look.test.js b/core/test/mention-look.test.js new file mode 100644 index 000000000..b5ea361da --- /dev/null +++ b/core/test/mention-look.test.js @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' +import { lookAvoidingLocals, lookKey, lookOf } from '../src/client/characters.js' +import { indexRoster } from '../src/client/sources.js' + +// A face-room @ row and the mention chip used to draw a stand-in whose color and +// avatar lived only on `remote`. lookOf ignores that and seeds the handle, so +// @chief.selfa1025.home is a green moon while the sidebar chat is the stored blue whale. +// BotMark then steps that whale when a local Bot of the same name draws it (#1069). + +const HANDLE = '@chief.selfa1025.home' + +async function load(entry) { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL(`../src/client/${entry}`, import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const module = { exports: {} } + runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, + require: (name) => { + const platform = { + react: { + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), + useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', + }, + 'react-dom': { createPortal: node => node }, + '@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + }) + return module.exports +} + +const expand = (node, names) => { + if (Array.isArray(node)) return node.flatMap(child => expand(child, names)) + if (node === null || typeof node !== 'object') return node + if (names.includes(node.type?.name)) return expand(node.type({ ...node.props, children: node.children }), names) + return { ...node, children: (node.children ?? []).map(child => expand(child, names)) } +} +const find = (node, match) => { + if (Array.isArray(node)) return node.flatMap(child => find(child, match)) + if (node === null || typeof node !== 'object') return [] + return [...(match(node) ? [node] : []), ...find(node.children, match)] +} + +const { mentionRows } = await load('at-menu.js') +const { menuIcons } = await load('composer.js') +const { UserMessageCell } = await load('user-message.js') + +const local = { id: 'b1', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' }, sessionId: 'b1' } +const chat = { id: 'remote-1', name: 'Chief', color: '#4d6bfe', avatar: { shape: 'whale' }, remote: { handle: HANDLE, homeName: 'SelfA1025', owner: 'SelfA1025' } } +const seat = { kind: 'bot', remote: true, id: HANDLE, handle: HANDLE, displayName: 'Chief', ownerName: 'SelfA1025', isChief: true, color: '#4d6bfe', avatar: { shape: 'whale' } } +const mine = { kind: 'bot', id: 'b1', handle: '@chief.selfb.home', displayName: 'Chief', ownerName: 'SelfB' } + +function roomRoster(bots) { + return indexRoster({ + self: { displayName: 'SelfB' }, + bots, + mainBotIds: ['b1'], + rooms: [{ id: 'room', origin: 'face-to-face', name: 'Face', members: ['b1'], participants: [mine, seat] }], + }) +} + +const storedKey = lookKey(lookOf(chat)) +const seededKey = lookKey(lookOf({ id: HANDLE, name: 'Chief (SelfA1025)', remote: seat, handle: HANDLE })) +// The picture BotMark paints: the stored record, stepped off a same-named local Chief. +const drawnKey = bot => lookKey(lookAvoidingLocals(bot)) + +function iconBot(roster, row) { + const Icon = menuIcons({ getSnapshot: () => roster })(row) + const drawn = Icon({ size: 14 }) + assert.equal(drawn.type?.name, 'BotMark') + return drawn.props.bot +} + +function chipBot(roster, text) { + const cell = UserMessageCell({ + node: { kind: 'user', data: { content: [{ type: 'text', text }] } }, + sessionId: 'room', + useRoster: select => select(roster), + actions: { openSession() {} }, + }) + const marks = find(expand(cell, ['UserText']), node => node.type?.name === 'BotMark') + assert.equal(marks.length, 1) + return marks[0].props.bot +} + +test('the reported handle seed is a green moon, not the stored blue whale', () => { + assert.equal(seededKey, 'shape:moon:green') + assert.equal(storedKey, 'shape:whale:#4d6bfe') + assert.notEqual(seededKey, storedKey) +}) + +test('a face-room @ row and mention chip draw the stored friend look, not the handle seed (#1088)', () => { + const roster = roomRoster([local, chat]) + const rows = mentionRows(roster, 'room') + const friend = rows.find(row => row.bot?.remote) + const mineRow = rows.find(row => row.bot && !row.bot.remote) + assert.ok(friend) + assert.equal(iconBot(roster, friend).id, chat.id) + assert.equal(lookKey(lookOf(iconBot(roster, friend))), storedKey) + assert.equal(drawnKey(iconBot(roster, friend)), drawnKey(chat)) + assert.notEqual(drawnKey(chat), seededKey) + assert.notEqual(drawnKey(chat), lookKey(lookOf(local))) + assert.equal(lookKey(lookOf(iconBot(roster, mineRow))), lookKey(lookOf(local))) + const chip = chipBot(roster, "@SelfA1025's Chief hi") + assert.equal(chip.id, chat.id) + assert.equal(lookKey(lookOf(chip)), storedKey) + assert.equal(drawnKey(chip), drawnKey(chat)) + // The chip still names the seat. The picture is the stored chat, the label is not. + const cell = expand(UserMessageCell({ + node: { kind: 'user', data: { content: [{ type: 'text', text: "@SelfA1025's Chief hi" }] } }, + sessionId: 'room', + useRoster: select => select(roster), + actions: { openSession() {} }, + }), ['UserText']) + const button = find(cell, node => node.props?.className === 'bt-umention')[0] + assert.match(JSON.stringify(button.children), /Chief \(SelfA1025\)/) +}) + +test('a face-room seat look is drawn when the friend has no chat yet (#1088)', () => { + const roster = roomRoster([local]) + const friend = mentionRows(roster, 'room').find(row => row.bot?.remote) + const icon = iconBot(roster, friend) + const chip = chipBot(roster, "@SelfA1025's Chief hi") + assert.equal(lookKey(lookOf(icon)), storedKey) + assert.equal(lookKey(lookOf(chip)), storedKey) + assert.equal(drawnKey(icon), drawnKey(chip)) + assert.equal(drawnKey(icon), drawnKey(chat)) + assert.notEqual(drawnKey(icon), seededKey) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 573a323fc..6af003965 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -91,7 +91,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A friend's remote Bot is stored and delivered by its handle (`@.`, what `sendUserMentions` matches), and the user reads one name for it on every surface (#967): `remoteMentionLabel` in `shared/remote-label.js` — the sidebar row's title (`friendBotTitle`, #498), and on a title two different handles share the home too, so two friends' `Chief` stay apart. The `@` menu inserts that name (`remoteMentionRows` in `flybot-registry.js`, so a row shows what it inserts), the bubble chips it (`mentionableBots` / `userTextParts` in `src/client/user-message.js`), and the sidebar preview renders it (`nameMentions` in `src/client/text.js`). `mentionableBots` reads `roster.remoteBots` as well as `roster.bots` and the room's seats, so a friend's Bot chips before it has a chat of its own and inside a group it holds no seat in; it also keeps the handle as a second name of the same Bot, so a line written before this change still chips, and a chip whose Bot has no chat yet is a mark, not a button that would open nothing. Names come from the sanitized roster records, never from message text, so a remote-controlled name reaches the UI only through them (#71; display names, #9). The handle stays the routing key: the menu's text is a label, not a transport token. - In a face-to-face room the bubble matches a mention by every alias the room host routes on (`faceAliases` in `shared/remote-label.js`, the same list `face-mentions.js` uses), so `@SelfA's Chief` typed on B is A's own Chief on A. The chip shows `@` and the seat label (`faceSeatLabel`, `SelfA · Chief`) on both homes, a bare name means the writer's own Bot (my line tries my seats first, a friend's line the friend's, whatever order the room lists its seats in, as `faceCandidates` does), a friend's Bot outside the room is not matched, and one space typed after a picked `@Name ` (the menu inserts its own) is dropped from the drawing (#305). -- A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id. When Bot Social sends `avatar` / `color` / `avatar_updated_at`, `flybot.remote.publish` stores them on the remote-chat record (cached by `avatar_updated_at`). `lookBotForRemote` overlays a person payload's look (face-room `members[]` is the source for room avatars). A stored `color`/`avatar` still wins over the seed; missing look stays handle-seeded (#137). +- A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id. When Bot Social sends `avatar` / `color` / `avatar_updated_at`, `flybot.remote.publish` stores them on the remote-chat record (cached by `avatar_updated_at`). `lookBotForRemote` overlays a person payload's look (face-room `members[]` is the source for room avatars). A stored `color`/`avatar` still wins over the seed; missing look stays handle-seeded (#137). The @ menu icon (`menuIcons`) and the user-bubble mention chip (`withMentionLook` in `user-message.js`) draw that same record through `markBotForMention`. A stand-in that keeps the look only on `remote` is not drawn: `lookOf` does not read it, and the handle seed (a green moon for `@chief.selfa1025.home`) was showing beside the stored blue whale (#1088). The seed remains the fallback when neither the chat nor the face-room seat has a look. A friend's file-chip anchor uses `lookAvoidingLocals`, the same decision as `BotMark`; a local Bot still owns a shared `@Name` chip (#963). - A shared display name does not restyle a local Bot (#963). The `@Name` file chip (`mentionCss` in `src/client/mention-style.js`) uses the local Bot's look when a friend has the same name; that friend's own mention anchor keeps the friend's color, and a name only a friend has keeps that friend's look. `indexRoster` does not let a remote chat replace a local Bot's id or session keys, and `rosterBot` resolves a member id to the local Bot. `@Name` in a bubble (`linkMentions`) names the local Bot when both are listed. A group reply that is not remote resolves through `rosterBot` before the index. The friend's own row keeps the friend's look, unless that drawn look is the same picture as the local Bot of the same display name, in which case the row steps its shape and the local Bot stays as stored (#1069). - A friend's 1:1 chat (#443): `AssistantCell` gives the reply bubbles a `bt-lead` `BotMark` from the chat record (`friendLead`; none on a `Not sent:` note). The activity row (`activity-row.js`) stays up while `awaitingReply` (`awaiting.js`, `roster.remoteAwaiting`, 10 min cap) holds. Between the send turn and the echo turn it sits in the last turn's tail, with a 1.5 s grace and `data-carry` so there is no second entrance, which keeps one row from send to reply. The social `PeerThinking` line is for face rooms only. A friend chat shows the row for a run that started from my submission and, between runs, while `awaitingReply` holds. The friend's reply run (the echo, which has no submission) hides it at once, and only the end of my send run gets the grace. The turn-tail hiding rules in `styles.js` spare `.bt-typing` and its tail item, or the row between runs would be drawn with `display:none`. - 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`). @@ -117,7 +117,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### One sidebar list -- `TeamSidebar` lists local Bots, shown remote chats (`isShownRemoteChat`, #956), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message — *no longer shared* once that Bot has left the published list, when the row stays with its history and its way back (#956) — and the full name is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. A published preset shape stays, unless it is the same drawn look as a local Bot with the same display name — two default Chiefs both store the blue whale — in which case the friend's row steps and the local Bot keeps its stored look (#1069). A published image or emoji stays. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A group row whose group holds an open question card (#550) says it waits for me: `Waiting for your answer` in its label and the waiting dot (`bt-unread-wait`), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. +- `TeamSidebar` lists local Bots, shown remote chats (`isShownRemoteChat`, #956), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message — *no longer shared* once that Bot has left the published list, when the row stays with its history and its way back (#956) — and the full name is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. A published preset shape stays, unless it is the same drawn look as a local Bot with the same display name — two default Chiefs both store the blue whale — in which case the friend's row steps and the local Bot keeps its stored look (#1069). A published image or emoji stays. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer, transcript, @ menu and mention chip agree. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A group row whose group holds an open question card (#550) says it waits for me: `Waiting for your answer` in its label and the waiting dot (`bt-unread-wait`), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. ### Agent inbox (#349, step 1) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 34757da23..e03c1798e 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2707,7 +2707,7 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js import * as ui from '@deepseek-ai/dsh-client-ui-primitives' import { mentionSpans } from '../shared/mentions.js' -import { BotMark } from './mark.js' -+import { BotMark, PersonMark } from './mark.js' ++import { BotMark, PersonMark, markBotForMention } from './mark.js' import { CheckIcon, CopyIcon } from './icons.js' import { dateLocale, t } from './i18n.js' +import { cleanPeerName, faceAliases, remoteBotTitle, remoteMentionLabel } from '../shared/remote-label.js' @@ -2715,7 +2715,7 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js // ------------------------------------------------------------------------- // The user's own messages. DSH draws every "@word" in them as a file reference, and -@@ -28,10 +28,77 @@ export function contentParts(content) { +@@ -28,10 +28,85 @@ export function contentParts(content) { } // In a group only its members are called; elsewhere a mention opens that Bot's chat. @@ -2726,6 +2726,14 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js +// carry), so both forms chip as the same mention. The click opens its chat when one exists. +const normMentionHandle = handle => String(handle ?? '').replace(/^@+/, '').toLowerCase() + ++// The chip draws `draw` when a friend's stored look is not on this matching record. ++// The name, id and mention label stay the ones the room routes on (#1088). ++function withMentionLook(bot, roster) { ++ if (!bot?.remote) return bot ++ const draw = markBotForMention(bot, roster) ++ return draw && draw !== bot ? { ...bot, draw } : bot ++} ++ +function remoteMentionableBots(roster, chats = []) { + const known = new Map() + const add = (record) => { @@ -2738,7 +2746,7 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js + return all.flatMap((record) => { + const handle = record.remote?.handle ?? record.handle + const chat = (Array.isArray(chats) ? chats : []).find(bot => normMentionHandle(bot?.remote?.handle) === normMentionHandle(handle)) -+ const base = { id: chat?.id, mentionLabel: remoteMentionLabel(record, all), kind: 'bot', remote: record, handle } ++ const base = withMentionLook({ id: chat?.id, mentionLabel: remoteMentionLabel(record, all), kind: 'bot', remote: record, handle }, roster) + return [{ ...base, name: String(handle).replace(/^@+/, '') }, { ...base, name: base.mentionLabel }] + }) +} @@ -2753,7 +2761,7 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js + const humans = (room?.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined) + .map(p => ({ id: p.id, name: p.displayName || p.id, kind: 'human', role: p.role })) + const remotes = (room?.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) -+ .map(p => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, mentionLabel: remoteBotTitle(p) })) ++ .map(p => withMentionLook({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, mentionLabel: remoteBotTitle(p) }, roster)) + return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots)) +} + @@ -2771,7 +2779,7 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js + const seats = all.filter(seat => !seat.left) + const ordered = [...seats.filter(seat => seat.mine !== fromFriend), ...seats.filter(seat => seat.mine === fromFriend), ...all.filter(seat => seat.left)] + const bots = ordered.flatMap((seat) => { -+ const target = seat.local ?? { id: seat.key, name: seat.bot, kind: 'bot', remote: seat.record } ++ const target = seat.local ?? withMentionLook({ id: seat.key, name: seat.bot, kind: 'bot', remote: seat.record }, roster) + const record = { ...seat.record, ownerName: seat.record.ownerName || seat.person } + const mentionLabel = faceSeatLabel(seat) + return faceAliases(record, seat.bot).map(name => ({ ...target, name, mentionLabel })) @@ -2813,7 +2821,7 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js - ? h('button', { key: index, type: 'button', className: 'bt-umention', title: `@${part.bot.name}`, onClick: () => actions.openSession(part.bot.id) }, - h(BotMark, { bot: part.bot, size: 16 }), part.label) - : h(Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, 'skill', references)))) -+ const chip = part => [h(BotMark, { bot: part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`] ++ const chip = part => [h(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`] + return userTextParts(text, bots, referenceLabels).map((part, index) => { + if (!part.bot) return h(Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, 'skill', references)) + const title = `@${part.bot.name}` diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 035bf7657..89d7ba8d7 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -3,7 +3,7 @@ FlyBot patch 0005-social-seams on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Genera diff --git a/src/client/characters.js b/src/client/characters.js --- a/src/client/characters.js +++ b/src/client/characters.js -@@ -63,17 +63,105 @@ export function defaultShape(id) { +@@ -63,17 +63,130 @@ export function defaultShape(id) { return DEFAULT_SHAPES[((value ^ (value >>> 16)) >>> 0) % DEFAULT_SHAPES.length] } @@ -32,6 +32,31 @@ diff --git a/src/client/characters.js b/src/client/characters.js + return { id: person?.id || 'remote', name, ...extra } +} + ++// The bot a mention draws. A local Bot is its roster record. A friend's Bot is the same ++// record the sidebar draws — its chat, or a face-room seat's color and avatar when the ++// seat carries them. The @ menu and the mention chip used to draw a stand-in that kept ++// that look only on `remote`, and lookOf does not read it, so the handle's seed (a green ++// moon for @chief.selfa1025.home) showed beside the stored blue whale (#1088). ++export function markBotForMention(bot, roster) { ++ if (!bot) return bot ++ if (!bot.remote) { ++ const stored = bot.id != null && bot.id !== '' ? roster?.byId?.[bot.id] : undefined ++ return stored && !stored.remote ? stored : bot ++ } ++ const nested = bot.remote !== true && typeof bot.remote === 'object' ? bot.remote : null ++ const handle = String(bot.handle || nested?.handle || nested?.remote?.handle || '').trim() ++ const color = typeof bot.color === 'string' && bot.color ? bot.color ++ : typeof nested?.color === 'string' && nested.color ? nested.color : undefined ++ const avatar = bot.avatar && typeof bot.avatar === 'object' ? bot.avatar ++ : nested?.avatar && typeof nested.avatar === 'object' ? nested.avatar : undefined ++ return lookBotForRemote({ ++ handle, ++ displayName: nested?.displayName || nested?.name, ++ ...(color ? { color } : {}), ++ ...(avatar ? { avatar } : {}), ++ }, roster) ++} ++ +// A stored shape or color wins; then the seed (a remote Bot's handle, else its id). +// With no Bot it is a black gumdrop. +export const colorOf = bot => (isColor(bot?.color) ? bot.color : lookSeed(bot) !== undefined ? defaultColor(lookSeed(bot)) : 'black') @@ -125,7 +150,7 @@ diff --git a/src/client/mark.js b/src/client/mark.js +import { rosterBot } from './sources.js' -export { colorOf, lookOf } from './characters.js' -+export { colorOf, lookOf, lookKey, lookKeys, lookAvoidingLocals, remoteSeed, lookBotForRemote } from './characters.js' ++export { colorOf, lookOf, lookKey, lookKeys, lookAvoidingLocals, remoteSeed, lookBotForRemote, markBotForMention } from './characters.js' // ------------------------------------------------------------------------- // Bot mark: the Bot's character (or its uploaded photo), looked up by lookOf. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 720cd49c4..0583a7055 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1105,7 +1105,7 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1177,15 +1177,75 @@ function defaultShape(id) { +@@ -1177,15 +1177,92 @@ function defaultShape(id) { value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -1123,6 +1123,23 @@ diff --git a/client.js b/client.js + if (handle) return { id: remoteSeed(handle), name, remote: { handle }, ...extra }; + return { id: person?.id || "remote", name, ...extra }; +} ++function markBotForMention(bot, roster) { ++ if (!bot) return bot; ++ if (!bot.remote) { ++ const stored = bot.id != null && bot.id !== "" ? roster?.byId?.[bot.id] : void 0; ++ return stored && !stored.remote ? stored : bot; ++ } ++ const nested = bot.remote !== true && typeof bot.remote === "object" ? bot.remote : null; ++ const handle = String(bot.handle || nested?.handle || nested?.remote?.handle || "").trim(); ++ const color = typeof bot.color === "string" && bot.color ? bot.color : typeof nested?.color === "string" && nested.color ? nested.color : void 0; ++ const avatar = bot.avatar && typeof bot.avatar === "object" ? bot.avatar : nested?.avatar && typeof nested.avatar === "object" ? nested.avatar : void 0; ++ return lookBotForRemote({ ++ handle, ++ displayName: nested?.displayName || nested?.name, ++ ...color ? { color } : {}, ++ ...avatar ? { avatar } : {} ++ }, roster); ++} +var colorOf = (bot) => isColor(bot?.color) ? bot.color : lookSeed(bot) !== void 0 ? defaultColor(lookSeed(bot)) : "black"; function lookOf(bot) { const avatar = bot?.avatar ?? {}; @@ -2592,7 +2609,7 @@ diff --git a/client.js b/client.js + const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]`; + const key = chipKey(bot); + const chip = key !== null && owner.get(key) === bot ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, "\\$&")}" i]` : ""; -+ const look = lookOf(bot); ++ const look = bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot); + const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`; + const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; + const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ""; @@ -9623,7 +9640,7 @@ diff --git a/client.js b/client.js if (!row.bot) return PeopleIcon; const id = row.bot.id; - if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react10.createElement)(BotMark, { bot: roster.getSnapshot().byId[id] ?? row.bot, size })); -+ if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react16.createElement)(BotMark, { bot: roster.getSnapshot().byId[id] ?? row.bot, size })); ++ if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react16.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })); return marks.get(id); }; } @@ -17982,12 +17999,17 @@ diff --git a/client.js b/client.js var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { -@@ -13119,10 +13119,58 @@ function contentParts(content) { +@@ -13119,10 +13119,63 @@ function contentParts(content) { } return { text: texts2.join("\n"), attachments, rest }; } -function mentionableBots(roster, sessionId) { +var normMentionHandle = (handle) => String(handle ?? "").replace(/^@+/, "").toLowerCase(); ++function withMentionLook(bot, roster) { ++ if (!bot?.remote) return bot; ++ const draw = markBotForMention(bot, roster); ++ return draw && draw !== bot ? { ...bot, draw } : bot; ++} +function remoteMentionableBots(roster, chats = []) { + const known = /* @__PURE__ */ new Map(); + const add = (record) => { @@ -18000,7 +18022,7 @@ diff --git a/client.js b/client.js + return all.flatMap((record) => { + const handle = record.remote?.handle ?? record.handle; + const chat = (Array.isArray(chats) ? chats : []).find((bot) => normMentionHandle(bot?.remote?.handle) === normMentionHandle(handle)); -+ const base = { id: chat?.id, mentionLabel: remoteMentionLabel(record, all), kind: "bot", remote: record, handle }; ++ const base = withMentionLook({ id: chat?.id, mentionLabel: remoteMentionLabel(record, all), kind: "bot", remote: record, handle }, roster); + return [{ ...base, name: String(handle).replace(/^@+/, "") }, { ...base, name: base.mentionLabel }]; + }); +} @@ -18011,7 +18033,7 @@ diff --git a/client.js b/client.js + if (room2?.origin === "face-to-face") return faceMentionables(room2, roster, fromFriend); + const bots = room2 ? room2.members.map((id) => roster.byId[id]).filter(Boolean) : roster.byId[sessionId] ? roster.bots : []; + const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })); -+ const remotes = (room2?.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, mentionLabel: remoteBotTitle(p) })); ++ const remotes = (room2?.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => withMentionLook({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, mentionLabel: remoteBotTitle(p) }, roster)); + return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots)); +} +function faceMentionables(room2, roster, fromFriend) { @@ -18020,7 +18042,7 @@ diff --git a/client.js b/client.js + const seats = all.filter((seat) => !seat.left); + const ordered = [...seats.filter((seat) => seat.mine !== fromFriend), ...seats.filter((seat) => seat.mine === fromFriend), ...all.filter((seat) => seat.left)]; + const bots = ordered.flatMap((seat) => { -+ const target = seat.local ?? { id: seat.key, name: seat.bot, kind: "bot", remote: seat.record }; ++ const target = seat.local ?? withMentionLook({ id: seat.key, name: seat.bot, kind: "bot", remote: seat.record }, roster); + const record = { ...seat.record, ownerName: seat.record.ownerName || seat.person }; + const mentionLabel = faceSeatLabel(seat); + return faceAliases(record, seat.bot).map((name) => ({ ...target, name, mentionLabel })); @@ -18060,7 +18082,7 @@ diff --git a/client.js b/client.js - (0, import_react34.createElement)(BotMark, { bot: part.bot, size: 16 }), - part.label - ) : (0, import_react34.createElement)(import_react34.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references))); -+ const chip = (part) => [(0, import_react42.createElement)(BotMark, { bot: part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; ++ const chip = (part) => [(0, import_react42.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; + return userTextParts(text, bots, referenceLabels).map((part, index) => { + if (!part.bot) return (0, import_react42.createElement)(import_react42.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); + const title = `@${part.bot.name}`; diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 4bc2d280c..18701c85f 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -32,6 +32,13 @@ diff --git a/src/client/api.js b/src/client/api.js diff --git a/src/client/composer.js b/src/client/composer.js --- a/src/client/composer.js +++ b/src/client/composer.js +@@ -2,5 +2,5 @@ + import { createPortal } from 'react-dom' + import { ChatsIcon, ClockIcon, MicIcon, PaperclipIcon, PeopleIcon, PlusIcon, WaveIcon } from './icons.js' +-import { BotMark } from './mark.js' ++import { BotMark, markBotForMention } from './mark.js' + import { useLinger } from './motion.js' + import { t } from './i18n.js' @@ -53,6 +53,20 @@ export function useDictation(onText) { return { listening, note, start, stop } } @@ -53,6 +60,19 @@ diff --git a/src/client/composer.js b/src/client/composer.js export function ComposerVoice({ sessionId, inputActions, actions, useUi }) { const dictation = useDictation((text, final) => { if (final && inputActions) inputActions.insertText(text, inputActions.captureInsertion()) +@@ -92,11 +92,11 @@ + // Character marks for the "@" menu rows. Each Bot keeps one component, so the menu + // does not remount its mark on every keystroke. + export function menuIcons(roster) { + const marks = new Map() + return (row) => { + if (!row.bot) return PeopleIcon + const id = row.bot.id +- if (!marks.has(id)) marks.set(id, ({ size = 14 }) => h(BotMark, { bot: roster.getSnapshot().byId[id] ?? row.bot, size })) ++ if (!marks.has(id)) marks.set(id, ({ size = 14 }) => h(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })) + return marks.get(id) + } + } diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js --- a/src/client/details-drawer.js +++ b/src/client/details-drawer.js diff --git a/patches/ds-bot/files/src/client/mention-style.js b/patches/ds-bot/files/src/client/mention-style.js index ed4cf082e..dd2e33655 100644 --- a/patches/ds-bot/files/src/client/mention-style.js +++ b/patches/ds-bot/files/src/client/mention-style.js @@ -1,4 +1,4 @@ -import { lookOf, maskOf, DEFAULT_MASK } from './characters.js' +import { lookAvoidingLocals, lookOf, maskOf, DEFAULT_MASK } from './characters.js' import { inkText, paintCss } from './inks.js' import { MENTION_ORIGIN } from './text.js' @@ -26,7 +26,9 @@ export function mentionCss(bots) { const chip = key !== null && owner.get(key) === bot ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, '\\$&')}" i]` : '' - const look = lookOf(bot) + // The same decision BotMark makes, so a friend's anchor does not keep a raw look the + // sidebar has already stepped off (#1088). A local Bot still owns a shared @Name chip. + const look = bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot) const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before` const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` diff --git a/patches/ds-bot/files/test/mention-look.test.js b/patches/ds-bot/files/test/mention-look.test.js new file mode 100644 index 000000000..b5ea361da --- /dev/null +++ b/patches/ds-bot/files/test/mention-look.test.js @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' +import { lookAvoidingLocals, lookKey, lookOf } from '../src/client/characters.js' +import { indexRoster } from '../src/client/sources.js' + +// A face-room @ row and the mention chip used to draw a stand-in whose color and +// avatar lived only on `remote`. lookOf ignores that and seeds the handle, so +// @chief.selfa1025.home is a green moon while the sidebar chat is the stored blue whale. +// BotMark then steps that whale when a local Bot of the same name draws it (#1069). + +const HANDLE = '@chief.selfa1025.home' + +async function load(entry) { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL(`../src/client/${entry}`, import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const module = { exports: {} } + runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, + require: (name) => { + const platform = { + react: { + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), + useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', + }, + 'react-dom': { createPortal: node => node }, + '@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + }) + return module.exports +} + +const expand = (node, names) => { + if (Array.isArray(node)) return node.flatMap(child => expand(child, names)) + if (node === null || typeof node !== 'object') return node + if (names.includes(node.type?.name)) return expand(node.type({ ...node.props, children: node.children }), names) + return { ...node, children: (node.children ?? []).map(child => expand(child, names)) } +} +const find = (node, match) => { + if (Array.isArray(node)) return node.flatMap(child => find(child, match)) + if (node === null || typeof node !== 'object') return [] + return [...(match(node) ? [node] : []), ...find(node.children, match)] +} + +const { mentionRows } = await load('at-menu.js') +const { menuIcons } = await load('composer.js') +const { UserMessageCell } = await load('user-message.js') + +const local = { id: 'b1', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' }, sessionId: 'b1' } +const chat = { id: 'remote-1', name: 'Chief', color: '#4d6bfe', avatar: { shape: 'whale' }, remote: { handle: HANDLE, homeName: 'SelfA1025', owner: 'SelfA1025' } } +const seat = { kind: 'bot', remote: true, id: HANDLE, handle: HANDLE, displayName: 'Chief', ownerName: 'SelfA1025', isChief: true, color: '#4d6bfe', avatar: { shape: 'whale' } } +const mine = { kind: 'bot', id: 'b1', handle: '@chief.selfb.home', displayName: 'Chief', ownerName: 'SelfB' } + +function roomRoster(bots) { + return indexRoster({ + self: { displayName: 'SelfB' }, + bots, + mainBotIds: ['b1'], + rooms: [{ id: 'room', origin: 'face-to-face', name: 'Face', members: ['b1'], participants: [mine, seat] }], + }) +} + +const storedKey = lookKey(lookOf(chat)) +const seededKey = lookKey(lookOf({ id: HANDLE, name: 'Chief (SelfA1025)', remote: seat, handle: HANDLE })) +// The picture BotMark paints: the stored record, stepped off a same-named local Chief. +const drawnKey = bot => lookKey(lookAvoidingLocals(bot)) + +function iconBot(roster, row) { + const Icon = menuIcons({ getSnapshot: () => roster })(row) + const drawn = Icon({ size: 14 }) + assert.equal(drawn.type?.name, 'BotMark') + return drawn.props.bot +} + +function chipBot(roster, text) { + const cell = UserMessageCell({ + node: { kind: 'user', data: { content: [{ type: 'text', text }] } }, + sessionId: 'room', + useRoster: select => select(roster), + actions: { openSession() {} }, + }) + const marks = find(expand(cell, ['UserText']), node => node.type?.name === 'BotMark') + assert.equal(marks.length, 1) + return marks[0].props.bot +} + +test('the reported handle seed is a green moon, not the stored blue whale', () => { + assert.equal(seededKey, 'shape:moon:green') + assert.equal(storedKey, 'shape:whale:#4d6bfe') + assert.notEqual(seededKey, storedKey) +}) + +test('a face-room @ row and mention chip draw the stored friend look, not the handle seed (#1088)', () => { + const roster = roomRoster([local, chat]) + const rows = mentionRows(roster, 'room') + const friend = rows.find(row => row.bot?.remote) + const mineRow = rows.find(row => row.bot && !row.bot.remote) + assert.ok(friend) + assert.equal(iconBot(roster, friend).id, chat.id) + assert.equal(lookKey(lookOf(iconBot(roster, friend))), storedKey) + assert.equal(drawnKey(iconBot(roster, friend)), drawnKey(chat)) + assert.notEqual(drawnKey(chat), seededKey) + assert.notEqual(drawnKey(chat), lookKey(lookOf(local))) + assert.equal(lookKey(lookOf(iconBot(roster, mineRow))), lookKey(lookOf(local))) + const chip = chipBot(roster, "@SelfA1025's Chief hi") + assert.equal(chip.id, chat.id) + assert.equal(lookKey(lookOf(chip)), storedKey) + assert.equal(drawnKey(chip), drawnKey(chat)) + // The chip still names the seat. The picture is the stored chat, the label is not. + const cell = expand(UserMessageCell({ + node: { kind: 'user', data: { content: [{ type: 'text', text: "@SelfA1025's Chief hi" }] } }, + sessionId: 'room', + useRoster: select => select(roster), + actions: { openSession() {} }, + }), ['UserText']) + const button = find(cell, node => node.props?.className === 'bt-umention')[0] + assert.match(JSON.stringify(button.children), /Chief \(SelfA1025\)/) +}) + +test('a face-room seat look is drawn when the friend has no chat yet (#1088)', () => { + const roster = roomRoster([local]) + const friend = mentionRows(roster, 'room').find(row => row.bot?.remote) + const icon = iconBot(roster, friend) + const chip = chipBot(roster, "@SelfA1025's Chief hi") + assert.equal(lookKey(lookOf(icon)), storedKey) + assert.equal(lookKey(lookOf(chip)), storedKey) + assert.equal(drawnKey(icon), drawnKey(chip)) + assert.equal(drawnKey(icon), drawnKey(chat)) + assert.notEqual(drawnKey(icon), seededKey) +})