diff --git a/changelog.d/967-friend-bot-mention.fixed.md b/changelog.d/967-friend-bot-mention.fixed.md new file mode 100644 index 000000000..6c63061c1 --- /dev/null +++ b/changelog.d/967-friend-bot-mention.fixed.md @@ -0,0 +1 @@ +A friend's Bot picked from the `@` menu now inserts the name people read — `@Chief (Bob)`, the same string as its sidebar row — and the sent message draws it as an `@` chip in the bubble and the preview instead of a bare handle. The handle stays the routing key underneath, so the message reaches the same Bot, and a line written before this change keeps its chip. Two friends' Bots with the same name stay apart in the label, and a friend the core does not list yet, offered by Bot Social's own `@` row, inserts that name too. ([#967](https://github.com/ThinkFlowLab/FlyBot/issues/967)) diff --git a/core/client.js b/core/client.js index 5beab5f58..0ce92f682 100644 --- a/core/client.js +++ b/core/client.js @@ -1531,14 +1531,26 @@ function friendRecord(bot) { const remote = bot?.remote && typeof bot.remote === "object" ? bot.remote : {}; return { displayName: remote.displayName || remote.name || bot?.name, - ownerName: remote.ownerName || remote.owner || remote.homeName, + ownerName: remote.ownerName || remote.owner || remote.homeName || bot?.ownerName || bot?.owner || bot?.homeName, handle: remote.handle ?? bot?.handle, - isChief: remote.isChief + isChief: remote.isChief ?? bot?.isChief }; } function friendBotTitle(bot) { return remoteBotTitle(friendRecord(bot)); } +var mentionHandleOf = (record) => String((record?.remote && typeof record.remote === "object" ? record.remote.handle : record?.handle) ?? "").replace(/^@+/, "").toLowerCase(); +function remoteMentionLabel(record, others = []) { + const title = friendBotTitle(record); + const key = mentionHandleOf(record); + const clash = (others ?? []).some((other) => { + const otherKey = mentionHandleOf(other); + return otherKey !== "" && otherKey !== key && friendBotTitle(other) === title; + }); + if (!clash) return title; + const home = cleanPeerName(String(record?.remote?.home ?? record?.home ?? "").replace(/^@+/, ""), ""); + return home ? `${title} · ${home}` : title; +} function friendBotOwner(bot) { return remoteOwnerBadge(friendRecord(bot)); } @@ -1705,7 +1717,8 @@ function remoteMembers(roster) { } function remoteMentionRows(roster, query = "") { const needle = String(query).trim().toLowerCase().replace(/^@/, ""); - return remoteMembers(roster).filter((bot) => bot.handle.toLowerCase().includes(needle) || String(bot.name ?? "").toLowerCase().includes(needle)).map((bot) => ({ remote: bot, text: `${bot.handle} ` })); + const all = remoteMembers(roster); + return all.filter((bot) => bot.handle.toLowerCase().includes(needle) || String(bot.name ?? "").toLowerCase().includes(needle)).map((bot) => ({ remote: bot, text: `@${remoteMentionLabel(bot, all)} ` })); } function remoteMembersByHome(roster) { const homes = /* @__PURE__ */ new Map(); @@ -2069,10 +2082,11 @@ function mentionTarget(event) { function nameMentions(text, roster) { const value = String(text ?? ""); if (!value.includes("@") || roster === null || typeof roster !== "object") return value; + const records = [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]; const names = /* @__PURE__ */ new Map(); - for (const record of [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]) { + for (const record of records) { const handle = typeof record?.remote === "object" && record.remote !== null ? record.remote.handle : record?.handle; - if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), record.name); + if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), remoteMentionLabel(record, records)); } const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length); if (handles.length === 0) return value; @@ -2736,7 +2750,7 @@ function registry2() { globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview; globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf; globalThis.__flybot__.inviteCountdown ??= inviteCountdown; - globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner }; + globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner, remoteMentionLabel }; return slots; } var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options }); @@ -8975,9 +8989,10 @@ function atSources({ roster, sessions, launched, iconFor }) { const snapshot = roster.getSnapshot(); const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]); const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({ - // A friend's Bot by its one name, "Chief (Bob)"; its handle is the second line (#384). A seat - // carries its own name; a friend's chat is named as its sidebar row and header (#498). - name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? remoteBotTitle({ ...row.remote, displayName: row.remote.name, ownerName: row.remote.owner || row.remote.homeName }) : t("Everyone"), + // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the + // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar + // row and header (#498). + name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"), ...row.bot?.role && !face ? { description: row.bot.role } : {}, ...row.remote ? { description: row.remote.handle } : {}, ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}, @@ -17019,13 +17034,30 @@ function contentParts(content) { } return { text: texts2.join("\n"), attachments, rest }; } +var normMentionHandle = (handle) => String(handle ?? "").replace(/^@+/, "").toLowerCase(); +function remoteMentionableBots(roster, chats = []) { + const known = /* @__PURE__ */ new Map(); + const add = (record) => { + const handle = normMentionHandle(record?.remote?.handle ?? record?.handle); + if (handle) known.set(handle, record); + }; + for (const record of Array.isArray(roster.remoteBots) ? roster.remoteBots : []) add(record); + for (const record of Array.isArray(chats) ? chats : []) if (typeof record?.remote?.handle === "string") add(record); + const all = [...known.values()]; + 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 }; + return [{ ...base, name: String(handle).replace(/^@+/, "") }, { ...base, name: base.mentionLabel }]; + }); +} function mentionableBots(roster, sessionId, { fromFriend = false } = {}) { const room2 = roster.roomsById[sessionId]; 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) })); - return bots.concat(humans, remotes, bots.filter((bot) => typeof bot?.remote?.handle === "string").map((bot) => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ""), mentionLabel: friendBotTitle(bot) }))); + return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots)); } function faceMentionables(room2, roster, fromFriend) { const humans = (room2.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })); @@ -17069,12 +17101,12 @@ function userTextParts(text, bots, referenceLabels = []) { return parts; } function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { - return userTextParts(text, bots, referenceLabels).map((part, index) => part.bot ? (0, import_react41.createElement)( - "button", - { key: index, type: "button", className: "bt-umention", title: `@${part.bot.name}`, onClick: () => actions.openSession(part.bot.id) }, - (0, import_react41.createElement)(BotMark, { bot: part.bot, size: 16 }), - `@${part.bot.mentionLabel ?? part.label}` - ) : (0, import_react41.createElement)(import_react41.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references))); + const chip = (part) => [(0, import_react41.createElement)(BotMark, { bot: part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; + return userTextParts(text, bots, referenceLabels).map((part, index) => { + if (!part.bot) return (0, import_react41.createElement)(import_react41.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); + const title = `@${part.bot.name}`; + return typeof part.bot.id === "string" ? (0, import_react41.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react41.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); + }); } function Attachments({ attachments, renderMessageImages }) { const compact2 = attachments.length > 1; diff --git a/core/src/client/at-menu.js b/core/src/client/at-menu.js index 98796f1c2..5f483535f 100644 --- a/core/src/client/at-menu.js +++ b/core/src/client/at-menu.js @@ -138,9 +138,10 @@ export function atSources({ roster, sessions, launched, iconFor }) { const snapshot = roster.getSnapshot() const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]) const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map(row => ({ - // A friend's Bot by its one name, "Chief (Bob)"; its handle is the second line (#384). A seat - // carries its own name; a friend's chat is named as its sidebar row and header (#498). - name: row.bot?.remote && !face ? (row.bot.kind === 'bot' ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot)) : row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? remoteBotTitle({ ...row.remote, displayName: row.remote.name, ownerName: row.remote.owner || row.remote.homeName }) : t('Everyone'), + // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the + // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar + // row and header (#498). + name: row.bot?.remote && !face ? (row.bot.kind === 'bot' ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot)) : row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? row.text.replace(/^@/, '').trimEnd() : t('Everyone'), ...(row.bot?.role && !face ? { description: row.bot.role } : {}), ...(row.remote ? { description: row.remote.handle } : {}), ...(row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}), diff --git a/core/src/client/flybot-registry.js b/core/src/client/flybot-registry.js index 3f88eef1f..51a246cd9 100644 --- a/core/src/client/flybot-registry.js +++ b/core/src/client/flybot-registry.js @@ -1,5 +1,6 @@ // The FlyBot slot registry, free of React so the "@" menu and tests can use it. // See flybot-slots.js for what each slot carries. +import { remoteMentionLabel } from '../shared/remote-label.js' export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page'] export function createRegistry() { @@ -132,13 +133,15 @@ export function remoteMembers(roster) { return Array.isArray(roster?.remoteBots) ? roster.remoteBots.filter(bot => typeof bot?.handle === 'string') : [] } -// "@query" rows for remote members: picking one inserts "@handle ", which the host sends -// through bot.send (so the social plugin's approval still applies). +// "@query" rows for remote members: picking one inserts the name people read ("@Coder (Bob) ", +// #967), which the host matches back to the handle it routes on and sends through bot.send (so the +// social plugin's approval still applies). export function remoteMentionRows(roster, query = '') { const needle = String(query).trim().toLowerCase().replace(/^@/, '') - return remoteMembers(roster) + const all = remoteMembers(roster) + return all .filter(bot => bot.handle.toLowerCase().includes(needle) || String(bot.name ?? '').toLowerCase().includes(needle)) - .map(bot => ({ remote: bot, text: `${bot.handle} ` })) + .map(bot => ({ remote: bot, text: `@${remoteMentionLabel(bot, all)} ` })) } // Remote members grouped by their home, for the sidebar. diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 7c737739e..93283c6c2 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -28,12 +28,13 @@ // // The same registry object also carries the host Bot look so a plugin can draw a Bot // exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf, and name a friend's Bot -// or its owner the same way: __flybot__.names.friendBotTitle / friendBotOwner (set in registry()). +// or its owner the same way: __flybot__.names.friendBotTitle / friendBotOwner / remoteMentionLabel +// (set in registry()). // // Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry). import { Component, createElement as h, Fragment, useEffect, useState } from 'react' import { call as callHost } from './api.js' -import { friendBotOwner, friendBotTitle, isListedRemoteChat } from '../shared/remote-label.js' +import { friendBotOwner, friendBotTitle, isListedRemoteChat, remoteMentionLabel } from '../shared/remote-label.js' import { registry as baseRegistry } from './flybot-registry.js' import { BotAvatar, BotMark, lookOf } from './mark.js' import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from './room-invite-preview.js' @@ -72,7 +73,9 @@ export function registry() { globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf globalThis.__flybot__.inviteCountdown ??= inviteCountdown // The same for a friend's Bot's one name and its owner (#498), so the social plugin says it as the sidebar does. - globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner } + // `remoteMentionLabel` is the one name a friend Bot mention wears (#967), so a plugin row for a + // friend the core does not list yet inserts the name the bubble and the preview will chip. + globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner, remoteMentionLabel } return slots } diff --git a/core/src/client/text.js b/core/src/client/text.js index c080a2a09..17ca7a787 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -2,6 +2,7 @@ // Text helpers import { dateLocale, t } from './i18n.js' import { mentionSpans } from '../shared/mentions.js' +import { remoteMentionLabel } from '../shared/remote-label.js' export const MENTION_ORIGIN = 'https://bots.local/' @@ -28,18 +29,19 @@ export function mentionTarget(event) { return anchor ? anchor.getAttribute('href').slice(MENTION_ORIGIN.length) : null } -// A friend's remote Bot is stored and delivered by its handle ("@bot-x.home": what the -// @ menu inserts, what the host matches); the user reads its display name. Previews -// rewrite the handle from the local roster, never from the text, so a remote name -// reaches the UI only through the sanitized roster record. One pass over the text: a -// name that itself contains a handle is never rescanned. +// A friend's remote Bot is stored and delivered by its handle ("@bot-x.home": what older lines +// carry, what the host routes on); the user reads one name for it, the same the @ menu inserts and +// the bubble chips (#967). Previews rewrite the handle from the local roster, never from the text, +// so a remote name reaches the UI only through the sanitized roster record. One pass over the text: +// a name that itself contains a handle is never rescanned. export function nameMentions(text, roster) { const value = String(text ?? '') if (!value.includes('@') || roster === null || typeof roster !== 'object') return value + const records = [...(Array.isArray(roster.remoteBots) ? roster.remoteBots : []), ...(Array.isArray(roster.bots) ? roster.bots : [])] const names = new Map() - for (const record of [...(Array.isArray(roster.remoteBots) ? roster.remoteBots : []), ...(Array.isArray(roster.bots) ? roster.bots : [])]) { + for (const record of records) { const handle = typeof record?.remote === 'object' && record.remote !== null ? record.remote.handle : record?.handle - if (typeof handle === 'string' && typeof record?.name === 'string' && record.name !== '') names.set(handle.replace(/^@+/, '').toLowerCase(), record.name) + if (typeof handle === 'string' && typeof record?.name === 'string' && record.name !== '') names.set(handle.replace(/^@+/, '').toLowerCase(), remoteMentionLabel(record, records)) } const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length) if (handles.length === 0) return value diff --git a/core/src/client/user-message.js b/core/src/client/user-message.js index 4f30b9d76..fb5c921bb 100644 --- a/core/src/client/user-message.js +++ b/core/src/client/user-message.js @@ -4,7 +4,7 @@ import { mentionSpans } from '../shared/mentions.js' import { BotMark, PersonMark } from './mark.js' import { CheckIcon, CopyIcon } from './icons.js' import { dateLocale, t } from './i18n.js' -import { cleanPeerName, faceAliases, friendBotTitle, remoteBotTitle } from '../shared/remote-label.js' +import { cleanPeerName, faceAliases, remoteBotTitle, remoteMentionLabel } from '../shared/remote-label.js' import { facePeople, faceSeatLabel, faceSeats } from './face-seats.js' // ------------------------------------------------------------------------- @@ -30,9 +30,29 @@ export function contentParts(content) { } // In a group only its members are called; elsewhere a mention opens that Bot's chat. -// A friend's remote Bot is also called by its handle — the @ menu inserts "@" -// and the host delivers on it — so the handle joins as a second name of the same Bot, -// and the chip reads the display name (mentionLabel) from the roster. +// A friend's Bot is called from any chat I can write in, and the roster knows it before a chat of +// its own exists and inside a group it holds no seat in (#967). Two names, one Bot: the title +// people read (what the @ menu inserts now) and the handle the host routes on (what older lines +// carry), so both forms chip as the same mention. The click opens its chat when one exists. +const normMentionHandle = handle => String(handle ?? '').replace(/^@+/, '').toLowerCase() + +function remoteMentionableBots(roster, chats = []) { + const known = new Map() + const add = (record) => { + const handle = normMentionHandle(record?.remote?.handle ?? record?.handle) + if (handle) known.set(handle, record) + } + for (const record of Array.isArray(roster.remoteBots) ? roster.remoteBots : []) add(record) + for (const record of Array.isArray(chats) ? chats : []) if (typeof record?.remote?.handle === 'string') add(record) + const all = [...known.values()] + 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 } + return [{ ...base, name: String(handle).replace(/^@+/, '') }, { ...base, name: base.mentionLabel }] + }) +} + export function mentionableBots(roster, sessionId, { fromFriend = false } = {}) { const room = roster.roomsById[sessionId] if (room?.origin === 'face-to-face') return faceMentionables(room, roster, fromFriend) @@ -42,8 +62,7 @@ export function mentionableBots(roster, sessionId, { fromFriend = false } = {}) .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) })) - return bots.concat(humans, remotes, bots.filter(bot => typeof bot?.remote?.handle === 'string') - .map(bot => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ''), mentionLabel: friendBotTitle(bot) }))) + return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots)) } // A face-to-face line was typed on either home, so a Bot is matched by every name the room @@ -102,10 +121,16 @@ export function userTextParts(text, bots, referenceLabels = []) { } function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { - return userTextParts(text, bots, referenceLabels).map((part, index) => (part.bot - ? 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.bot.mentionLabel ?? 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}`] + 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}` + // A friend's Bot the roster knows but that has no chat yet has nowhere to navigate to; the + // name still chips, as a mark rather than a button that would open nothing (#967). + return typeof part.bot.id === 'string' + ? h('button', { key: index, type: 'button', className: 'bt-umention', title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) + : h('span', { key: index, className: 'bt-umention', title }, chip(part)) + }) } function Attachments({ attachments, renderMessageImages }) { diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 0edc8b322..bb2b6226a 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -14,7 +14,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { isApprovalWaiting } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' import { mentionSpans } from '../shared/mentions.js' -import { isHomeLevelRemote } from '../shared/remote-label.js' +import { isHomeLevelRemote, remoteMentionLabel } from '../shared/remote-label.js' import { cleanName, stripSessionChips } from './delivery.js' import { SOURCE_KIND } from './constants.js' import { currentParticipants, isFaceRoom } from './room-membership.js' @@ -890,10 +890,13 @@ export function install(rt) { const from = room ? (room.admin ?? room.members?.[0]) : rt.selfOf(sessionId) if (from === undefined || rt.botOf(from) === undefined) return [] // The same "@" rule as the team (code, links and quoted lines are literal), over the - // friends' Bots by handle and by chat name; a menu chip names the chat directly. + // friends' Bots by the name the @ menu inserts (#967), by the handle older lines carry, and by + // chat name; a menu chip names the chat directly. Every alias routes to the one handle. const handles = new Set() const quoted = text.replace(/^[ \t]*>.*$/gm, line => ' '.repeat(line.length)) - const names = [...known.values()].flatMap(bot => [ + const remoteList = [...known.values()] + const names = remoteList.flatMap(bot => [ + { name: remoteMentionLabel(bot, remoteList), handle: bot.handle }, { name: bot.handle.slice(1), handle: bot.handle }, ...Object.values(state.bots).filter(chat => chat.remote?.handle === bot.handle && chat.name).map(chat => ({ name: chat.name, handle: bot.handle })), ]) diff --git a/core/src/shared/remote-label.js b/core/src/shared/remote-label.js index 8cc588abf..2b2d5da67 100644 --- a/core/src/shared/remote-label.js +++ b/core/src/shared/remote-label.js @@ -144,14 +144,16 @@ export function remoteOwnerBadge(record) { } // The record those two read for a friend's Bot as the roster lists it: its chat (`bot.remote` from -// remote-chat.js, named by the chat) or a seat that carries its own name and owner. +// remote-chat.js, named by the chat) or a published roster record (roster.remoteBots, which carries +// the name, owner and home at the top level and `remote: true`). Reading both shapes here is what +// keeps a Bot's name the same before and after its chat exists (#498, #967). function friendRecord(bot) { const remote = bot?.remote && typeof bot.remote === 'object' ? bot.remote : {} return { displayName: remote.displayName || remote.name || bot?.name, - ownerName: remote.ownerName || remote.owner || remote.homeName, + ownerName: remote.ownerName || remote.owner || remote.homeName || bot?.ownerName || bot?.owner || bot?.homeName, handle: remote.handle ?? bot?.handle, - isChief: remote.isChief, + isChief: remote.isChief ?? bot?.isChief, } } @@ -161,6 +163,24 @@ export function friendBotTitle(bot) { return remoteBotTitle(friendRecord(bot)) } +const mentionHandleOf = record => String((record?.remote && typeof record.remote === 'object' ? record.remote.handle : record?.handle) ?? '').replace(/^@+/, '').toLowerCase() + +// The one name a friend's Bot wears where a mention is shown or picked (#967): the same title as +// its sidebar row (#498), so the `@` menu, the bubble and the preview never disagree. Two friends +// may publish a Bot with the same name, so a title that two different handles share carries the +// home: one name still names one Bot at the transport, and the handle stays the routing key. +export function remoteMentionLabel(record, others = []) { + const title = friendBotTitle(record) + const key = mentionHandleOf(record) + const clash = (others ?? []).some((other) => { + const otherKey = mentionHandleOf(other) + return otherKey !== '' && otherKey !== key && friendBotTitle(other) === title + }) + if (!clash) return title + const home = cleanPeerName(String(record?.remote?.home ?? record?.home ?? '').replace(/^@+/, ''), '') + return home ? `${title} · ${home}` : title +} + // The person who owns a friend's Bot: who a held reply to that Bot goes to (#418, #498). export function friendBotOwner(bot) { return remoteOwnerBadge(friendRecord(bot)) diff --git a/core/test/at-menu.test.js b/core/test/at-menu.test.js index 5990467c8..b07083b2a 100644 --- a/core/test/at-menu.test.js +++ b/core/test/at-menu.test.js @@ -145,7 +145,7 @@ test('#305: a face room offers each of its Bots once, named Bot (Person), and no assert.deepEqual(mentionRows(group, 'face', 'chief.selfa').map(row => row.text), ['@Chief (SelfA) ']) }) -test('#384: a friend\'s Bot with its own chat is offered once in a Bot\'s chat, by its handle, named Bot (owner)', async () => { +test('#384: a friend\'s Bot with its own chat is offered once in a Bot\'s chat, by its handle, under its one name (owner)', async () => { const remote = { handle: '@coder.bob.home', name: 'Coder', home: '@bob.home', owner: 'Bob', homeName: 'Family Workspace' } const chat = indexRoster({ bots: [ @@ -158,7 +158,10 @@ test('#384: a friend\'s Bot with its own chat is offered once in a Bot\'s chat, }) const rows = mentionRows(chat, 'chief') assert.equal(rows.filter(row => (row.remote ?? row.bot?.remote)?.handle === '@coder.bob.home').length, 1) - assert.deepEqual(rows.map(row => row.text), ['@Helper ', '@coder.bob.home ']) + // The row still belongs to that handle, but it inserts the name the bubble and the sidebar preview + // show, so the token the user reads is not the transport key (#967). + assert.deepEqual(rows.map(row => row.text), ['@Helper ', '@Coder (Bob) ']) + assert.deepEqual(rows.filter(row => row.text === '@Coder (Bob) ').map(row => row.remote.handle), ['@coder.bob.home']) const [bots] = atSources({ roster: { getSnapshot: () => chat }, sessions: { list: { getSnapshot: () => ({ ids: [], byId: {} }) } }, diff --git a/core/test/flybot-client.test.js b/core/test/flybot-client.test.js index 2b9a2763c..4958cfebc 100644 --- a/core/test/flybot-client.test.js +++ b/core/test/flybot-client.test.js @@ -1,7 +1,39 @@ 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 { createClientApi, createRegistry, mentionCandidates, newChatActionRows, registry, SLOTS } from '../src/client/flybot-registry.js' +// A client module that imports React cannot be loaded as plain ESM here (the platform packages come +// from a DSH install, not from this checkout), so bundle it the way the other client tests do +// (test/face-person-first.test.js) and hand it a small stand-in for the platform modules. +async function loadClientModule(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 +} + test('the client registry orders entries, replaces by id, and notifies', () => { const slots = createRegistry() let ticks = 0 @@ -86,15 +118,16 @@ test('the browser flybot service lists Bots and turns host changes into field ev assert.throws(() => api.fields.on('nope', () => {}), /unknown event/) }) -test('remote members: @ rows insert the handle, the sidebar groups by home, bubbles show the fenced words', async () => { +test('remote members: @ rows insert the one name, the sidebar groups by home, bubbles show the fenced words', async () => { const { remoteMentionRows, remoteMembersByHome, remoteMessageText } = await import('../src/client/flybot-registry.js') const { mentionRows } = await import('../src/client/at-menu.js') const roster = { ready: true, bots: [{ id: 'a', name: 'Chief' }, { id: 'b', name: 'Writer' }], byId: { a: { id: 'a', name: 'Chief' }, b: { id: 'b', name: 'Writer' } }, roomsById: {}, rooms: [], remoteBots: [{ handle: '@coder.bob.home', name: 'Coder', home: '@bob.home', homeName: 'Bob', remote: true }, { handle: '@x.eve.home', name: 'X', home: '@eve.home', remote: true }], } - assert.deepEqual(remoteMentionRows(roster, 'cod').map(row => row.text), ['@coder.bob.home ']) - assert.deepEqual(mentionRows(roster, 'a', 'coder').map(row => row.text), ['@coder.bob.home ']) + // #967: the menu inserts the name people read ("Coder (Bob)"), not the handle it routes on. + assert.deepEqual(remoteMentionRows(roster, 'cod').map(row => row.text), ['@Coder (Bob) ']) + assert.deepEqual(mentionRows(roster, 'a', 'coder').map(row => row.text), ['@Coder (Bob) ']) assert.deepEqual(remoteMembersByHome(roster).map(home => [home.home, home.name, home.bots.length]), [['@bob.home', 'Bob', 1], ['@eve.home', '@eve.home', 1]]) assert.equal(remoteMessageText('\nLGTM\n'), 'LGTM') const { failureLabel } = await import('../src/client/diagnostics.js') @@ -227,12 +260,75 @@ test("a friend's Bot sidebar row is its one name, \"Chief (Bob)\", on one line, assert.match(row, /const secondLine = awayLine \|\| preview \|\| \(remote \? \(bot\.remote\?\.gone \? t\(.no longer shared.\) : t\("Friend's Bot"\)\) : ''\)/) // The full name is on hover and in the label. assert.match(row, /title: remote \? shown :/) - for (const file of ['header.js', 'brand.js', 'user-message.js', 'at-menu.js']) { + // flybot-slots.js publishes the same formatter to plugins (__flybot__.names), so a plugin row + // inserts the name the bubble chips (#967). + for (const file of ['header.js', 'brand.js', 'user-message.js', 'at-menu.js', 'flybot-slots.js']) { const src = readFileSync(new URL(`../src/client/${file}`, import.meta.url), 'utf8') - assert.match(src, /friendBotTitle\(/, `${file} names a friend's chat through friendBotTitle`) + // #967: a mention reads the same label through remoteMentionLabel, which itself reads + // friendBotTitle, so the menu, the bubble and the preview cannot drift apart. + assert.match(src, /\bfriendBotTitle\b|\bremoteMentionLabel\b/, `${file} names a friend's Bot through the one formatter`) assert.doesNotMatch(src, /ownerName: bot\.remote\.homeName \|\| bot\.remote\.owner/, `${file} builds no name of its own`) } assert.match(styles, /\.bt-row-remote\{[^}]*color:var\(--bt-ink-2\)\}/) const { DICTIONARY } = await import('../src/client/i18n.js') assert.equal(DICTIONARY.zh["Friend's Bot"], '好友的 Bot') }) + +test("a friend's Bot picked from the @ menu reads as its one name in the menu, the bubble and the preview (#967)", async () => { + const { remoteMentionRows } = await import('../src/client/flybot-registry.js') + const { mentionableBots, userTextParts } = await loadClientModule('user-message.js') + const { nameMentions } = await import('../src/client/text.js') + const { atSources } = await import('../src/client/at-menu.js') + // The reported shape: the friend's Bot is on the roster, and no chat of its own exists yet, so + // the bubble used to know no name for it and DSH drew "@coder.bob.home" as a file (bare handle). + const roster = { + ready: true, bots: [{ id: 'a', name: 'Chief' }], byId: { a: { id: 'a', name: 'Chief' } }, roomsById: {}, rooms: [], + remoteBots: [{ handle: '@coder.bob.home', name: 'Coder', home: '@bob.home', homeName: 'Bob', remote: true }], + } + assert.deepEqual(remoteMentionRows(roster, 'cod').map(row => row.text), ['@Coder (Bob) ']) + const bots = mentionableBots(roster, 'a') + const parts = userTextParts('@Coder (Bob) hello', bots) + assert.equal(parts.length, 2) + assert.equal(parts[0].bot.mentionLabel, 'Coder (Bob)') + assert.equal(`@${parts[0].bot.mentionLabel}`, '@Coder (Bob)') + assert.equal(parts[1].text, ' hello') + // The line the menu used to insert still chips as the same Bot, one mention, not raw text. + const legacy = userTextParts('@coder.bob.home hello', bots) + assert.equal(legacy.length, 2) + assert.equal(legacy[0].bot.mentionLabel, 'Coder (Bob)') + assert.equal(legacy[1].text, ' hello') + // One vocabulary: the sidebar preview renders the same words for both forms. + assert.equal(nameMentions('@Coder (Bob) hello', roster), '@Coder (Bob) hello') + assert.equal(nameMentions('@coder.bob.home hello', roster), '@Coder (Bob) hello') + // The menu row shows what it inserts, so picking it is never a surprise. + const source = (await atSources({ + roster: { getSnapshot: () => roster }, + sessions: { list: { getSnapshot: () => ({ byId: {}, ids: [] }) } }, + launched: () => false, + iconFor: () => 'bot', + }))[0] + const row = (await source.candidates({ sessionId: 'a' }, { query: 'cod' }))[0] + assert.equal(row.name, 'Coder (Bob)') + assert.equal(row.value, '@Coder (Bob) ') + assert.equal(row.description, '@coder.bob.home') + // Two friends may publish a Bot with the same name: the label keeps them apart, and each still + // names its own handle at the transport. + const clash = { + ...roster, + remoteBots: [ + { handle: '@chief.bob.home', name: 'Chief', home: '@bob.home', homeName: 'Bob', remote: true }, + { handle: '@chief.ann.home', name: 'Chief', home: '@ann.home', homeName: 'Ann', remote: true }, + ], + } + const labels = remoteMentionRows(clash, 'chief').map(row => row.text) + assert.notEqual(labels[0], labels[1]) + const both = mentionableBots(clash, 'a') + assert.equal(userTextParts(labels[0], both)[0].bot.handle, '@chief.bob.home') + assert.equal(userTextParts(labels[1], both)[0].bot.handle, '@chief.ann.home') + assert.equal(nameMentions(labels[0].trim(), clash), labels[0].trim()) + // Not a mention: a bare handle typed inside a message stays exactly as typed (the raw path is + // not removed, and nothing is silently rewritten), and plain text is untouched. + assert.equal(nameMentions('mail me at coder.bob.home', roster), 'mail me at coder.bob.home') + // The bundled module builds its arrays in another realm, so compare their values, not their prototypes. + assert.deepEqual(JSON.parse(JSON.stringify(userTextParts('plain words', bots).map(part => part.text))), ['plain words']) +}) diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index dcbf7dbf6..ea6718bfc 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -474,6 +474,12 @@ test('mentions (#13): a friend\'s Bot named with @ gets the message once; code, ctx.emitArgs('session/event', { id: 'a' }, { type: 'user/message', seq: 1, data: { source: { kind: 'user' }, content: [{ type: 'text', text: '@helper.friend.home 你好' }] } }) await new Promise(resolve => setTimeout(resolve, 30)) assert.equal(out.length, 1) + // #967: the @ menu inserts the name people read, "助手 (friend)", not the handle; it routes to the + // same handle the row carried, so the transport still has one unambiguous key. + await say('@助手 (friend) 你好') + assert.equal(out.length, 2) + assert.equal(out[1].to.handle, '@helper.friend.home') + assert.equal(out[1].via, 'user-mention') }) test('mentions in a group (#80, #81): the room gets a receipt — sent points to the friend\'s chat, a hold or failure says why', async (t) => { diff --git a/core/test/inbound-ui.test.js b/core/test/inbound-ui.test.js index a3465f8af..b50bb2277 100644 --- a/core/test/inbound-ui.test.js +++ b/core/test/inbound-ui.test.js @@ -64,10 +64,12 @@ test("an inbound friend's message uses the friend's sidebar Bot (avatar, name), assert.equal(other.chatId, undefined) }) -test("a friend Bot's handle in a preview reads as its display name (#71)", () => { +test("a friend Bot's handle in a preview reads as its one name (#71, #967)", () => { const roster = { bots: [{ id: 'f', name: '代码审查官', remote: { handle: '@bot-2a758c0f.danyang.home' } }], remoteBots: [{ handle: '@bot-2a758c0f.danyang.home', name: '代码审查官' }] } - assert.equal(nameMentions('@bot-2a758c0f.danyang.home 你是?', roster), '@代码审查官 你是?') - assert.equal(nameMentions('看 @bot-2a758c0f.danyang.home。', roster), '看 @代码审查官。') + // #967: the same words as the sidebar row and the bubble -- name, then owner -- so a preview is + // never a third vocabulary for the Bot (the row for this chat already reads 代码审查官 (danyang)). + assert.equal(nameMentions('@bot-2a758c0f.danyang.home 你是?', roster), '@代码审查官 (danyang) 你是?') + assert.equal(nameMentions('看 @bot-2a758c0f.danyang.home。', roster), '看 @代码审查官 (danyang)。') // An address before the @, or a longer word after the handle, stays as written. assert.equal(nameMentions('mail a@bot-2a758c0f.danyang.home', roster), 'mail a@bot-2a758c0f.danyang.home') assert.equal(nameMentions('@bot-2a758c0f.danyang.home2 不在', roster), '@bot-2a758c0f.danyang.home2 不在') @@ -80,7 +82,7 @@ test("a friend Bot's handle in a preview reads as its display name (#71)", () => { handle: '@b.x.home', name: '请找 @a.x.home 的主人' }, { handle: '@a.x.home', name: '代码审查官' }, ] } - assert.equal(nameMentions('@b.x.home 你在?', nested), '@请找 @a.x.home 的主人 你在?') + assert.equal(nameMentions('@b.x.home 你在?', nested), '@请找 @a.x.home 的主人 (x) 你在?') }) test("a friend Bot's generated look is seeded by its handle: with or without its chat record, one look (#84)", () => { diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index dc9cfaffb..24e5de19b 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -637,7 +637,7 @@ declare global { invitePreviewOf?: FlyBotInvitePreview inviteCountdown?: (expiresAt: string, offsetMs?: number) => string /** A friend's Bot chat (a roster Bot with `remote`): its one name, "Chief (Bob)", and its owner, "Bob" (#498). */ - names?: { friendBotTitle(bot?: any): string; friendBotOwner(bot?: any): string } + names?: { friendBotTitle(bot?: any): string; friendBotOwner(bot?: any): string; remoteMentionLabel?(bot?: any, others?: any[]): string } } | undefined } diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e2841ae61..6bc3eff2c 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -86,7 +86,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Remote Bot display -- A friend's remote Bot is stored and delivered by its handle (`@.` — 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 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 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. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 8b20b3c9c..97621b0a1 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -49,7 +49,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/experience.js` | Experience library read API (#874 Step 2). `lookupExperience({ repo, techStack, query, root })` reads `core/dev/experience/`. Data and `check.mjs` stay under `core/dev/` (see [ds-bot-core](ds-bot-core.md)). | | `core/src/host/usage.js` | Usage ledger at `$DSH_HOME/bot/usage.sqlite` (`calls`). Ledger v3 (#903): every row carries `harness`, `issue_pr`, `tool_calls`, `wall_ms`, plus existing token columns (`withUsageFields` / `missingUsageFields`). Session turns are metered via `foldCalls` over turn events (`tool/call` count, wall time from attempt start, `issuePr` from turn text or `rt.noteUsageIssuePr(sessionId, ref)`), not from llm/stream alone. A triage tier is stamped with `rt.noteUsageHarness(sessionId, idOrLabels)` and lands on the row's `harness` field; the resume cursor (`marks.fold`) stays provider / model / retry. Sample row fixture: `core/test/fixtures/usage-sample.sqlite`. Tests: `core/test/usage-fields.test.js`, `core/test/usage.test.js`. | | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | -| `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `friendBotTitle(bot)` / `friendBotOwner(bot)` name a friend's chat from the roster (`Chief (SelfB1233)`) and its owner (`SelfB1233`, the person an approval card names) with one formatter for every client surface (#498). `remoteBotName` / `isAutoOwnerName` / `seatOwnerName` keep group and face-room labels in that format too (#526): Bot Social's ` · ` member label is not a Bot name, and an owner name made up from an install id (`FlyBot 682b8a43`) gives way to the username in the home handle (`Reviewer (flybot682b8a43)`). `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the **发给** picker, mentions, and `list_bots` friends lines: a share that ended is not a place to send to. The sidebar asks a different question through `isShownRemoteChat` (#956): a chat whose Bot left the published list keeps its row, marked *no longer shared*, while only a hide the user set (`bot.hidden`) takes a row out. History stays on disk either way. | +| `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `friendBotTitle(bot)` / `friendBotOwner(bot)` name a friend's chat from the roster (`Chief (SelfB1233)`) and its owner (`SelfB1233`, the person an approval card names) with one formatter for every client surface (#498); `friendRecord` reads a published roster record (`roster.remoteBots`, name/owner/home at the top level) as well as a chat's `remote`, so the name does not change when the chat appears (#967). `remoteMentionLabel(record, others)` is the one name a mention wears — that title, and on a title two different handles share the home too (`Chief (Bob) · bob2.home`), so the menu, the bubble, the preview and the host's match cannot drift apart (#967). `remoteBotName` / `isAutoOwnerName` / `seatOwnerName` keep group and face-room labels in that format too (#526): Bot Social's ` · ` member label is not a Bot name, and an owner name made up from an install id (`FlyBot 682b8a43`) gives way to the username in the home handle (`Reviewer (flybot682b8a43)`). `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the **发给** picker, mentions, and `list_bots` friends lines: a share that ended is not a place to send to. The sidebar asks a different question through `isShownRemoteChat` (#956): a chat whose Bot left the published list keeps its row, marked *no longer shared*, while only a hide the user set (`bot.hidden`) takes a row out. History stays on disk either way. | ## Invariants @@ -274,7 +274,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - A friend chat waits on its reply (#443): a successful `forward` sets `state.remoteAwaiting[chatId]` (saved before the send turn ends), the friend's reply clears it as it arrives (`deliver`, before the reply is written into the chat) and again in the echo turn. A failed send never sets it. `channel.js` exposes it as `roster.remoteAwaiting`, and `authz.js` narrows it per owner like `remoteTypedTurns`. - Remote-chat `open` copies optional published `color` / `avatar` / `avatarUpdatedAt` onto the chat record (cached by `avatarUpdatedAt`) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137). - `0002-flybot-api-host` `cleanRemote` sanitizes those peer-controlled `name` / `homeName` / `owner` labels (strip `@` and controls/newlines, 60-char cap) before they are stored. Details in [plugin-runtime](plugin-runtime.md) (#75). -- A friend's Bot @-mentioned in a group is answered in the group (#825): the line reaches it as a room line and its reply comes back as one, so the host no longer posts a "sent" receipt — `mentionReceipt` (`src/host/hooks.js`, source role `mention`) keeps only what went wrong (a `deny`, a `hold`) as one muted line per mention, and the client renders it as an event line (`src/client/cells.js`). The role stays outside the room's round trigger (`groups.js` `opens`) and outside `RELAY_ROLES`, so the receipt opens no round and folds no turns. When that friend's Bot is already a current participant of the room, `sendUserMentions` skips the 1:1 `bot.send` entirely (#143): the human line in the room is the delivery, and Chief's grant is not consulted. A friend's line Bot Social attests they typed themselves (#227) arrives as `remoteChats.deliver({ human: true, ownerName })`: the message source carries `typedBy`, `turns.js` `claimTrigger` records the turn in `state.remoteTypedTurns[sessionId][turn]` (capped at 400 per Session, dropped with the part), `channel.js` publishes it as `roster.remoteTypedTurns` (narrowed per owner in `authz.js`), and the client draws that turn's reply as the person (`typedBy` in `src/client/text.js`, `PersonMessage` in `cells.js`). The flag is never derived from text. A failed 1:1 remote-chat send (`remote-chat.js` `forward` / `notSentLine`) is one short English line with a next step, never a raw `Bot Social send_as_member: …` preview. A local Bot's answer to a friend's Bot (`turns.js` `answer` → `flybotReplyRemote`, `role: 'reply'`, `via: 'auto-reply'`) that the send chain refuses is logged with its reason (#272): a `deny` resolves, it does not throw. +- A friend's Bot @-mentioned in a group is answered in the group (#825): the line reaches it as a room line and its reply comes back as one, so the host no longer posts a "sent" receipt — `mentionReceipt` (`src/host/hooks.js`, source role `mention`) keeps only what went wrong (a `deny`, a `hold`) as one muted line per mention, and the client renders it as an event line (`src/client/cells.js`). The role stays outside the room's round trigger (`groups.js` `opens`) and outside `RELAY_ROLES`, so the receipt opens no round and folds no turns. When that friend's Bot is already a current participant of the room, `sendUserMentions` skips the 1:1 `bot.send` entirely (#143): the human line in the room is the delivery, and Chief's grant is not consulted. `sendUserMentions` matches the name the @ menu inserts (`remoteMentionLabel`, the same one the bubble and the preview render) as an alias of that friend's Bot, keeps the bare handle as another, and routes every alias to the one handle it owns — so the token the user reads is not the transport key (#967). A friend's line Bot Social attests they typed themselves (#227) arrives as `remoteChats.deliver({ human: true, ownerName })`: the message source carries `typedBy`, `turns.js` `claimTrigger` records the turn in `state.remoteTypedTurns[sessionId][turn]` (capped at 400 per Session, dropped with the part), `channel.js` publishes it as `roster.remoteTypedTurns` (narrowed per owner in `authz.js`), and the client draws that turn's reply as the person (`typedBy` in `src/client/text.js`, `PersonMessage` in `cells.js`). The flag is never derived from text. A failed 1:1 remote-chat send (`remote-chat.js` `forward` / `notSentLine`) is one short English line with a next step, never a raw `Bot Social send_as_member: …` preview. A local Bot's answer to a friend's Bot (`turns.js` `answer` → `flybotReplyRemote`, `role: 'reply'`, `via: 'auto-reply'`) that the send chain refuses is logged with its reason (#272): a `deny` resolves, it does not throw. - `deliver()` (`src/host/delivery.js`) copies a cleaned, plugin-attested `remote.typedBy` onto the message `source.remote` for a friend's line to a local Bot; the client uses it to draw the person (#233 follow-up). - A friend's line in a Bot's own chat (#420): `deliver()` (`noteFriendLine`) stamps the receiving Bot with `friendLineAt` (published in the roster, so the save moves the revision) and keeps the senders seen per Bot in `state.remoteContacts[botId][handle]` (host only, capped at 200, dropped with the Bot). A sender's first line carries `source.remote.firstContact`. Group lines, and re-deliveries (`deferred`, `resumed`, `retried`, `resent`), stamp nothing. Covered by `core/test/flybot.test.js`. - **Pickup after a restart** (`resume.js`): every running Bot turn and every delivered-but-not-taken message is noted in `inflight.json`. At the next start a noted turn is picked up once only if a restart really cut it: the durable Session log's `turn/end` for that turn is `interrupted` (or an abort caused by `disposed`, or there is no end). A turn that finished while this plugin was not listening — `completed`, `blocked`, `max-tokens`, `forked`, or aborted by the user / hook / parent — is not repeated; a `turn/start` seen after the start means the noted turn ended too. Notes from a build before the record carried `turn` keep the old behaviour. A pickup is confirmed before its note is dropped: the notes read at start are seeded into the live `turns`/`queued` (so the file keeps everything still owed), a turn's note stays while its pickup runs and is dropped only once handed over (unless the resumed turn has written its own note first), and a queued message whose resend fails is kept for the next start, an exchange is noted once (a resend delivers the `exchangeId` the notes already hold, and noting it again left a copy behind after a turn took it up, so the start after that sent the message once more), and a turn taking up a message forgets every note of its exchange; a crash or a failed delivery during the pickup no longer loses the work — a pickup is at-least-once, not once: a crash between handing the turn over and the write that drops its note repeats that pickup once, because the `taken` set and the inbox that would tell the next start otherwise are process-local. That is the safe side of the trade, a repeated turn the user can see instead of work the user was told about going silently dead. Covered by `core/test/follow-through.test.js`. diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index c36259478..1f994e4c8 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -23,7 +23,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots - `core/src/client/flybot-slots.js` — client slot registry. - `core/src/client/flybot-registry.js` — plugin discovery/registration. - `core/types/flybot.d.ts` — public types. -- Flybot unit tests (`flybot.test.js`, `flybot-client.test.js`, `flybot-feed.test.js`); `flybot-client.test.js` also pins the sidebar footer button (Connect apps opens Connectors), the account menu's Plugins row, the account menu's labels (Connect apps, Scheduled tasks opening Settings → Scheduled tasks, Settings, and the only classic Agent mode switch; #392-#394), and the one-line face-room row with its hover title (#304). +- Flybot unit tests (`flybot.test.js`, `flybot-client.test.js`, `flybot-feed.test.js`); `flybot-client.test.js` also pins the one name a mention wears on every surface — menu insert, bubble chip, sidebar preview, at-source row — and that two friends' same-named Bots stay apart while every alias still routes to its own handle (#967), the sidebar footer button (Connect apps opens Connectors), the account menu's Plugins row, the account menu's labels (Connect apps, Scheduled tasks opening Settings → Scheduled tasks, Settings, and the only classic Agent mode switch; #392-#394), and the one-line face-room row with its hover title (#304). ## Does not own @@ -37,7 +37,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots | `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). A dispatch that fails keeps its error on the record, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend’s Bot no longer shared — reaches the chat view as `retry: false` so the card draws no Retry (#954); every other failure stays retryable. Whether a failure can still be retried is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` reads the flags or, for an older record, the stored sentence, and `approvals.list` marks a failed terminal approval `retry: false` so a dock card and the chat card answer the same question the same way. | | `core/src/host/flybot-feed.js` | Activity feed: folds a Bot Session's `session/event`s into `flybot.on('turn.start' \| 'turn.end' \| 'tool.start' \| 'tool.end' \| 'exchange.delivered' \| 'decision')` (#241). `turn.end` also carries the turn's token counts (summed from its `assistant/message` usage, floored and capped, counts only) under `tokens` (#357). `decision` is fired by `decisions.js` (#396), not folded from a Session event. | | `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`) and `flybot.sidebar.structure` (`parentOf`, optional `include`, `menuItems`, `nodeActions`). Every `flybot.sidebar.section` entry is wrapped in `div.bt-flybot-section`, and a wrapper a section leaves empty (its component renders null) draws no separator: the `:empty` rule zeroes that margin, border and padding, keyed on emptiness rather than on a section id, so the next null-rendering section is covered too (#818; `core/test/flybot-section-empty.test.js` renders the host entry and pins both rules). Its Approve / Reject and the held-reply card both ask `approval-waiting.js` `isApprovalWaiting()` (`pending` and `pending_approval` wait), so a hold carrying the spelling Bot Social's own outbound hold uses stays answerable (#734). Its dock card draws **Retry** only while `src/shared/held-retry.js` `canRetry(approval)` allows it — a terminal failure asks one shared predicate instead of `status === 'failed'` (#968) — and it publishes `globalThis.__flybot__.canRetry` so a classic-script plugin's own dock asks the same rule rather than keeping a second copy. | -| `core/src/client/flybot-registry.js` | Registry. | +| `core/src/client/flybot-registry.js` | Registry. A friend's Bot row in the @ menu carries `remoteMentionLabel` — the one name its bubble and the sidebar preview wear — so the menu inserts the name the user reads, while the row keeps the handle as its value and the send still routes by handle (#967). | | `core/types/flybot.d.ts` | Types (`BotView.hidden`, `bot.update`, …). | ## Entry points @@ -90,7 +90,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Host Bot look for classic-script plugins -- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color / emoji, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. A face-room `members[]` look overlays the chat record (`lookBotForRemote`). For the same reason `registry()` sets `globalThis.__flybot__.names` to `{ friendBotTitle, friendBotOwner }` from `shared/remote-label.js` (typed in `types/flybot.d.ts`), so a plugin names a friend's Bot (`Chief (SelfB1233)`) or its owner exactly as the sidebar and approval cards do (#498). For the same reason `registry()` sets `globalThis.__flybot__.RoomInvitePreview`, `invitePreviewOf` and `inviteCountdown` from `src/client/room-invite-preview.js` (#876): the invite card is one component for the face-room paste flow and the member-invite dialog, and a classic-script plugin renders it instead of keeping a second copy of the markup. `kind` only renames the `data-flybot` hooks (`face-link-preview` / `room-invite-preview`), and `invitePreviewOf` says only what the server reply can honestly say (a name cleaned at fixed point, the lead, the seat count, the deadline). +- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color / emoji, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. A face-room `members[]` look overlays the chat record (`lookBotForRemote`). For the same reason `registry()` sets `globalThis.__flybot__.names` to `{ friendBotTitle, friendBotOwner, remoteMentionLabel }` from `shared/remote-label.js` (typed in `types/flybot.d.ts`), so a plugin names a friend's Bot (`Chief (SelfB1233)`) or its owner exactly as the sidebar and approval cards do (#498), and a plugin's `@` row inserts the one name a mention wears — the one the bubble and the sidebar preview chip — instead of a bare handle (#967). For the same reason `registry()` sets `globalThis.__flybot__.RoomInvitePreview`, `invitePreviewOf` and `inviteCountdown` from `src/client/room-invite-preview.js` (#876): the invite card is one component for the face-room paste flow and the member-invite dialog, and a classic-script plugin renders it instead of keeping a second copy of the markup. `kind` only renames the `data-flybot` hooks (`face-link-preview` / `room-invite-preview`), and `invitePreviewOf` says only what the server reply can honestly say (a name cleaned at fixed point, the lead, the seat count, the deadline). ### A plugin's sanctioned model call (#450) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 848051602..78eae5032 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -162,7 +162,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### One name for a friend's Bot (#384, #305) -`faceMemberLabel` (face-room join/leave lines and member labels), the waiting tiles (`FaceTile` title) and a face room's thinking line (every remote Bot with a known owner, not only on a name clash) all read ` ()`, the same as the core's `remoteBotTitle`; a friend's Chief is `Chief (Bob)`. `faceOwnerName` (#526) reads the owner for those labels, the leave line, a person's typed line and `viewFaceMember`'s `ownerName` (the seat the room, the waiting tiles and the host read): an owner name Bot Social made up from an install id (`FlyBot 682b8a43`) gives way to the username in the handle (`Reviewer (flybot682b8a43) joined`, `flybot682b8a43 left the room`), the same rule as the core's `ownerNameOf`. Approval cards name the receiving person instead (#418, #498): `localHoldView` and `ChatApprovalDock` take the hold's `recipientName`, else `friendPerson` (the friend chat's owner through the core's shared formatter, `globalThis.__flybot__.names.friendBotOwner`), so a card reads `Chief → UiA1415`. `social/friends` adds each Bot's `owner` / `homeName` (additive), and the composer `@` rows from this plugin (`friendRows`) sit in the core's **Bots** section with that name and the handle as description, and skip handles the core already lists from `roster.remoteBots` (no separate Friends' Bots section). +`faceMemberLabel` (face-room join/leave lines and member labels), the waiting tiles (`FaceTile` title) and a face room's thinking line (every remote Bot with a known owner, not only on a name clash) all read ` ()`, the same as the core's `remoteBotTitle`; a friend's Chief is `Chief (Bob)`. `faceOwnerName` (#526) reads the owner for those labels, the leave line, a person's typed line and `viewFaceMember`'s `ownerName` (the seat the room, the waiting tiles and the host read): an owner name Bot Social made up from an install id (`FlyBot 682b8a43`) gives way to the username in the handle (`Reviewer (flybot682b8a43) joined`, `flybot682b8a43 left the room`), the same rule as the core's `ownerNameOf`. Approval cards name the receiving person instead (#418, #498): `localHoldView` and `ChatApprovalDock` take the hold's `recipientName`, else `friendPerson` (the friend chat's owner through the core's shared formatter, `globalThis.__flybot__.names.friendBotOwner`), so a card reads `Chief → UiA1415`. `social/friends` adds each Bot's `owner` / `homeName` (additive), and the composer `@` rows from this plugin (`friendRows`) sit in the core's **Bots** section, skip handles the core already lists from `roster.remoteBots` (no separate Friends' Bots section), and insert that name — `@Coder (Bob) `, the row's `name`, with the handle as description and as the fallback text on a host without `__flybot__.names.remoteMentionLabel` — so the token the row shows is the token the bubble chips (#967). ### Friends listed per Bot (#420) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 19b56251b..6d229fc34 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1796,6 +1796,14 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js diff --git a/src/client/text.js b/src/client/text.js --- a/src/client/text.js +++ b/src/client/text.js +@@ -2,6 +2,7 @@ + // Text helpers + import { dateLocale, t } from './i18n.js' + import { mentionSpans } from '../shared/mentions.js' ++import { remoteMentionLabel } from '../shared/remote-label.js' + + export const MENTION_ORIGIN = 'https://bots.local/' + @@ -10,9 +10,12 @@ export const MENTION_ORIGIN = 'https://bots.local/' // ("@新 Bot" in "@新 Bot 2"); it then stays plain text. export function linkMentions(text, bots, selfId) { @@ -1810,22 +1818,23 @@ diff --git a/src/client/text.js b/src/client/text.js if (!span.bot || span.bot.id === selfId) continue out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, '\\$&')}](${MENTION_ORIGIN}${span.bot.id})` cursor = span.end -@@ -25,6 +25,25 @@ export function mentionTarget(event) { +@@ -25,6 +25,26 @@ export function mentionTarget(event) { return anchor ? anchor.getAttribute('href').slice(MENTION_ORIGIN.length) : null } -+// A friend's remote Bot is stored and delivered by its handle ("@bot-x.home": what the -+// @ menu inserts, what the host matches); the user reads its display name. Previews -+// rewrite the handle from the local roster, never from the text, so a remote name -+// reaches the UI only through the sanitized roster record. One pass over the text: a -+// name that itself contains a handle is never rescanned. ++// A friend's remote Bot is stored and delivered by its handle ("@bot-x.home": what older lines ++// carry, what the host routes on); the user reads one name for it, the same the @ menu inserts and ++// the bubble chips (#967). Previews rewrite the handle from the local roster, never from the text, ++// so a remote name reaches the UI only through the sanitized roster record. One pass over the text: ++// a name that itself contains a handle is never rescanned. +export function nameMentions(text, roster) { + const value = String(text ?? '') + if (!value.includes('@') || roster === null || typeof roster !== 'object') return value ++ const records = [...(Array.isArray(roster.remoteBots) ? roster.remoteBots : []), ...(Array.isArray(roster.bots) ? roster.bots : [])] + const names = new Map() -+ for (const record of [...(Array.isArray(roster.remoteBots) ? roster.remoteBots : []), ...(Array.isArray(roster.bots) ? roster.bots : [])]) { ++ for (const record of records) { + const handle = typeof record?.remote === 'object' && record.remote !== null ? record.remote.handle : record?.handle -+ if (typeof handle === 'string' && typeof record?.name === 'string' && record.name !== '') names.set(handle.replace(/^@+/, '').toLowerCase(), record.name) ++ if (typeof handle === 'string' && typeof record?.name === 'string' && record.name !== '') names.set(handle.replace(/^@+/, '').toLowerCase(), remoteMentionLabel(record, records)) + } + const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length) + if (handles.length === 0) return value diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index eeb810ad3..e4451cfda 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -255,7 +255,7 @@ diff --git a/src/client/at-menu.js b/src/client/at-menu.js // The chats a message here can reference: every other Bot's chat, then the latest // conversations outside the team. Group chats stay out: a group's Session holds only // the user's messages, so a snapshot of it would miss every Bot's reply. -@@ -78,14 +78,32 @@ export function atSources({ roster, sessions, launched, iconFor }) { +@@ -78,14 +78,33 @@ export function atSources({ roster, sessions, launched, iconFor }) { order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -266,9 +266,10 @@ diff --git a/src/client/at-menu.js b/src/client/at-menu.js + const snapshot = roster.getSnapshot() + const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]) + const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map(row => ({ -+ // A friend's Bot by its one name, "Chief (Bob)"; its handle is the second line (#384). A seat -+ // carries its own name; a friend's chat is named as its sidebar row and header (#498). -+ name: row.bot?.remote && !face ? (row.bot.kind === 'bot' ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot)) : row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? remoteBotTitle({ ...row.remote, displayName: row.remote.name, ownerName: row.remote.owner || row.remote.homeName }) : t('Everyone'), ++ // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the ++ // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar ++ // row and header (#498). ++ name: row.bot?.remote && !face ? (row.bot.kind === 'bot' ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot)) : row.bot ? (row.bot.label ?? row.bot.name) : row.remote ? row.text.replace(/^@/, '').trimEnd() : t('Everyone'), + ...(row.bot?.role && !face ? { description: row.bot.role } : {}), + ...(row.remote ? { description: row.remote.handle } : {}), + ...(row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}), @@ -2698,19 +2699,39 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js +import { BotMark, PersonMark } from './mark.js' import { CheckIcon, CopyIcon } from './icons.js' import { dateLocale, t } from './i18n.js' -+import { cleanPeerName, faceAliases, friendBotTitle, remoteBotTitle } from '../shared/remote-label.js' ++import { cleanPeerName, faceAliases, remoteBotTitle, remoteMentionLabel } from '../shared/remote-label.js' +import { facePeople, faceSeatLabel, faceSeats } from './face-seats.js' // ------------------------------------------------------------------------- // The user's own messages. DSH draws every "@word" in them as a file reference, and -@@ -28,10 +28,58 @@ export function contentParts(content) { +@@ -28,12 +28,79 @@ export function contentParts(content) { } // In a group only its members are called; elsewhere a mention opens that Bot's chat. -export function mentionableBots(roster, sessionId) { -+// A friend's remote Bot is also called by its handle — the @ menu inserts "@" -+// and the host delivers on it — so the handle joins as a second name of the same Bot, -+// and the chip reads the display name (mentionLabel) from the roster. ++// A friend's Bot is called from any chat I can write in, and the roster knows it before a chat of ++// its own exists and inside a group it holds no seat in (#967). Two names, one Bot: the title ++// people read (what the @ menu inserts now) and the handle the host routes on (what older lines ++// carry), so both forms chip as the same mention. The click opens its chat when one exists. ++const normMentionHandle = handle => String(handle ?? '').replace(/^@+/, '').toLowerCase() ++ ++function remoteMentionableBots(roster, chats = []) { ++ const known = new Map() ++ const add = (record) => { ++ const handle = normMentionHandle(record?.remote?.handle ?? record?.handle) ++ if (handle) known.set(handle, record) ++ } ++ for (const record of Array.isArray(roster.remoteBots) ? roster.remoteBots : []) add(record) ++ for (const record of Array.isArray(chats) ? chats : []) if (typeof record?.remote?.handle === 'string') add(record) ++ const all = [...known.values()] ++ 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 } ++ return [{ ...base, name: String(handle).replace(/^@+/, '') }, { ...base, name: base.mentionLabel }] ++ }) ++} ++ +export function mentionableBots(roster, sessionId, { fromFriend = false } = {}) { const room = roster.roomsById[sessionId] - if (room) return room.members.map(id => roster.byId[id]).filter(Boolean) @@ -2722,10 +2743,9 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js + .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) })) -+ return bots.concat(humans, remotes, bots.filter(bot => typeof bot?.remote?.handle === 'string') -+ .map(bot => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ''), mentionLabel: friendBotTitle(bot) }))) -+} -+ ++ return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots)) + } + +// A face-to-face line was typed on either home, so a Bot is matched by every name the room +// host routes on (faceAliases: "SelfA's Chief" typed on B is A's own Chief on A) and the chip +// reads as the Members panel names it, "SelfA · Chief", the same on both homes (#305). @@ -2762,10 +2782,12 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js + ownerName: remote?.ownerName, + mine: roster?.self?.memberId === authorId, + } - } - ++} ++ // Runs of plain text and Bot mentions, in order. -@@ -43,6 +43,9 @@ export function userTextParts(text, bots, referenceLabels = []) { + export function userTextParts(text, bots, referenceLabels = []) { + const parts = [] +@@ -43,16 +43,25 @@ export function userTextParts(text, bots, referenceLabels = []) { if (span.start > cursor) parts.push({ text: text.slice(cursor, span.start) }) parts.push({ bot: span.bot, label: text.slice(span.start + 1, span.end) }) cursor = span.end @@ -2775,15 +2797,26 @@ diff --git a/src/client/user-message.js b/src/client/user-message.js } if (cursor < text.length) parts.push({ text: text.slice(cursor) }) return parts -@@ -51,7 +51,7 @@ export function userTextParts(text, bots, referenceLabels = []) { + } + function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { - return userTextParts(text, bots, referenceLabels).map((part, index) => (part.bot - ? h('button', { key: index, type: 'button', className: 'bt-umention', title: `@${part.bot.name}`, onClick: () => actions.openSession(part.bot.id) }, +- return userTextParts(text, bots, referenceLabels).map((part, index) => (part.bot +- ? 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(BotMark, { bot: part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`) - : h(Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, 'skill', references)))) +- : 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}`] ++ 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}` ++ // A friend's Bot the roster knows but that has no chat yet has nowhere to navigate to; the ++ // name still chips, as a mark rather than a button that would open nothing (#967). ++ return typeof part.bot.id === 'string' ++ ? h('button', { key: index, type: 'button', className: 'bt-umention', title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) ++ : h('span', { key: index, className: 'bt-umention', title }, chip(part)) ++ }) } + function Attachments({ attachments, renderMessageImages }) { @@ -87,11 +87,34 @@ export function UserMessageCell({ node, sessionId, useRoster, actions, renderMes const referenceLabels = data.referenceLabels ?? [] const references = openFile && openSkill ? { openFile, openSkill } : undefined diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index db9b2a269..7c611ba12 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -696,7 +696,7 @@ diff --git a/client.js b/client.js if (!REFUSALS.has(code)) { noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); markNoted(error); -@@ -1097,6 +1097,360 @@ +@@ -1097,6 +1097,373 @@ return result.value; }; @@ -811,14 +811,26 @@ diff --git a/client.js b/client.js + const remote = bot?.remote && typeof bot.remote === "object" ? bot.remote : {}; + return { + displayName: remote.displayName || remote.name || bot?.name, -+ ownerName: remote.ownerName || remote.owner || remote.homeName, ++ ownerName: remote.ownerName || remote.owner || remote.homeName || bot?.ownerName || bot?.owner || bot?.homeName, + handle: remote.handle ?? bot?.handle, -+ isChief: remote.isChief ++ isChief: remote.isChief ?? bot?.isChief + }; +} +function friendBotTitle(bot) { + return remoteBotTitle(friendRecord(bot)); +} ++var mentionHandleOf = (record) => String((record?.remote && typeof record.remote === "object" ? record.remote.handle : record?.handle) ?? "").replace(/^@+/, "").toLowerCase(); ++function remoteMentionLabel(record, others = []) { ++ const title = friendBotTitle(record); ++ const key = mentionHandleOf(record); ++ const clash = (others ?? []).some((other) => { ++ const otherKey = mentionHandleOf(other); ++ return otherKey !== "" && otherKey !== key && friendBotTitle(other) === title; ++ }); ++ if (!clash) return title; ++ const home = cleanPeerName(String(record?.remote?.home ?? record?.home ?? "").replace(/^@+/, ""), ""); ++ return home ? `${title} · ${home}` : title; ++} +function friendBotOwner(bot) { + return remoteOwnerBadge(friendRecord(bot)); +} @@ -985,7 +997,8 @@ diff --git a/client.js b/client.js +} +function remoteMentionRows(roster, query = "") { + const needle = String(query).trim().toLowerCase().replace(/^@/, ""); -+ return remoteMembers(roster).filter((bot) => bot.handle.toLowerCase().includes(needle) || String(bot.name ?? "").toLowerCase().includes(needle)).map((bot) => ({ remote: bot, text: `${bot.handle} ` })); ++ const all = remoteMembers(roster); ++ return all.filter((bot) => bot.handle.toLowerCase().includes(needle) || String(bot.name ?? "").toLowerCase().includes(needle)).map((bot) => ({ remote: bot, text: `@${remoteMentionLabel(bot, all)} ` })); +} +function remoteMembersByHome(roster) { + const homes = /* @__PURE__ */ new Map(); @@ -1159,7 +1172,16 @@ diff --git a/client.js b/client.js - for (const listener of [...listeners2]) listener(); - } - }; --} ++// src/client/cluster-order.js ++function clusterOrder(people2) { ++ const list = Array.isArray(people2) ? people2 : []; ++ const remote = (bot) => Boolean(bot?.remote); ++ if (!list.some(remote)) return list; ++ const locals = list.filter((bot) => !remote(bot)); ++ const remotes = list.filter(remote); ++ if (list.length <= 4) return [...remotes, ...locals]; ++ return [...locals, ...remotes]; + } -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) { @@ -1190,16 +1212,7 @@ diff --git a/client.js b/client.js - else delete own[callId]; - } - return { ...state, [sessionId]: own }; -+// src/client/cluster-order.js -+function clusterOrder(people2) { -+ const list = Array.isArray(people2) ? people2 : []; -+ const remote = (bot) => Boolean(bot?.remote); -+ if (!list.some(remote)) return list; -+ const locals = list.filter((bot) => !remote(bot)); -+ const remotes = list.filter(remote); -+ if (list.length <= 4) return [...remotes, ...locals]; -+ return [...locals, ...remotes]; - } +-} -// src/client/themes.js -var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -1347,17 +1360,18 @@ diff --git a/client.js b/client.js if (!span.bot || span.bot.id === selfId) continue; out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; cursor = span.end; -@@ -1463,6 +1463,19 @@ +@@ -1463,6 +1463,20 @@ const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; } +function nameMentions(text, roster) { + const value = String(text ?? ""); + if (!value.includes("@") || roster === null || typeof roster !== "object") return value; ++ const records = [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]; + const names = /* @__PURE__ */ new Map(); -+ for (const record of [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]) { ++ for (const record of records) { + const handle = typeof record?.remote === "object" && record.remote !== null ? record.remote.handle : record?.handle; -+ if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), record.name); ++ if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), remoteMentionLabel(record, records)); + } + const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length); + if (handles.length === 0) return value; @@ -1930,7 +1944,7 @@ diff --git a/client.js b/client.js + globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview; + globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf; + globalThis.__flybot__.inviteCountdown ??= inviteCountdown; -+ globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner }; ++ globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner, remoteMentionLabel }; + return slots; +} +var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options }); @@ -2743,17 +2757,6 @@ diff --git a/client.js b/client.js +function isGenericFaceTitle(name) { + const n = String(name ?? "").trim(); + return !n || n === "Face-to-face room"; -+} -+function formatFaceWhen(value) { -+ const date = value instanceof Date ? value : new Date(value); -+ if (!Number.isFinite(date.getTime())) return ""; -+ const hh = String(date.getHours()).padStart(2, "0"); -+ const mm = String(date.getMinutes()).padStart(2, "0"); -+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`; -+} -+function isAutoFaceTitle(name) { -+ const n = String(name ?? "").trim(); -+ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); } -var FIDGETS = ["hop", "sway", "stretch", "look"]; -function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { @@ -2784,24 +2787,58 @@ diff --git a/client.js b/client.js - if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); - const box = shapeOf(look.shape).size; - return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); --} ++function formatFaceWhen(value) { ++ const date = value instanceof Date ? value : new Date(value); ++ if (!Number.isFinite(date.getTime())) return ""; ++ const hh = String(date.getHours()).padStart(2, "0"); ++ const mm = String(date.getMinutes()).padStart(2, "0"); ++ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`; + } -function StarIcon({ size = 8 }) { - return (0, import_react2.createElement)( - "svg", - { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, - (0, import_react2.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 isAutoFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); + } -function AdminStar() { - return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); --} ++function ownersFromMembers(members) { ++ const rows = []; ++ const seen = /* @__PURE__ */ new Set(); ++ for (const member of members ?? []) { ++ const owner = String(member?.ownerName ?? member?.owner_name ?? "").trim(); ++ const key = owner.toLowerCase(); ++ if (!owner || seen.has(key)) continue; ++ seen.add(key); ++ const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); ++ rows.push({ owner, sort: handle || key }); ++ } ++ rows.sort((a, b) => a.sort.localeCompare(b.sort)); ++ return rows.slice(0, 3).map((row) => row.owner); + } -function MainStar() { - return (0, import_react2.createElement)( - "svg", - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); --} ++function uniqueOwners(owners) { ++ const seen = /* @__PURE__ */ new Set(); ++ const out = []; ++ for (const raw of owners ?? []) { ++ const owner = String(raw ?? "").trim(); ++ const key = owner.toLowerCase(); ++ if (!owner || seen.has(key)) continue; ++ seen.add(key); ++ out.push(owner); ++ } ++ out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); ++ return out.slice(0, 3); + } -function useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); @@ -2818,7 +2855,9 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; --} ++function joinCodeOf(code) { ++ return String(code ?? "").replace(/\D/g, "").slice(0, 6); + } -function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { - const settled = useSettled(state); - if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); @@ -2832,7 +2871,13 @@ diff --git a/client.js b/client.js - badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, - badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null - ); --} ++var FACE_JOIN_WINDOW_MS = 10 * 6e4; ++function faceRoomCreatedAt({ createdAt, expiresAt, startedAt } = {}) { ++ if (createdAt && Number.isFinite(Date.parse(createdAt))) return createdAt; ++ const deadline = Date.parse(expiresAt); ++ if (Number.isFinite(deadline)) return new Date(deadline - FACE_JOIN_WINDOW_MS).toISOString(); ++ return startedAt || void 0; + } -function clusterSeats(count, frame) { - if (count === 1) return [[0, 0, frame]]; - if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; @@ -2840,76 +2885,6 @@ diff --git a/client.js b/client.js - const size = frame * 5 / 9; - const rest = frame - size; - return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -+function ownersFromMembers(members) { -+ const rows = []; -+ const seen = /* @__PURE__ */ new Set(); -+ for (const member of members ?? []) { -+ const owner = String(member?.ownerName ?? member?.owner_name ?? "").trim(); -+ const key = owner.toLowerCase(); -+ if (!owner || seen.has(key)) continue; -+ seen.add(key); -+ const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); -+ rows.push({ owner, sort: handle || key }); - } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; -+ rows.sort((a, b) => a.sort.localeCompare(b.sort)); -+ return rows.slice(0, 3).map((row) => row.owner); - } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); -- } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); -+function uniqueOwners(owners) { -+ const seen = /* @__PURE__ */ new Set(); -+ const out = []; -+ for (const raw of owners ?? []) { -+ const owner = String(raw ?? "").trim(); -+ const key = owner.toLowerCase(); -+ if (!owner || seen.has(key)) continue; -+ seen.add(key); -+ out.push(owner); -+ } -+ out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); -+ return out.slice(0, 3); -+} -+function joinCodeOf(code) { -+ return String(code ?? "").replace(/\D/g, "").slice(0, 6); -+} -+var FACE_JOIN_WINDOW_MS = 10 * 6e4; -+function faceRoomCreatedAt({ createdAt, expiresAt, startedAt } = {}) { -+ if (createdAt && Number.isFinite(Date.parse(createdAt))) return createdAt; -+ const deadline = Date.parse(expiresAt); -+ if (Number.isFinite(deadline)) return new Date(deadline - FACE_JOIN_WINDOW_MS).toISOString(); -+ return startedAt || void 0; -+} +function formatFaceRoomTitle({ name, owners, members, code, at } = {}) { + const named = String(name ?? "").trim(); + if (named && !isAutoFaceTitle(named)) return named; @@ -2942,11 +2917,25 @@ diff --git a/client.js b/client.js + const room2 = roster.roomsById?.[sessionId]; + if (bot?.remote) { + return friendBotTitle(bot); -+ } + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return roomTitle(room2); + return bot?.name || ""; -+} + } +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +function installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -2976,7 +2965,25 @@ diff --git a/client.js b/client.js + link.rel = "icon"; + link.type = "image/svg+xml"; + document.head.appendChild(link); -+ } + } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) +- ); + link.href = href; + const retitle = () => { + const next = brandTitle(document.title); @@ -4119,8 +4126,8 @@ diff --git a/client.js b/client.js - texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null + reasoning ? (0, import_react8.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react8.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles -+ ); -+} + ); + } +function HeldReply({ held, roster, sessionId, actions, closing, children }) { + const bot = roster.byId[sessionId]; + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); @@ -4139,8 +4146,8 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react8.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react8.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) - ); - } ++ ); ++} function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { if (whole && bots.length === 1) { - return (0, import_react6.createElement)( @@ -4306,8 +4313,8 @@ diff --git a/client.js b/client.js - (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react8.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); -+} + ); + } +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; @@ -4351,8 +4358,8 @@ diff --git a/client.js b/client.js + `📎 ${file.name} · ${fileSize(file.size)}` + )), + error ? (0, import_react8.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null - ); - } ++ ); ++} function GroupEvent({ prefix, room: room2, name, roster, actions }) { - return (0, import_react6.createElement)( + return (0, import_react8.createElement)( @@ -4648,10 +4655,10 @@ diff --git a/client.js b/client.js if (!tool) return t("Thinking about it"); - return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); -+} + } +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; - } ++} function useLiveNote(actions, sessionId, id, entry) { const key = entry ? JSON.stringify(entry) : ""; - (0, import_react6.useEffect)(() => { @@ -4947,16 +4954,16 @@ diff --git a/client.js b/client.js - return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react8.createElement)(import_react8.Fragment, null, drawn.map((call4, index) => (0, import_react8.createElement)(import_react8.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); -+} -+ + } + +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; +function awaitingReply(roster, sessionId, now = Date.now()) { + const at = roster?.remoteAwaiting?.[sessionId]; + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; - } - ++} ++ // src/client/activity-row.js -var import_react7 = require("react"); +var import_react9 = require("react"); @@ -6010,7 +6017,7 @@ diff --git a/client.js b/client.js ) : null, - wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + unreadMark -+ ); + ); + const settingsLabel = bot.remote ? t("Bot settings") : t("Edit Bot"); + const settings = wide ? (0, import_react13.createElement)("button", { + type: "button", @@ -6026,7 +6033,7 @@ diff --git a/client.js b/client.js + }, (0, import_react13.createElement)(GearIcon)) : null; + if (settings) return (0, import_react13.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings); + return row; -+} + } +function keepSidebarStill(node) { + const reset = () => { + for (let at = node?.parentElement; at; at = at.parentElement) if (at.scrollLeft > 0) at.scrollLeft = 0; @@ -6066,8 +6073,8 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), + (0, import_react13.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) + ) - ); - } ++ ); ++} function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { const id = room2.id; - const fallback = useSessions((list) => previewOf(list, id)); @@ -6810,15 +6817,14 @@ diff --git a/client.js b/client.js - (0, import_react9.createElement)("span", { className: "bt-pill-name" }, bot?.name ?? room2?.name ?? (title || t("New chat"))), - (0, import_react9.createElement)(MemoryNotice, { shown: memoryNotice }), - (0, import_react9.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react9.createElement)(GoArrow)) -- ), -- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), -- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), -- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) + bot ? (0, import_react14.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react14.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, + (0, import_react14.createElement)("span", { className: "bt-pill-name" }, name || title || t("New chat")), + (0, import_react14.createElement)(MemoryNotice, { shown: memoryNotice }), + (0, import_react14.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react14.createElement)(GoArrow)) -+ ), + ), +- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), +- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), +- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) + (0, import_react14.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), + (0, import_react14.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), + (0, import_react14.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) @@ -7233,7 +7239,7 @@ diff --git a/client.js b/client.js function chatRows(roster, list, sessionId) { const here = conversationOf(roster, sessionId); if (here === null) return []; -@@ -5959,14 +5959,30 @@ +@@ -5959,14 +5959,31 @@ order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -7244,9 +7250,10 @@ diff --git a/client.js b/client.js + const snapshot = roster.getSnapshot(); + const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]); + const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({ -+ // A friend's Bot by its one name, "Chief (Bob)"; its handle is the second line (#384). A seat -+ // carries its own name; a friend's chat is named as its sidebar row and header (#498). -+ name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? remoteBotTitle({ ...row.remote, displayName: row.remote.name, ownerName: row.remote.owner || row.remote.homeName }) : t("Everyone"), ++ // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the ++ // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar ++ // row and header (#498). ++ name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"), + ...row.bot?.role && !face ? { description: row.bot.role } : {}, + ...row.remote ? { description: row.remote.handle } : {}, + ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}, @@ -7806,8 +7813,8 @@ diff --git a/client.js b/client.js - error ? (0, import_react13.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null + (0, import_react16.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), + error ? (0, import_react16.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null -+ ); -+} + ); + } +function RemoteGoneDock({ bot }) { + return (0, import_react16.createElement)( + "div", @@ -7826,8 +7833,8 @@ diff --git a/client.js b/client.js + ) + ) + ) - ); - } ++ ); ++} function setupChoices(actions) { return [ actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null, @@ -8473,11 +8480,11 @@ diff --git a/client.js b/client.js publish(next); setAdding(false); + setEditing(null); -+ }; + }; + const edit = (task) => { + setAdding(false); + setEditing(task); - }; ++ }; const byChat = [ - ...roster.bots.map((bot) => [bot.id, (0, import_react15.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), - ...roster.rooms.map((room2) => [room2.id, (0, import_react15.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) @@ -10815,7 +10822,7 @@ diff --git a/client.js b/client.js - (0, import_react20.createElement)( + menuOnly ? null : (0, import_react28.createElement)("div", { className: "bt-nc-spacer" }), + error && fix ? (0, import_react28.createElement)( -+ "div", + "div", + { className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" }, + (0, import_react28.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })), + (0, import_react28.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [ @@ -10828,7 +10835,7 @@ diff --git a/client.js b/client.js + inviteLink ? (0, import_react28.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null + ) : error ? (0, import_react28.createElement)("div", { className: "bt-error" }, error) : null, + menuOnly ? null : (0, import_react28.createElement)( - "div", ++ "div", { className: "bt-nc-compose" }, - (0, import_react20.createElement)( + (0, import_react28.createElement)( @@ -11039,7 +11046,7 @@ diff --git a/client.js b/client.js const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); const level = speech ? serviceLevel : micLevel; const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -@@ -8875,291 +8875,47 @@ +@@ -8875,292 +8875,48 @@ const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -11095,10 +11102,12 @@ diff --git a/client.js b/client.js onClick: togglePause - }, (0, import_react22.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react22.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), - (0, import_react22.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react22.createElement)(CloseIcon)) -- ) -- ); --} -- ++ }, (0, import_react30.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react30.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), ++ (0, import_react30.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react30.createElement)(CloseIcon)) + ) + ); + } + -// src/client/group-settings.js -var import_react23 = require("react"); -var ROOM_MODES = [ @@ -11253,11 +11262,9 @@ diff --git a/client.js b/client.js - ), - (0, import_react23.createElement)("span", { className: "bt-chevron" }, (0, import_react23.createElement)(ChevronRightIcon)) - ) -+ }, (0, import_react30.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react30.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), -+ (0, import_react30.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react30.createElement)(CloseIcon)) - ) - ); - } +- ) +- ); +-} -function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { - const [admin, setAdmin] = (0, import_react23.useState)(null); - const [members, setMembers] = (0, import_react23.useState)([]); @@ -11344,9 +11351,10 @@ diff --git a/client.js b/client.js -.bt-bs-stack .bt-seg{align-self:stretch} -@media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}} -`; - +- // src/client/usage-data.js var HOUR = 0; + var OWNER = 1; @@ -9277,13 +9277,13 @@ } return result; @@ -13113,7 +13121,7 @@ diff --git a/client.js b/client.js const mainCount = (roster.mainBotIds ?? []).length; - const groups = [ + const groups = bot?.remote ? [ -+ [ + [ + [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] + ], + [ @@ -13122,7 +13130,7 @@ diff --git a/client.js b/client.js + pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]), + [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]] + ] : [ - [ ++ [ isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] ], @@ -13588,13 +13596,16 @@ diff --git a/client.js b/client.js useMorph(body, open); - return (0, import_react31.createElement)( + return (0, import_react37.createElement)( -+ "div", -+ null, + "div", + null, +- (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), +- (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), +- long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null + (0, import_react37.createElement)("div", { className: "bt-section-title" }, t(title)), + (0, import_react37.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), + long ? (0, import_react37.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null -+ ); -+} + ); + } +function SharedRoomApprovals({ room: room2 }) { + const [items, setItems] = (0, import_react37.useState)(room2.pendingApprovals ?? []); + const [busy, setBusy] = (0, import_react37.useState)(""); @@ -13642,11 +13653,8 @@ diff --git a/client.js b/client.js + const people2 = facePeople(room2, roster); + const pick2 = (bot) => call("social/face-lead", { roomId: room2.id, bot }); + return (0, import_react37.createElement)( - "div", - null, -- (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), -- (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), -- long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null ++ "div", ++ null, + (0, import_react37.createElement)(FaceLead, { room: room2, roster, pick: pick2 }), + (0, import_react37.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })), + (0, import_react37.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react37.createElement)(FacePersonRow, { @@ -13656,8 +13664,8 @@ diff --git a/client.js b/client.js + onOpenBot: (id) => actions.openSession(id), + lead: room2.face?.lead?.handle + }))) - ); - } ++ ); ++} function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) { + const live = room2.presentedParticipants ?? room2.participants ?? []; + const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0); @@ -13676,7 +13684,7 @@ diff --git a/client.js b/client.js - (0, import_react31.createElement)( + (0, import_react37.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), + invited && room2.sharedFrom && !face ? (0, import_react37.createElement)( -+ "div", + "div", + { key: "from", className: "bt-shared-from" }, + (0, import_react37.createElement)("div", { className: "bt-section-title" }, title), + (0, import_react37.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })), @@ -13685,7 +13693,7 @@ diff --git a/client.js b/client.js + ) : face ? null : room2.notice ? (0, import_react37.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react37.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), + face ? null : (0, import_react37.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), + face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react37.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react37.createElement)( - "div", ++ "div", { key: "members" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length })), - (0, import_react31.createElement)("div", { className: "bt-members", role: "list" }, members.map((member) => { @@ -14616,11 +14624,28 @@ 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,41 @@ +@@ -13119,11 +13119,59 @@ } return { text: texts2.join("\n"), attachments, rest }; } -function mentionableBots(roster, sessionId) { ++var normMentionHandle = (handle) => String(handle ?? "").replace(/^@+/, "").toLowerCase(); ++function remoteMentionableBots(roster, chats = []) { ++ const known = /* @__PURE__ */ new Map(); ++ const add = (record) => { ++ const handle = normMentionHandle(record?.remote?.handle ?? record?.handle); ++ if (handle) known.set(handle, record); ++ }; ++ for (const record of Array.isArray(roster.remoteBots) ? roster.remoteBots : []) add(record); ++ for (const record of Array.isArray(chats) ? chats : []) if (typeof record?.remote?.handle === "string") add(record); ++ const all = [...known.values()]; ++ 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 }; ++ return [{ ...base, name: String(handle).replace(/^@+/, "") }, { ...base, name: base.mentionLabel }]; ++ }); ++} +function mentionableBots(roster, sessionId, { fromFriend = false } = {}) { const room2 = roster.roomsById[sessionId]; - if (room2) return room2.members.map((id) => roster.byId[id]).filter(Boolean); @@ -14629,8 +14654,8 @@ diff --git a/client.js b/client.js + 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) })); -+ return bots.concat(humans, remotes, bots.filter((bot) => typeof bot?.remote?.handle === "string").map((bot) => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ""), mentionLabel: friendBotTitle(bot) }))); -+} ++ return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots)); + } +function faceMentionables(room2, roster, fromFriend) { + const humans = (room2.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })); + const all = faceSeats(room2, roster, { left: true }); @@ -14658,9 +14683,10 @@ diff --git a/client.js b/client.js + ownerName: remote?.ownerName, + mine: roster?.self?.memberId === authorId + }; - } ++} function userTextParts(text, bots, referenceLabels = []) { const parts = []; + let cursor = 0; @@ -13132,43 +13132,44 @@ if (span.start > cursor) parts.push({ text: text.slice(cursor, span.start) }); parts.push({ bot: span.bot, label: text.slice(span.start + 1, span.end) }); @@ -14672,15 +14698,17 @@ diff --git a/client.js b/client.js } function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { - return userTextParts(text, bots, referenceLabels).map((part, index) => part.bot ? (0, import_react34.createElement)( -+ return userTextParts(text, bots, referenceLabels).map((part, index) => part.bot ? (0, import_react41.createElement)( - "button", - { key: index, type: "button", className: "bt-umention", title: `@${part.bot.name}`, onClick: () => actions.openSession(part.bot.id) }, +- "button", +- { key: index, type: "button", className: "bt-umention", title: `@${part.bot.name}`, onClick: () => actions.openSession(part.bot.id) }, - (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))); -+ (0, import_react41.createElement)(BotMark, { bot: part.bot, size: 16 }), -+ `@${part.bot.mentionLabel ?? part.label}` -+ ) : (0, import_react41.createElement)(import_react41.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references))); ++ const chip = (part) => [(0, import_react41.createElement)(BotMark, { bot: part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; ++ return userTextParts(text, bots, referenceLabels).map((part, index) => { ++ if (!part.bot) return (0, import_react41.createElement)(import_react41.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); ++ const title = `@${part.bot.name}`; ++ return typeof part.bot.id === "string" ? (0, import_react41.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react41.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); ++ }); } function Attachments({ attachments, renderMessageImages }) { const compact2 = attachments.length > 1; @@ -14861,8 +14889,8 @@ diff --git a/client.js b/client.js + off(); + timers.clearTimeout?.(timer); + }; -+} -+ + } + +// src/client/stats-dock.js +var import_react42 = require("react"); +var STATS_DOCK = "conversation.composer.dock"; @@ -14886,9 +14914,9 @@ diff --git a/client.js b/client.js + return (0, import_react42.createElement)(entry.component, { ...props, t: t2 }); + } + return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }; - } ++} +var statsDocks = (entriesOf) => STATS_IDS.map((id) => ({ id, ...statsDock(entriesOf, id) })); - ++ // src/client/index.js var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 37c6a9ae4..4bc2d280c 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -1790,7 +1790,7 @@ diff --git a/test/at-menu.test.js b/test/at-menu.test.js test('"@" offers a group\'s members and everyone, or every other Bot in a Bot\'s chat', () => { assert.deepEqual(names(mentionRows(roster, 'room')), ['Chief', 'King', 'everyone']) assert.deepEqual(names(mentionRows(roster, 'chief-2')), ['King', 'Writer']) -@@ -76,3 +76,113 @@ test('the chats join only the menu that "+" opened, and a pick inserts a session +@@ -76,3 +76,116 @@ test('the chats join only the menu that "+" opened, and a pick inserts a session assert.deepEqual(bots.onPick({ candidate: mentions[1] }), { text: '@King ' }) assert.deepEqual(await bots.candidates({ sessionId: 'plain' }, { query: '' }), []) }) @@ -1843,7 +1843,7 @@ diff --git a/test/at-menu.test.js b/test/at-menu.test.js + assert.deepEqual(mentionRows(group, 'face', 'chief.selfa').map(row => row.text), ['@Chief (SelfA) ']) +}) + -+test('#384: a friend\'s Bot with its own chat is offered once in a Bot\'s chat, by its handle, named Bot (owner)', async () => { ++test('#384: a friend\'s Bot with its own chat is offered once in a Bot\'s chat, by its handle, under its one name (owner)', async () => { + const remote = { handle: '@coder.bob.home', name: 'Coder', home: '@bob.home', owner: 'Bob', homeName: 'Family Workspace' } + const chat = indexRoster({ + bots: [ @@ -1856,7 +1856,10 @@ diff --git a/test/at-menu.test.js b/test/at-menu.test.js + }) + const rows = mentionRows(chat, 'chief') + assert.equal(rows.filter(row => (row.remote ?? row.bot?.remote)?.handle === '@coder.bob.home').length, 1) -+ assert.deepEqual(rows.map(row => row.text), ['@Helper ', '@coder.bob.home ']) ++ // The row still belongs to that handle, but it inserts the name the bubble and the sidebar preview ++ // show, so the token the user reads is not the transport key (#967). ++ assert.deepEqual(rows.map(row => row.text), ['@Helper ', '@Coder (Bob) ']) ++ assert.deepEqual(rows.filter(row => row.text === '@Coder (Bob) ').map(row => row.remote.handle), ['@coder.bob.home']) + const [bots] = atSources({ + roster: { getSnapshot: () => chat }, + sessions: { list: { getSnapshot: () => ({ ids: [], byId: {} }) } }, diff --git a/patches/ds-bot/files/src/client/flybot-registry.js b/patches/ds-bot/files/src/client/flybot-registry.js index 3f88eef1f..51a246cd9 100644 --- a/patches/ds-bot/files/src/client/flybot-registry.js +++ b/patches/ds-bot/files/src/client/flybot-registry.js @@ -1,5 +1,6 @@ // The FlyBot slot registry, free of React so the "@" menu and tests can use it. // See flybot-slots.js for what each slot carries. +import { remoteMentionLabel } from '../shared/remote-label.js' export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page'] export function createRegistry() { @@ -132,13 +133,15 @@ export function remoteMembers(roster) { return Array.isArray(roster?.remoteBots) ? roster.remoteBots.filter(bot => typeof bot?.handle === 'string') : [] } -// "@query" rows for remote members: picking one inserts "@handle ", which the host sends -// through bot.send (so the social plugin's approval still applies). +// "@query" rows for remote members: picking one inserts the name people read ("@Coder (Bob) ", +// #967), which the host matches back to the handle it routes on and sends through bot.send (so the +// social plugin's approval still applies). export function remoteMentionRows(roster, query = '') { const needle = String(query).trim().toLowerCase().replace(/^@/, '') - return remoteMembers(roster) + const all = remoteMembers(roster) + return all .filter(bot => bot.handle.toLowerCase().includes(needle) || String(bot.name ?? '').toLowerCase().includes(needle)) - .map(bot => ({ remote: bot, text: `${bot.handle} ` })) + .map(bot => ({ remote: bot, text: `@${remoteMentionLabel(bot, all)} ` })) } // Remote members grouped by their home, for the sidebar. diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 7c737739e..93283c6c2 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -28,12 +28,13 @@ // // The same registry object also carries the host Bot look so a plugin can draw a Bot // exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf, and name a friend's Bot -// or its owner the same way: __flybot__.names.friendBotTitle / friendBotOwner (set in registry()). +// or its owner the same way: __flybot__.names.friendBotTitle / friendBotOwner / remoteMentionLabel +// (set in registry()). // // Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry). import { Component, createElement as h, Fragment, useEffect, useState } from 'react' import { call as callHost } from './api.js' -import { friendBotOwner, friendBotTitle, isListedRemoteChat } from '../shared/remote-label.js' +import { friendBotOwner, friendBotTitle, isListedRemoteChat, remoteMentionLabel } from '../shared/remote-label.js' import { registry as baseRegistry } from './flybot-registry.js' import { BotAvatar, BotMark, lookOf } from './mark.js' import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from './room-invite-preview.js' @@ -72,7 +73,9 @@ export function registry() { globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf globalThis.__flybot__.inviteCountdown ??= inviteCountdown // The same for a friend's Bot's one name and its owner (#498), so the social plugin says it as the sidebar does. - globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner } + // `remoteMentionLabel` is the one name a friend Bot mention wears (#967), so a plugin row for a + // friend the core does not list yet inserts the name the bubble and the preview will chip. + globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner, remoteMentionLabel } return slots } diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 0edc8b322..bb2b6226a 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -14,7 +14,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { isApprovalWaiting } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' import { mentionSpans } from '../shared/mentions.js' -import { isHomeLevelRemote } from '../shared/remote-label.js' +import { isHomeLevelRemote, remoteMentionLabel } from '../shared/remote-label.js' import { cleanName, stripSessionChips } from './delivery.js' import { SOURCE_KIND } from './constants.js' import { currentParticipants, isFaceRoom } from './room-membership.js' @@ -890,10 +890,13 @@ export function install(rt) { const from = room ? (room.admin ?? room.members?.[0]) : rt.selfOf(sessionId) if (from === undefined || rt.botOf(from) === undefined) return [] // The same "@" rule as the team (code, links and quoted lines are literal), over the - // friends' Bots by handle and by chat name; a menu chip names the chat directly. + // friends' Bots by the name the @ menu inserts (#967), by the handle older lines carry, and by + // chat name; a menu chip names the chat directly. Every alias routes to the one handle. const handles = new Set() const quoted = text.replace(/^[ \t]*>.*$/gm, line => ' '.repeat(line.length)) - const names = [...known.values()].flatMap(bot => [ + const remoteList = [...known.values()] + const names = remoteList.flatMap(bot => [ + { name: remoteMentionLabel(bot, remoteList), handle: bot.handle }, { name: bot.handle.slice(1), handle: bot.handle }, ...Object.values(state.bots).filter(chat => chat.remote?.handle === bot.handle && chat.name).map(chat => ({ name: chat.name, handle: bot.handle })), ]) diff --git a/patches/ds-bot/files/src/shared/remote-label.js b/patches/ds-bot/files/src/shared/remote-label.js index 8cc588abf..2b2d5da67 100644 --- a/patches/ds-bot/files/src/shared/remote-label.js +++ b/patches/ds-bot/files/src/shared/remote-label.js @@ -144,14 +144,16 @@ export function remoteOwnerBadge(record) { } // The record those two read for a friend's Bot as the roster lists it: its chat (`bot.remote` from -// remote-chat.js, named by the chat) or a seat that carries its own name and owner. +// remote-chat.js, named by the chat) or a published roster record (roster.remoteBots, which carries +// the name, owner and home at the top level and `remote: true`). Reading both shapes here is what +// keeps a Bot's name the same before and after its chat exists (#498, #967). function friendRecord(bot) { const remote = bot?.remote && typeof bot.remote === 'object' ? bot.remote : {} return { displayName: remote.displayName || remote.name || bot?.name, - ownerName: remote.ownerName || remote.owner || remote.homeName, + ownerName: remote.ownerName || remote.owner || remote.homeName || bot?.ownerName || bot?.owner || bot?.homeName, handle: remote.handle ?? bot?.handle, - isChief: remote.isChief, + isChief: remote.isChief ?? bot?.isChief, } } @@ -161,6 +163,24 @@ export function friendBotTitle(bot) { return remoteBotTitle(friendRecord(bot)) } +const mentionHandleOf = record => String((record?.remote && typeof record.remote === 'object' ? record.remote.handle : record?.handle) ?? '').replace(/^@+/, '').toLowerCase() + +// The one name a friend's Bot wears where a mention is shown or picked (#967): the same title as +// its sidebar row (#498), so the `@` menu, the bubble and the preview never disagree. Two friends +// may publish a Bot with the same name, so a title that two different handles share carries the +// home: one name still names one Bot at the transport, and the handle stays the routing key. +export function remoteMentionLabel(record, others = []) { + const title = friendBotTitle(record) + const key = mentionHandleOf(record) + const clash = (others ?? []).some((other) => { + const otherKey = mentionHandleOf(other) + return otherKey !== '' && otherKey !== key && friendBotTitle(other) === title + }) + if (!clash) return title + const home = cleanPeerName(String(record?.remote?.home ?? record?.home ?? '').replace(/^@+/, ''), '') + return home ? `${title} · ${home}` : title +} + // The person who owns a friend's Bot: who a held reply to that Bot goes to (#418, #498). export function friendBotOwner(bot) { return remoteOwnerBadge(friendRecord(bot)) diff --git a/patches/ds-bot/files/test/flybot-client.test.js b/patches/ds-bot/files/test/flybot-client.test.js index 2b9a2763c..4958cfebc 100644 --- a/patches/ds-bot/files/test/flybot-client.test.js +++ b/patches/ds-bot/files/test/flybot-client.test.js @@ -1,7 +1,39 @@ 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 { createClientApi, createRegistry, mentionCandidates, newChatActionRows, registry, SLOTS } from '../src/client/flybot-registry.js' +// A client module that imports React cannot be loaded as plain ESM here (the platform packages come +// from a DSH install, not from this checkout), so bundle it the way the other client tests do +// (test/face-person-first.test.js) and hand it a small stand-in for the platform modules. +async function loadClientModule(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 +} + test('the client registry orders entries, replaces by id, and notifies', () => { const slots = createRegistry() let ticks = 0 @@ -86,15 +118,16 @@ test('the browser flybot service lists Bots and turns host changes into field ev assert.throws(() => api.fields.on('nope', () => {}), /unknown event/) }) -test('remote members: @ rows insert the handle, the sidebar groups by home, bubbles show the fenced words', async () => { +test('remote members: @ rows insert the one name, the sidebar groups by home, bubbles show the fenced words', async () => { const { remoteMentionRows, remoteMembersByHome, remoteMessageText } = await import('../src/client/flybot-registry.js') const { mentionRows } = await import('../src/client/at-menu.js') const roster = { ready: true, bots: [{ id: 'a', name: 'Chief' }, { id: 'b', name: 'Writer' }], byId: { a: { id: 'a', name: 'Chief' }, b: { id: 'b', name: 'Writer' } }, roomsById: {}, rooms: [], remoteBots: [{ handle: '@coder.bob.home', name: 'Coder', home: '@bob.home', homeName: 'Bob', remote: true }, { handle: '@x.eve.home', name: 'X', home: '@eve.home', remote: true }], } - assert.deepEqual(remoteMentionRows(roster, 'cod').map(row => row.text), ['@coder.bob.home ']) - assert.deepEqual(mentionRows(roster, 'a', 'coder').map(row => row.text), ['@coder.bob.home ']) + // #967: the menu inserts the name people read ("Coder (Bob)"), not the handle it routes on. + assert.deepEqual(remoteMentionRows(roster, 'cod').map(row => row.text), ['@Coder (Bob) ']) + assert.deepEqual(mentionRows(roster, 'a', 'coder').map(row => row.text), ['@Coder (Bob) ']) assert.deepEqual(remoteMembersByHome(roster).map(home => [home.home, home.name, home.bots.length]), [['@bob.home', 'Bob', 1], ['@eve.home', '@eve.home', 1]]) assert.equal(remoteMessageText('\nLGTM\n'), 'LGTM') const { failureLabel } = await import('../src/client/diagnostics.js') @@ -227,12 +260,75 @@ test("a friend's Bot sidebar row is its one name, \"Chief (Bob)\", on one line, assert.match(row, /const secondLine = awayLine \|\| preview \|\| \(remote \? \(bot\.remote\?\.gone \? t\(.no longer shared.\) : t\("Friend's Bot"\)\) : ''\)/) // The full name is on hover and in the label. assert.match(row, /title: remote \? shown :/) - for (const file of ['header.js', 'brand.js', 'user-message.js', 'at-menu.js']) { + // flybot-slots.js publishes the same formatter to plugins (__flybot__.names), so a plugin row + // inserts the name the bubble chips (#967). + for (const file of ['header.js', 'brand.js', 'user-message.js', 'at-menu.js', 'flybot-slots.js']) { const src = readFileSync(new URL(`../src/client/${file}`, import.meta.url), 'utf8') - assert.match(src, /friendBotTitle\(/, `${file} names a friend's chat through friendBotTitle`) + // #967: a mention reads the same label through remoteMentionLabel, which itself reads + // friendBotTitle, so the menu, the bubble and the preview cannot drift apart. + assert.match(src, /\bfriendBotTitle\b|\bremoteMentionLabel\b/, `${file} names a friend's Bot through the one formatter`) assert.doesNotMatch(src, /ownerName: bot\.remote\.homeName \|\| bot\.remote\.owner/, `${file} builds no name of its own`) } assert.match(styles, /\.bt-row-remote\{[^}]*color:var\(--bt-ink-2\)\}/) const { DICTIONARY } = await import('../src/client/i18n.js') assert.equal(DICTIONARY.zh["Friend's Bot"], '好友的 Bot') }) + +test("a friend's Bot picked from the @ menu reads as its one name in the menu, the bubble and the preview (#967)", async () => { + const { remoteMentionRows } = await import('../src/client/flybot-registry.js') + const { mentionableBots, userTextParts } = await loadClientModule('user-message.js') + const { nameMentions } = await import('../src/client/text.js') + const { atSources } = await import('../src/client/at-menu.js') + // The reported shape: the friend's Bot is on the roster, and no chat of its own exists yet, so + // the bubble used to know no name for it and DSH drew "@coder.bob.home" as a file (bare handle). + const roster = { + ready: true, bots: [{ id: 'a', name: 'Chief' }], byId: { a: { id: 'a', name: 'Chief' } }, roomsById: {}, rooms: [], + remoteBots: [{ handle: '@coder.bob.home', name: 'Coder', home: '@bob.home', homeName: 'Bob', remote: true }], + } + assert.deepEqual(remoteMentionRows(roster, 'cod').map(row => row.text), ['@Coder (Bob) ']) + const bots = mentionableBots(roster, 'a') + const parts = userTextParts('@Coder (Bob) hello', bots) + assert.equal(parts.length, 2) + assert.equal(parts[0].bot.mentionLabel, 'Coder (Bob)') + assert.equal(`@${parts[0].bot.mentionLabel}`, '@Coder (Bob)') + assert.equal(parts[1].text, ' hello') + // The line the menu used to insert still chips as the same Bot, one mention, not raw text. + const legacy = userTextParts('@coder.bob.home hello', bots) + assert.equal(legacy.length, 2) + assert.equal(legacy[0].bot.mentionLabel, 'Coder (Bob)') + assert.equal(legacy[1].text, ' hello') + // One vocabulary: the sidebar preview renders the same words for both forms. + assert.equal(nameMentions('@Coder (Bob) hello', roster), '@Coder (Bob) hello') + assert.equal(nameMentions('@coder.bob.home hello', roster), '@Coder (Bob) hello') + // The menu row shows what it inserts, so picking it is never a surprise. + const source = (await atSources({ + roster: { getSnapshot: () => roster }, + sessions: { list: { getSnapshot: () => ({ byId: {}, ids: [] }) } }, + launched: () => false, + iconFor: () => 'bot', + }))[0] + const row = (await source.candidates({ sessionId: 'a' }, { query: 'cod' }))[0] + assert.equal(row.name, 'Coder (Bob)') + assert.equal(row.value, '@Coder (Bob) ') + assert.equal(row.description, '@coder.bob.home') + // Two friends may publish a Bot with the same name: the label keeps them apart, and each still + // names its own handle at the transport. + const clash = { + ...roster, + remoteBots: [ + { handle: '@chief.bob.home', name: 'Chief', home: '@bob.home', homeName: 'Bob', remote: true }, + { handle: '@chief.ann.home', name: 'Chief', home: '@ann.home', homeName: 'Ann', remote: true }, + ], + } + const labels = remoteMentionRows(clash, 'chief').map(row => row.text) + assert.notEqual(labels[0], labels[1]) + const both = mentionableBots(clash, 'a') + assert.equal(userTextParts(labels[0], both)[0].bot.handle, '@chief.bob.home') + assert.equal(userTextParts(labels[1], both)[0].bot.handle, '@chief.ann.home') + assert.equal(nameMentions(labels[0].trim(), clash), labels[0].trim()) + // Not a mention: a bare handle typed inside a message stays exactly as typed (the raw path is + // not removed, and nothing is silently rewritten), and plain text is untouched. + assert.equal(nameMentions('mail me at coder.bob.home', roster), 'mail me at coder.bob.home') + // The bundled module builds its arrays in another realm, so compare their values, not their prototypes. + assert.deepEqual(JSON.parse(JSON.stringify(userTextParts('plain words', bots).map(part => part.text))), ['plain words']) +}) diff --git a/patches/ds-bot/files/test/flybot.test.js b/patches/ds-bot/files/test/flybot.test.js index dcbf7dbf6..ea6718bfc 100644 --- a/patches/ds-bot/files/test/flybot.test.js +++ b/patches/ds-bot/files/test/flybot.test.js @@ -474,6 +474,12 @@ test('mentions (#13): a friend\'s Bot named with @ gets the message once; code, ctx.emitArgs('session/event', { id: 'a' }, { type: 'user/message', seq: 1, data: { source: { kind: 'user' }, content: [{ type: 'text', text: '@helper.friend.home 你好' }] } }) await new Promise(resolve => setTimeout(resolve, 30)) assert.equal(out.length, 1) + // #967: the @ menu inserts the name people read, "助手 (friend)", not the handle; it routes to the + // same handle the row carried, so the transport still has one unambiguous key. + await say('@助手 (friend) 你好') + assert.equal(out.length, 2) + assert.equal(out[1].to.handle, '@helper.friend.home') + assert.equal(out[1].via, 'user-mention') }) test('mentions in a group (#80, #81): the room gets a receipt — sent points to the friend\'s chat, a hold or failure says why', async (t) => { diff --git a/patches/ds-bot/files/test/inbound-ui.test.js b/patches/ds-bot/files/test/inbound-ui.test.js index a3465f8af..b50bb2277 100644 --- a/patches/ds-bot/files/test/inbound-ui.test.js +++ b/patches/ds-bot/files/test/inbound-ui.test.js @@ -64,10 +64,12 @@ test("an inbound friend's message uses the friend's sidebar Bot (avatar, name), assert.equal(other.chatId, undefined) }) -test("a friend Bot's handle in a preview reads as its display name (#71)", () => { +test("a friend Bot's handle in a preview reads as its one name (#71, #967)", () => { const roster = { bots: [{ id: 'f', name: '代码审查官', remote: { handle: '@bot-2a758c0f.danyang.home' } }], remoteBots: [{ handle: '@bot-2a758c0f.danyang.home', name: '代码审查官' }] } - assert.equal(nameMentions('@bot-2a758c0f.danyang.home 你是?', roster), '@代码审查官 你是?') - assert.equal(nameMentions('看 @bot-2a758c0f.danyang.home。', roster), '看 @代码审查官。') + // #967: the same words as the sidebar row and the bubble -- name, then owner -- so a preview is + // never a third vocabulary for the Bot (the row for this chat already reads 代码审查官 (danyang)). + assert.equal(nameMentions('@bot-2a758c0f.danyang.home 你是?', roster), '@代码审查官 (danyang) 你是?') + assert.equal(nameMentions('看 @bot-2a758c0f.danyang.home。', roster), '看 @代码审查官 (danyang)。') // An address before the @, or a longer word after the handle, stays as written. assert.equal(nameMentions('mail a@bot-2a758c0f.danyang.home', roster), 'mail a@bot-2a758c0f.danyang.home') assert.equal(nameMentions('@bot-2a758c0f.danyang.home2 不在', roster), '@bot-2a758c0f.danyang.home2 不在') @@ -80,7 +82,7 @@ test("a friend Bot's handle in a preview reads as its display name (#71)", () => { handle: '@b.x.home', name: '请找 @a.x.home 的主人' }, { handle: '@a.x.home', name: '代码审查官' }, ] } - assert.equal(nameMentions('@b.x.home 你在?', nested), '@请找 @a.x.home 的主人 你在?') + assert.equal(nameMentions('@b.x.home 你在?', nested), '@请找 @a.x.home 的主人 (x) 你在?') }) test("a friend Bot's generated look is seeded by its handle: with or without its chat record, one look (#84)", () => { diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index dc9cfaffb..24e5de19b 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -637,7 +637,7 @@ declare global { invitePreviewOf?: FlyBotInvitePreview inviteCountdown?: (expiresAt: string, offsetMs?: number) => string /** A friend's Bot chat (a roster Bot with `remote`): its one name, "Chief (Bob)", and its owner, "Bob" (#498). */ - names?: { friendBotTitle(bot?: any): string; friendBotOwner(bot?: any): string } + names?: { friendBotTitle(bot?: any): string; friendBotOwner(bot?: any): string; remoteMentionLabel?(bot?: any, others?: any[]): string } } | undefined } diff --git a/plugins/social/client.js b/plugins/social/client.js index 904aca0b8..2980f70b3 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -3356,10 +3356,14 @@ window.__ModuleLoader__.load({ return cache.list .filter(friend => !listed.has(String(friend.handle).toLowerCase())) .filter(friend => friend.handle.includes(needle) || String(friend.name).toLowerCase().includes(needle)) - // The inserted text asks the Bot to use message_friend_bot; the send is held for approval. + // The row inserts what it shows: the one name the bubble and the sidebar preview wear, which + // the host also matches as an alias of this Bot (#967). The second line stays the handle it + // routes on, and a core without the helper keeps the old handle insertion, which still routes. .map((friend) => { + const label = globalThis.__flybot__?.names?.remoteMentionLabel?.(friend, cache.list) ?? '' const owner = String(friend.owner || friend.homeName || '').trim() - return { name: owner ? `${friend.name} (${owner})` : friend.name, description: friend.handle, section: 'Bots', value: `${friend.handle} ` } + const name = label || (owner ? `${friend.name} (${owner})` : friend.name) + return { name, description: friend.handle, section: 'Bots', value: label ? `@${label} ` : `${friend.handle} ` } }) } diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 91e5e87fb..d76d96e51 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -762,6 +762,32 @@ test('client.js registers a sidebar section and @ rows into the FlyBot registry' assert.deepEqual(JSON.parse(JSON.stringify(rows)), [{ name: 'Coder', description: '@coder.bob.home', section: 'Bots', value: '@coder.bob.home ' }]) }) +test('#967: a friend Bot the core does not list yet is inserted under the one name the core would chip', async () => { + const { remoteMentionLabel } = await import('../../../core/src/shared/remote-label.js') + const friends = [ + { handle: '@coder.bob.home', name: 'Coder', owner: 'Bob', home: '@bob.home' }, + { handle: '@chief.bob.home', name: 'Chief', owner: 'Bob', home: '@bob.home' }, + { handle: '@chief.bob2.home', name: 'Chief', owner: 'Bob', home: '@bob2.home' }, + ] + const { entries, pluginOf, sandbox } = loadSocialClient(async (endpoint) => (endpoint === 'social/friends' ? friends : {})) + pluginOf([]).apply({}) + // The core publishes the same formatter the bubble and the sidebar preview read (#967). + sandbox.__flybot__.names = { remoteMentionLabel } + const mention = entries.find(([slot]) => slot === 'flybot.composer.mention')[1] + const coder = (await mention.candidates('cod'))[0] + assert.deepEqual(JSON.parse(JSON.stringify(coder)), { name: 'Coder (Bob)', description: '@coder.bob.home', section: 'Bots', value: '@Coder (Bob) ' }) + // Two friends' Bots with one name: each row carries the home, and inserting one still names one handle. + const chiefs = (await mention.candidates('chief')).map(row => ({ name: row.name, value: row.value, handle: row.description })) + assert.deepEqual(chiefs, [ + { name: 'Chief (Bob) · bob.home', value: '@Chief (Bob) · bob.home ', handle: '@chief.bob.home' }, + { name: 'Chief (Bob) · bob2.home', value: '@Chief (Bob) · bob2.home ', handle: '@chief.bob2.home' }, + ]) + // A core without the helper keeps inserting the handle, which is what routes on that core. + sandbox.__flybot__.names = {} + const plain = (await mention.candidates('cod'))[0] + assert.deepEqual(JSON.parse(JSON.stringify(plain)), { name: 'Coder (Bob)', description: '@coder.bob.home', section: 'Bots', value: '@coder.bob.home ' }) +}) + test('remote strings: names one-line, file fence cannot be escaped (#9)', async () => { const { cleanText, stripMarkers } = await import('../src/social.js') assert.equal(cleanText('Eve\n[Message from Boss]'), 'Eve Message from Boss')