From c546294b1734bb37c087579a5bc88d1ae2c55bf6 Mon Sep 17 00:00:00 2001 From: Birfy Date: Mon, 19 Oct 2026 17:06:08 +0800 Subject: [PATCH 1/2] Mark a Bot unread for a friend's line and list per-Bot friendships (#420) A friend's line lands in the receiving Bot's own chat (social.md invariant 8), so DSH's completion mark did not flag it. The host now stamps the Bot with friendLineAt and the first line from a sender with remote.firstContact; the client marks the Bot unread when a newer stamp arrives while its chat is not open, and shows a quiet first-contact line. social/overview asks the home and every member identity, so per-Bot friendships and requests are listed. --- changelog.d/420-friend-line-unread.fixed.md | 1 + core/client.js | 52 ++- core/src/client/cells.js | 12 +- core/src/client/friend-lines.js | 24 + core/src/client/i18n.js | 2 + core/src/client/index.js | 16 + core/src/client/sidebar.js | 2 + core/src/client/storage.js | 1 + core/src/client/styles.js | 3 +- core/src/host/delivery.js | 24 +- core/src/host/roster.js | 1 + core/test/flybot.test.js | 21 + core/test/friend-lines.test.js | 36 ++ core/test/inbound-ui.test.js | 15 +- docs/subsystems/core-client.md | 1 + docs/subsystems/core-host.md | 1 + docs/subsystems/social.md | 4 + patches/ds-bot/0001-core-fixes.patch | 4 +- patches/ds-bot/0002-flybot-api-host.patch | 6 +- patches/ds-bot/0003-flybot-api-client.patch | 81 +++- patches/ds-bot/0005-social-seams.patch | 30 +- patches/ds-bot/0008-client-bundle.patch | 432 ++++++++++-------- .../ds-bot/files/src/client/friend-lines.js | 24 + patches/ds-bot/files/test/flybot.test.js | 21 + .../ds-bot/files/test/friend-lines.test.js | 36 ++ patches/ds-bot/files/test/inbound-ui.test.js | 15 +- patches/ds-bot/topics.json | 3 +- plugins/social/src/social.js | 25 +- plugins/social/test/social.test.js | 32 ++ 29 files changed, 707 insertions(+), 218 deletions(-) create mode 100644 changelog.d/420-friend-line-unread.fixed.md create mode 100644 core/src/client/friend-lines.js create mode 100644 core/test/friend-lines.test.js create mode 100644 patches/ds-bot/files/src/client/friend-lines.js create mode 100644 patches/ds-bot/files/test/friend-lines.test.js diff --git a/changelog.d/420-friend-line-unread.fixed.md b/changelog.d/420-friend-line-unread.fixed.md new file mode 100644 index 000000000..cda49b7db --- /dev/null +++ b/changelog.d/420-friend-line-unread.fixed.md @@ -0,0 +1 @@ +A friend's line in one of your Bots' own chats is no longer easy to miss: the Bot's row (and the Main Bot's tile) gets the unread dot until you open that chat, also when the line came while FlyBot was closed, and a sender's first line opens with a quiet note such as `SelfB1238 is talking to Chief through Bot Social`. Add friend now also lists friendships and requests made through a per-Bot invite ([#420](https://github.com/ThinkFlowLab/FlyBot/issues/420)). diff --git a/core/client.js b/core/client.js index 62094beaa..820d87023 100644 --- a/core/client.js +++ b/core/client.js @@ -592,6 +592,8 @@ var ZH = { "Reply from": "收到回复:", "Message from": "收到消息:", "via {name}": "经由 {name}", + "{name} is talking to {bot} through Bot Social": "{name} 正通过 Bot Social 和 {bot} 对话", + "your Bot": "你的 Bot", "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", @@ -3055,9 +3057,10 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-round:active{transform:scale(.94)} .bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} .bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px} -.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} +.bt-tile{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} .bt-tile:hover{background:var(--bt-hover)} .bt-tile[aria-current=page]{background:var(--bt-active)} +.bt-tile-unread{position:absolute;top:8px;right:12px} .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} @@ -5243,6 +5246,12 @@ function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi files.length ? (0, import_react7.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; + const bot = roster.byId[sessionId]?.name ?? t("your Bot"); + return (0, import_react7.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react7.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); +} function GlobeMark() { return (0, import_react7.createElement)( "svg", @@ -5432,7 +5441,8 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us function triggerBody() { if (source.form === "remote-chat") return null; if (source.remote?.handle) { - return (0, import_react7.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); + const message = (0, import_react7.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); + return source.remote.firstContact === true ? (0, import_react7.createElement)(import_react7.Fragment, null, (0, import_react7.createElement)(FirstContact, { source, roster, sessionId }), message) : message; } if (source.kind === "bot") { if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react7.createElement)(EarlierParts, { sessionId, roster, actions }); @@ -6139,6 +6149,8 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio }, (0, import_react9.createElement)(BotAvatar, { bot, size: 60, state, main: true }), (0, import_react9.createElement)("span", { className: "bt-tile-name" }, shown), + // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. + unread && !waiting && !active ? (0, import_react9.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, bot.role ? (0, import_react9.createElement)("span", { className: "bt-chip" }, bot.role) : null )); } @@ -12994,6 +13006,7 @@ var STORAGE = { unread: "ds-bot:unread", reactions: "ds-bot:reactions", seen: "ds-bot:seen", + friendLines: "ds-bot:friend-lines", prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", @@ -15259,6 +15272,22 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random }; } +// src/client/friend-lines.js +function friendLineNews(bots, known, isOpen, selfId) { + const next = { ...known ?? {} }; + const unread = []; + for (const bot of bots ?? []) { + const at = bot?.friendLineAt; + if (bot?.remote || !Number.isFinite(at)) continue; + if (selfId && bot.ownerId && bot.ownerId !== selfId) continue; + const shown = known?.[bot.id]; + if (shown === at) continue; + next[bot.id] = at; + if (known && (shown === void 0 || at > shown) && !isOpen(bot)) unread.push(bot.id); + } + return { known: next, unread }; +} + // src/client/surface.js function groupsFor({ mode, plain = false }) { return { shell: mode === "bot", conversation: mode === "bot" && !plain }; @@ -15544,6 +15573,12 @@ function apply(ctx) { } catch { seenAt = {}; } + let friendLines = null; + try { + friendLines = JSON.parse(localStorage.getItem(STORAGE.friendLines) ?? "null"); + } catch { + friendLines = null; + } const ui2 = createSource({ details: null, view: "details", renaming: false, tab: null, newChat: null, exchange: null, menu: null, voice: null, palette: null, quote: null, settings: null, remoteBotSettings: null, memory: null, toast: null, signIn: null, renameAgent: null, unread: savedUnread, reactions: savedReactions, visits: {} }); const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); @@ -15614,6 +15649,18 @@ function apply(ctx) { } return { ...state, unread }; }); + const noteFriendLines = (value) => { + const visited = new Set(Object.keys(ui2.getSnapshot().visits ?? {}).map(seenKey)); + const news = friendLineNews(value.bots, friendLines, (bot) => visited.has(bot.id), value.self?.memberId); + if (JSON.stringify(news.known) !== JSON.stringify(friendLines ?? null)) { + friendLines = news.known; + try { + localStorage.setItem(STORAGE.friendLines, JSON.stringify(friendLines)); + } catch { + } + } + for (const id of news.unread) setUnread(id, true); + }; const refreshed = /* @__PURE__ */ new Set(); let pulls = 0; let applied = 0; @@ -15646,6 +15693,7 @@ function apply(ctx) { if (value.unchanged || value.revision === current.revision && value.modelsKey === current.modelsKey) return; if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map((entry) => entry.id)); roster.set(indexRoster(value)); + noteFriendLines(value); try { const ids = (value.agents ?? []).flatMap((agent) => agent.sessions ?? []); localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)); diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 4fc0aa114..c304fd44e 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -281,6 +281,15 @@ export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) } +// A sender's first line in a Bot's own chat (#420): one quiet line says who is talking to the Bot +// and how, so the line is not read as one of my own messages. +export function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === 'string' ? source.remote.typedBy : '' + const who = person || remoteSpeaker(source, roster).name + const bot = roster.byId[sessionId]?.name ?? t('your Bot') + return h('div', { className: 'bt-event', 'data-first-contact': source.remote.handle }, h('span', null, t('{name} is talking to {bot} through Bot Social', { name: who, bot }))) +} + function GlobeMark() { return h('svg', { width: 10, height: 10, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', 'aria-hidden': true }, h('circle', { cx: 12, cy: 12, r: 9 }), h('path', { d: 'M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18' })) @@ -468,7 +477,8 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl // A friend's message in its remote chat: the reply below shows it (remote-chat.js). if (source.form === 'remote-chat') return null if (source.remote?.handle) { - return h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }) + const message = h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }) + return source.remote.firstContact === true ? h(Fragment, null, h(FirstContact, { source, roster, sessionId }), message) : message } if (source.kind === 'bot') { // A part opens with its handoff package, so the earlier parts go right above it. diff --git a/core/src/client/friend-lines.js b/core/src/client/friend-lines.js new file mode 100644 index 000000000..ad5a55c18 --- /dev/null +++ b/core/src/client/friend-lines.js @@ -0,0 +1,24 @@ +// A friend's line in one of my Bots' own chats (#420): it lands among my own messages, so DSH's +// "finished while you were away" mark is not enough (that chat may be the one on screen, or the +// line came while this window was closed). The host stamps the Bot with `friendLineAt`; this +// window keeps the last stamp it has shown per Bot and marks a Bot unread when a newer one +// arrives while its chat is not open. Stamps are compared with stamps, never with this +// browser's clock. A browser with no record yet takes everything already there as shown. + +// `known`: the saved record, or null when this browser has none yet. `isOpen(bot)`: its chat is on +// screen now. Returns the record to save and the Bots to mark unread. +export function friendLineNews(bots, known, isOpen, selfId) { + const next = { ...(known ?? {}) } + const unread = [] + for (const bot of bots ?? []) { + const at = bot?.friendLineAt + if (bot?.remote || !Number.isFinite(at)) continue + // Another member's Bot shared in a group is theirs to read. + if (selfId && bot.ownerId && bot.ownerId !== selfId) continue + const shown = known?.[bot.id] + if (shown === at) continue + next[bot.id] = at + if (known && (shown === undefined || at > shown) && !isOpen(bot)) unread.push(bot.id) + } + return { known: next, unread } +} diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 1a5895d21..5cfb58b40 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -536,6 +536,8 @@ const ZH = { 'Reply from': '收到回复:', 'Message from': '收到消息:', 'via {name}': '经由 {name}', + '{name} is talking to {bot} through Bot Social': '{name} 正通过 Bot Social 和 {bot} 对话', + 'your Bot': '你的 Bot', 'Stopped by': '叫停者:', '{count} messages with': '{count} 条消息,来自', 'Routine started': '例行任务开始', diff --git a/core/src/client/index.js b/core/src/client/index.js index 2b3791cf7..068aa42b0 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -28,6 +28,7 @@ import { FOLDER_CSS } from './folder-menu.js' import { USAGE_CSS } from './usage-page.js' import { SCHEDULE_CSS } from './schedules.js' import { STORAGE, migrateStorage } from './storage.js' +import { friendLineNews } from './friend-lines.js' import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js' import { installLocale, language } from './i18n.js' import { noteClientError, noteNavigation, quietly } from './diagnostics.js' @@ -63,6 +64,9 @@ export function apply(ctx) { const SEEN_KEY = STORAGE.seen let seenAt = {} try { seenAt = JSON.parse(localStorage.getItem(SEEN_KEY) ?? '{}') ?? {} } catch { seenAt = {} } + // Per Bot, the last friend's-line stamp this browser has shown (friend-lines.js); null: no record yet. + let friendLines = null + try { friendLines = JSON.parse(localStorage.getItem(STORAGE.friendLines) ?? 'null') } catch { friendLines = null } // `details` is the panel's subject (a Session id); `view` picks details or the // subject Bot's browser inside it. const ui = createSource({ details: null, view: 'details', renaming: false, tab: null, newChat: null, exchange: null, menu: null, voice: null, palette: null, quote: null, settings: null, remoteBotSettings: null, memory: null, toast: null, signIn: null, renameAgent: null, unread: savedUnread, reactions: savedReactions, visits: {} }) @@ -138,6 +142,17 @@ export function apply(ctx) { try { localStorage.setItem(UNREAD_KEY, JSON.stringify(unread)) } catch { /* storage unavailable */ } return { ...state, unread } }) + // A friend's line in a Bot's chat that is not on screen marks the Bot unread, like Mark as Unread, + // and opening the chat clears it (#420). + const noteFriendLines = (value) => { + const visited = new Set(Object.keys(ui.getSnapshot().visits ?? {}).map(seenKey)) + const news = friendLineNews(value.bots, friendLines, bot => visited.has(bot.id), value.self?.memberId) + if (JSON.stringify(news.known) !== JSON.stringify(friendLines ?? null)) { + friendLines = news.known + try { localStorage.setItem(STORAGE.friendLines, JSON.stringify(friendLines)) } catch { /* storage unavailable */ } + } + for (const id of news.unread) setUnread(id, true) + } const refreshed = new Set() // Pulls can overlap (the poll and a navigation); an older answer never wins. let pulls = 0 @@ -165,6 +180,7 @@ export function apply(ctx) { // On a browser with no visit record yet, everything already there counts as seen. if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map(entry => entry.id)) roster.set(indexRoster(value)) + noteFriendLines(value) try { const ids = (value.agents ?? []).flatMap(agent => agent.sessions ?? []) localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)) diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index 0f650d205..f27a51ced 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -80,6 +80,8 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio }, h(BotAvatar, { bot, size: 60, state, main: true }), h('span', { className: 'bt-tile-name' }, shown), + // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. + unread && !waiting && !active ? h('span', { className: 'bt-unread bt-tile-unread', 'aria-hidden': true }) : null, bot.role ? h('span', { className: 'bt-chip' }, bot.role) : null)) } const unreadMark = wide && waiting && !active ? h('span', { className: 'bt-unread bt-unread-wait', 'aria-hidden': true }) diff --git a/core/src/client/storage.js b/core/src/client/storage.js index f74ffcb38..860395c11 100644 --- a/core/src/client/storage.js +++ b/core/src/client/storage.js @@ -5,6 +5,7 @@ export const STORAGE = { unread: 'ds-bot:unread', reactions: 'ds-bot:reactions', seen: 'ds-bot:seen', + friendLines: 'ds-bot:friend-lines', prefs: 'ds-bot:prefs', surface: 'ds-bot:surface', agentSessions: 'ds-bot:agent-sessions', diff --git a/core/src/client/styles.js b/core/src/client/styles.js index e0c9e8768..59ccc7805 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -137,9 +137,10 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-round:active{transform:scale(.94)} .bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} .bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px} -.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} +.bt-tile{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} .bt-tile:hover{background:var(--bt-hover)} .bt-tile[aria-current=page]{background:var(--bt-active)} +.bt-tile-unread{position:absolute;top:8px;right:12px} .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} diff --git a/core/src/host/delivery.js b/core/src/host/delivery.js index b45f4e5f2..e5672f2d0 100644 --- a/core/src/host/delivery.js +++ b/core/src/host/delivery.js @@ -96,6 +96,26 @@ export function install(rt) { return said === OWNER || said === SCHEDULED ? OWNER : OTHER } + // A friend's line in a Bot's own chat (#420): `friendLineAt` on the Bot tells every window that a + // line it has not shown yet arrived (its row turns unread), and the senders seen so far, kept apart + // from the roster, tell the first line from a sender. A group line is the group's, not the Bot's. + const CONTACTS_KEPT = 200 + const noteFriendLine = (to, sessionId, roomId, handle) => { + if (to === undefined || roomId || (sessionId !== undefined && sessionId !== rt.chatOf(to.id))) return false + const key = String(handle) + const contacts = (state.remoteContacts ??= {})[to.id] ??= {} + const first = contacts[key] === undefined + const now = Date.now() + if (first) { + contacts[key] = now + const keys = Object.keys(contacts) + if (keys.length > CONTACTS_KEPT) for (const old of keys.slice(0, keys.length - CONTACTS_KEPT)) delete contacts[old] + } + to.friendLineAt = now + void rt.save().catch(() => {}) + return first + } + // Roles recorded as team exchanges. const EXCHANGE_ROLES = new Set(['request', 'reply', 'stopped']) @@ -116,6 +136,7 @@ export function install(rt) { const fromName = from?.name ?? (givenName === undefined ? 'You' : untrusted ? cleanName(givenName) : givenName) const room = rt.roomOf(roomId) const origin = originOf({ from, to, fromId, remote, given: givenOrigin }) + const firstContact = remote?.handle && !deferred && !resumed && !retried && !resent ? noteFriendLine(to, sessionId, roomId, remote.handle) : false // Session events reject `undefined` members, so optional fields are omitted instead. const source = { kind: SOURCE_KIND, @@ -137,7 +158,8 @@ export function install(rt) { ...(fromRoomId ? { senderRoomId: fromRoomId } : {}), // A remote member's message (FlyBot): the chat draws it as that member's bubble. // `typedBy`: the plugin has the server's attestation that this friend typed the line themselves. - ...(remote?.handle ? { remote: { handle: String(remote.handle), ...(remote.home ? { home: String(remote.home) } : {}), ...(remote.typedBy ? { typedBy: cleanName(remote.typedBy) } : {}) } } : {}), + // `firstContact`: this sender's first line in the Bot's own chat; the chat names who is talking (#420). + ...(remote?.handle ? { remote: { handle: String(remote.handle), ...(remote.home ? { home: String(remote.home) } : {}), ...(remote.typedBy ? { typedBy: cleanName(remote.typedBy) } : {}), ...(firstContact ? { firstContact: true } : {}) } } : {}), // A remote member's files, as references the chat shows as chips (fetched on demand). ...(remote?.handle && Array.isArray(attachments) && attachments.length ? { attachments: attachments.map(file => ({ id: String(file.id ?? ''), name: String(file.name), size: Number(file.size), mime: String(file.mime ?? '') })) } : {}), } diff --git a/core/src/host/roster.js b/core/src/host/roster.js index 0d92db0bf..082292ad3 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -522,6 +522,7 @@ export function install(rt) { // Questions and exchange turns are kept per Session, so every part of the chat is cleared. const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)]) delete state.bots[id] + delete state.remoteContacts?.[id] for (const part of parts) { delete state.questions[part] delete state.exchangeTurns[part] diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index 060ca24ef..a6b8320a6 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -268,6 +268,27 @@ test('a remote sender the plugin says typed the line is marked on the message so assert.equal('typedBy' in sources[1].remote, false) }) +test("#420: a friend's line stamps the receiving Bot for its unread mark, and only a sender's first line is first contact", async (t) => { + const { fb, agents, call, stop, grantTo } = await start() + t.after(stop) + await grantTo('a', '@bob.bot') + await grantTo('a', '@carol.bot') + const before = (await call('state', {})).value + assert.equal(before.bots.find(bot => bot.id === 'a').friendLineAt, undefined) + await fb.bot.send({ from: { kind: 'remote', handle: '@bob.bot', name: 'Bob (typed via @bob.bot, Bot Social)', typedBy: 'Bob' }, to: 'a', text: 'hi' }) + const first = (await call('state', {})).value + const stamp = first.bots.find(bot => bot.id === 'a').friendLineAt + assert.ok(Number.isFinite(stamp), 'the Bot carries the stamp') + assert.ok(first.revision > before.revision, 'the stamp moves the revision, so an open window reads it') + assert.equal(first.bots.find(bot => bot.id === 'b').friendLineAt, undefined, 'other Bots are untouched') + assert.equal('remoteContacts' in first, false, 'the senders seen stay on the host') + await fb.bot.send({ from: { kind: 'remote', handle: '@bob.bot', name: 'Bob' }, to: 'a', text: 'again' }) + await fb.bot.send({ from: { kind: 'remote', handle: '@carol.bot', name: 'Carol' }, to: 'a', text: 'me too' }) + const sources = agents.get('a').calls.map(([, m]) => m.source) + assert.deepEqual(sources.map(source => [source.remote.handle, source.remote.firstContact === true]), [['@bob.bot', true], ['@bob.bot', false], ['@carol.bot', true]]) + assert.ok((await call('state', {})).value.bots.find(bot => bot.id === 'a').friendLineAt >= stamp) +}) + test('#226: chatApprovals.changed moves the team revision so a friend chat badge refreshes', async (t) => { const { fb, call, stop } = await start() t.after(stop) diff --git a/core/test/friend-lines.test.js b/core/test/friend-lines.test.js new file mode 100644 index 000000000..b773c8830 --- /dev/null +++ b/core/test/friend-lines.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { friendLineNews } from '../src/client/friend-lines.js' + +const closed = () => false + +test("#420: a friend's line in a Bot's chat that is not open marks that Bot unread once", () => { + const bots = [{ id: 'a', friendLineAt: 200 }, { id: 'b' }] + const news = friendLineNews(bots, { a: 100 }, closed) + assert.deepEqual(news.unread, ['a']) + assert.deepEqual(news.known, { a: 200 }) + // The same stamp again (the next pull) marks nothing: opening the chat cleared the mark. + assert.deepEqual(friendLineNews(bots, news.known, closed).unread, []) +}) + +test("#420: the first friend's line a Bot ever gets is news too, once this browser keeps a record", () => { + assert.deepEqual(friendLineNews([{ id: 'a', friendLineAt: 50 }], {}, closed).unread, ['a']) +}) + +test('#420: a line that arrives while its chat is on screen was seen live', () => { + const news = friendLineNews([{ id: 'a', friendLineAt: 300 }], { a: 100 }, bot => bot.id === 'a') + assert.deepEqual(news.unread, []) + assert.deepEqual(news.known, { a: 300 }, 'recorded, so leaving the chat does not mark it later') +}) + +test('#420: a browser with no record yet takes what is there as shown', () => { + const news = friendLineNews([{ id: 'a', friendLineAt: 300 }, { id: 'b' }], null, closed) + assert.deepEqual(news.unread, []) + assert.deepEqual(news.known, { a: 300 }) + assert.deepEqual(friendLineNews([{ id: 'b' }], null, closed).known, {}, 'an empty record still starts tracking') +}) + +test("#420: a friend's Bot chat and another member's shared Bot are not marked here", () => { + const bots = [{ id: 'r', remote: { handle: '@x.home' }, friendLineAt: 9 }, { id: 'theirs', ownerId: 'm2', friendLineAt: 9 }, { id: 'mine', ownerId: 'm1', friendLineAt: 9 }] + assert.deepEqual(friendLineNews(bots, {}, closed, 'm1').unread, ['mine']) +}) diff --git a/core/test/inbound-ui.test.js b/core/test/inbound-ui.test.js index a2237e0c0..76316afab 100644 --- a/core/test/inbound-ui.test.js +++ b/core/test/inbound-ui.test.js @@ -7,7 +7,7 @@ import { build } from 'esbuild' // The inbound friend conversation's display: trace lines, the thinking row, the remote speaker. const { outputFiles } = await build({ stdin: { - contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker, RemoteMessage } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople } from './src/client/mark.js'", + contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker, RemoteMessage, FirstContact } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople } from './src/client/mark.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { stripTrace, previewOf, nameMentions, remoteSpeaker, RemoteMessage, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople } = module.exports +const { stripTrace, previewOf, nameMentions, remoteSpeaker, RemoteMessage, FirstContact, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople } = module.exports test('a reply opening with a delegation trace shows without it', () => { assert.equal(stripTrace('Chief → 助手\n\nHi Chief! Good to hear from you.'), 'Hi Chief! Good to hear from you.') @@ -201,3 +201,14 @@ test('#275: a typed line from a friend has one short header: the person, then "v assert.ok(via) assert.doesNotMatch(header(known), /typed via|Bot Social|'s Bot/) }) + +test("#420: a sender's first line in a Bot's own chat gets one quiet line naming who talks to which Bot", () => { + const roster = { bots: [], remoteBots: [], byId: { s: { id: 'a', name: 'Chief' } } } + const text = node => flat(node).flatMap(n => (Array.isArray(n.props?.children) ? n.props.children : [])).filter(c => typeof c === 'string').join('') + const typed = FirstContact({ roster, sessionId: 's', source: { remote: { handle: '@chief.selfb1238.home', typedBy: 'SelfB1238', firstContact: true }, senderName: 'SelfB1238 (typed via @chief.selfb1238.home, Bot Social)' } }) + assert.equal(typed.props.className, 'bt-event') + assert.equal(text(typed), 'SelfB1238 is talking to Chief through Bot Social') + const known = { ...roster, remoteBots: [{ handle: '@helper.selfb1238.home', name: 'Helper', owner: 'SelfB1238' }] } + const bot = FirstContact({ roster: known, sessionId: 's', source: { remote: { handle: '@helper.selfb1238.home', firstContact: true }, senderName: 'Helper' } }) + assert.equal(text(bot), 'Helper (SelfB1238) is talking to Chief through Bot Social') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 0c9ece48f..183fb223e 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -140,3 +140,4 @@ A discarded `.catch(() => {})` in the client sources either starts with `void` ( `npm test` in `core/` (it also fails when `client.js` is stale). - A friend's line that Bot Social attests the friend typed themselves (#227) is drawn as the person: `AssistantCell` looks the turn up in `roster.remoteTypedTurns` (`typedBy` in `src/client/text.js`) and renders `PersonMessage` — the person's name and a person mark (`.bt-person-mark`) — instead of the Bot's `Bubbles`. The name comes from the roster (the authenticated Bot Social `from`), never from message text. - The same attested line sent to one of my own Bots outside that friend's chat (a friend's Chief typing to my Helper) arrives as a remote message whose source has `remote.typedBy`; `RemoteMessage` (`cells.js`) then draws it as the person (name, person mark, `via `) instead of the friend's Bot avatar and handle. Without `typedBy` it keeps the Bot rendering (#233 follow-up). The `via` name is the carrying Bot's plain name when this home knows that Bot (its chat or `roster.remoteBots`), and is left out when it does not; the model-facing sender label (`X (typed via @…, Bot Social)`) and the handle-derived `· owner's Bot` never reach that header (#275). Settings → Bots offers only the user's own Bots as a Main Bot, in the first-Main-Bot and Add a Main Bot selects (#276). +- A friend's line in my Bot's own chat (#420): `friend-lines.js` `friendLineNews` compares each Bot's host stamp `friendLineAt` with the last stamp this browser has shown (`localStorage` `ds-bot:friend-lines`; stamps against stamps, never this browser's clock) and, when a newer one arrives while that chat is not visited, `index.js` marks the Bot unread the way **Mark as Unread** does, so the row shows the blue dot until the chat is opened. A browser with no record yet takes what is there as shown; another member's shared Bot and a friend's Bot chat are skipped. The Main Bot tile shows the same dot (`.bt-tile-unread`; a wait still shows on its avatar). A line whose source has `remote.firstContact` opens with one muted `FirstContact` line (`cells.js`): ` is talking to through Bot Social`. Covered by `core/test/friend-lines.test.js` and `core/test/inbound-ui.test.js`. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 78dcec677..1d4982aa3 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -148,6 +148,7 @@ credentials and team mode remain separate work under #55/#198. - `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 never has its reply delivered back into that group (it lands in the friend's Bot's own chat), so the host posts one receipt line into the room per mention (`src/host/hooks.js` `mentionReceipt`, source role `mention`) and the client renders it as a muted event line (`src/client/cells.js`) (#80, #81). 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. - `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`. ### Connectors: GitHub and the Composio gateway (#311) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index dd732522e..68276bcb2 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -148,6 +148,10 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ `friendLabel` (thinking line, card names), `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)`. `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). +### Friends listed per Bot (#420) + +`social/overview` (`friendsOverview`) asks `list_friends` for the home and for every active member identity, as `setAutoAccept` does, because a per-Bot invite hangs the relationship on the member (`@chief.alice.home` ↔ `@chief.bob.home`), not on the home. Each relationship is listed once (by `relationship_id`); a row found through a member carries that local Bot (`botId`, `botName`). Pending requests are gathered the same way, so Add friend shows per-Bot requests too. A member whose read fails costs only its own rows; a failed home read still fails the call. Owner decision (2026-10-19): a grantee's lines stay in the Bot's own chat (invariant 8) with no thread per person, and a friend with no Bot shared to me gets no sidebar row; the Bot's row turns unread for a friend's line instead (core `friendLineAt`, see core-client). + ### Past rooms, unique titles, poll, and + (#129–#133) - Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index ad8360140..0af5d7ffe 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -537,11 +537,13 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Open updates': '打开更新', // Command palette -@@ -403,6 +403,7 @@ const ZH = { +@@ -403,6 +403,9 @@ const ZH = { 'Replies from': '群聊回复:', 'Reply from': '收到回复:', 'Message from': '收到消息:', + 'via {name}': '经由 {name}', ++ '{name} is talking to {bot} through Bot Social': '{name} 正通过 Bot Social 和 {bot} 对话', ++ 'your Bot': '你的 Bot', 'Stopped by': '叫停者:', '{count} messages with': '{count} 条消息,来自', 'Routine started': '例行任务开始', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 64ec793b6..2ce712d14 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -346,7 +346,11 @@ diff --git a/src/host/roster.js b/src/host/roster.js await rt.save() } -@@ -438,6 +438,7 @@ export function install(rt) { +@@ -435,9 +435,11 @@ export function install(rt) { + // Questions and exchange turns are kept per Session, so every part of the chat is cleared. + const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)]) + delete state.bots[id] ++ delete state.remoteContacts?.[id] for (const part of parts) { delete state.questions[part] delete state.exchangeTurns[part] diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index a17c9d46f..87ae976ed 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -293,7 +293,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js } function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { -@@ -170,16 +170,128 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { +@@ -170,16 +170,137 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { return h('div', { className: 'bt-event' }, parts) } @@ -389,6 +389,15 @@ diff --git a/src/client/cells.js b/src/client/cells.js + files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null) +} + ++// A sender's first line in a Bot's own chat (#420): one quiet line says who is talking to the Bot ++// and how, so the line is not read as one of my own messages. ++export function FirstContact({ source, roster, sessionId }) { ++ const person = typeof source.remote.typedBy === 'string' ? source.remote.typedBy : '' ++ const who = person || remoteSpeaker(source, roster).name ++ const bot = roster.byId[sessionId]?.name ?? t('your Bot') ++ return h('div', { className: 'bt-event', 'data-first-contact': source.remote.handle }, h('span', null, t('{name} is talking to {bot} through Bot Social', { name: who, bot }))) ++} ++ +function GlobeMark() { + return h('svg', { width: 10, height: 10, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', 'aria-hidden': true }, + h('circle', { cx: 12, cy: 12, r: 9 }), h('path', { d: 'M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18' })) @@ -425,14 +434,15 @@ diff --git a/src/client/cells.js b/src/client/cells.js // A centered event line that names a group chat and opens it. function GroupEvent({ prefix, room, name, roster, actions }) { return h('div', { className: 'bt-event' }, -@@ -331,17 +331,27 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl +@@ -331,17 +331,28 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl : body function triggerBody() { + // A friend's message in its remote chat: the reply below shows it (remote-chat.js). + if (source.form === 'remote-chat') return null + if (source.remote?.handle) { -+ return h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }) ++ const message = h(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }) ++ return source.remote.firstContact === true ? h(Fragment, null, h(FirstContact, { source, roster, sessionId }), message) : message + } if (source.kind === 'bot') { // A part opens with its handoff package, so the earlier parts go right above it. @@ -695,7 +705,7 @@ diff --git a/src/client/icons.js b/src/client/icons.js diff --git a/src/client/index.js b/src/client/index.js --- a/src/client/index.js +++ b/src/client/index.js -@@ -1,25 +1,28 @@ +@@ -1,33 +1,37 @@ +import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js' import { call } from './api.js' import { isHex, inkText, paintCss } from './inks.js' @@ -729,8 +739,9 @@ diff --git a/src/client/index.js b/src/client/index.js import { GROUP_SETTINGS_CSS } from './group-settings.js' import { FOLDER_CSS } from './folder-menu.js' import { USAGE_CSS } from './usage-page.js' -@@ -27,7 +27,7 @@ import { SCHEDULE_CSS } from './schedules.js' + import { SCHEDULE_CSS } from './schedules.js' import { STORAGE, migrateStorage } from './storage.js' ++import { friendLineNews } from './friend-lines.js' import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js' import { installLocale, language } from './i18n.js' -import { noteClientError, quietly } from './diagnostics.js' @@ -757,8 +768,13 @@ diff --git a/src/client/index.js b/src/client/index.js migrateStorage() const UNREAD_KEY = STORAGE.unread let savedUnread = {} -@@ -52,7 +52,7 @@ export function apply(ctx) { +@@ -50,9 +50,12 @@ export function apply(ctx) { + const SEEN_KEY = STORAGE.seen + let seenAt = {} try { seenAt = JSON.parse(localStorage.getItem(SEEN_KEY) ?? '{}') ?? {} } catch { seenAt = {} } ++ // Per Bot, the last friend's-line stamp this browser has shown (friend-lines.js); null: no record yet. ++ let friendLines = null ++ try { friendLines = JSON.parse(localStorage.getItem(STORAGE.friendLines) ?? 'null') } catch { friendLines = null } // `details` is the panel's subject (a Session id); `view` picks details or the // subject Bot's browser inside it. - const ui = createSource({ details: null, view: 'details', renaming: false, tab: null, newChat: null, exchange: null, menu: null, voice: null, palette: null, quote: null, settings: null, memory: null, toast: null, signIn: null, renameAgent: null, unread: savedUnread, reactions: savedReactions, visits: {} }) @@ -766,7 +782,25 @@ diff --git a/src/client/index.js b/src/client/index.js // Bot mode paints the whole team surface; Agent mode withdraws every slot and // style so the shell's own interface returns. `plain` stays false until a Bot-mode // view hosts a plain session, which drops only the conversation replacements. -@@ -145,6 +145,8 @@ export function apply(ctx) { +@@ -125,6 +125,17 @@ export function apply(ctx) { + try { localStorage.setItem(UNREAD_KEY, JSON.stringify(unread)) } catch { /* storage unavailable */ } + return { ...state, unread } + }) ++ // A friend's line in a Bot's chat that is not on screen marks the Bot unread, like Mark as Unread, ++ // and opening the chat clears it (#420). ++ const noteFriendLines = (value) => { ++ const visited = new Set(Object.keys(ui.getSnapshot().visits ?? {}).map(seenKey)) ++ const news = friendLineNews(value.bots, friendLines, bot => visited.has(bot.id), value.self?.memberId) ++ if (JSON.stringify(news.known) !== JSON.stringify(friendLines ?? null)) { ++ friendLines = news.known ++ try { localStorage.setItem(STORAGE.friendLines, JSON.stringify(friendLines)) } catch { /* storage unavailable */ } ++ } ++ for (const id of news.unread) setUnread(id, true) ++ } + const refreshed = new Set() + // Pulls can overlap (the poll and a navigation); an older answer never wins. + let pulls = 0 +@@ -145,11 +145,14 @@ export function apply(ctx) { const live = {} if (value.connectors !== undefined && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live.connectors = value.connectors if (value.capabilityStatuses !== undefined && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live.capabilityStatuses = value.capabilityStatuses @@ -775,6 +809,12 @@ diff --git a/src/client/index.js b/src/client/index.js if (Object.keys(live).length > 0 && current.ready) roster.set({ ...current, ...live }) if (value.unchanged || (value.revision === current.revision && value.modelsKey === current.modelsKey)) return // On a browser with no visit record yet, everything already there counts as seen. + if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map(entry => entry.id)) + roster.set(indexRoster(value)) ++ noteFriendLines(value) + try { + const ids = (value.agents ?? []).flatMap(agent => agent.sessions ?? []) + localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)) @@ -325,7 +325,7 @@ export function apply(ctx) { ctx.inject(['shortcuts'], scope => { scope.effect(() => holdShellShortcuts(scope.shortcuts), 'ds-bot: settings shortcut') }) @@ -1234,7 +1274,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js import { showsDshRow } from './dsh-offer.js' // ------------------------------------------------------------------------- -@@ -37,56 +37,115 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { +@@ -37,56 +37,117 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { })) const workLine = (lead, tool, more) => h(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ''}`) @@ -1286,6 +1326,8 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js h(BotAvatar, { bot, size: 60, state, main: true }), - h('span', { className: 'bt-tile-name' }, bot.name), + h('span', { className: 'bt-tile-name' }, shown), ++ // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. ++ unread && !waiting && !active ? h('span', { className: 'bt-unread bt-tile-unread', 'aria-hidden': true }) : null, bot.role ? h('span', { className: 'bt-chip' }, bot.role) : null)) } - return h('button', { @@ -1650,6 +1692,17 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js +.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings svg{width:15px;height:15px} ` +diff --git a/src/client/storage.js b/src/client/storage.js +--- a/src/client/storage.js ++++ b/src/client/storage.js +@@ -5,6 +5,7 @@ export const STORAGE = { + unread: 'ds-bot:unread', + reactions: 'ds-bot:reactions', + seen: 'ds-bot:seen', ++ friendLines: 'ds-bot:friend-lines', + prefs: 'ds-bot:prefs', + surface: 'ds-bot:surface', + agentSessions: 'ds-bot:agent-sessions', diff --git a/src/client/styles.js b/src/client/styles.js --- a/src/client/styles.js +++ b/src/client/styles.js @@ -1676,7 +1729,7 @@ diff --git a/src/client/styles.js b/src/client/styles.js @keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} /* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -@@ -115,7 +115,14 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -115,15 +115,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} .bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} @@ -1692,6 +1745,16 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} .bt-round:hover{background:var(--bt-hover)} .bt-round:active{transform:scale(.94)} + .bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} + .bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px} +-.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} ++.bt-tile{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} + .bt-tile:hover{background:var(--bt-hover)} + .bt-tile[aria-current=page]{background:var(--bt-active)} ++.bt-tile-unread{position:absolute;top:8px;right:12px} + .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} + .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} @@ -132,21 +132,24 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 1190d9969..6a96406a3 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -712,7 +712,7 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js return resolved.agent } -@@ -28,6 +28,18 @@ export function install(rt) { +@@ -28,6 +28,38 @@ export function install(rt) { return `[Message from ${fromName}]` } @@ -727,11 +727,31 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js + const said = given ?? rt.botTurnOrigin?.(fromId) + return said === OWNER || said === SCHEDULED ? OWNER : OTHER + } ++ ++ // A friend's line in a Bot's own chat (#420): `friendLineAt` on the Bot tells every window that a ++ // line it has not shown yet arrived (its row turns unread), and the senders seen so far, kept apart ++ // from the roster, tell the first line from a sender. A group line is the group's, not the Bot's. ++ const CONTACTS_KEPT = 200 ++ const noteFriendLine = (to, sessionId, roomId, handle) => { ++ if (to === undefined || roomId || (sessionId !== undefined && sessionId !== rt.chatOf(to.id))) return false ++ const key = String(handle) ++ const contacts = (state.remoteContacts ??= {})[to.id] ??= {} ++ const first = contacts[key] === undefined ++ const now = Date.now() ++ if (first) { ++ contacts[key] = now ++ const keys = Object.keys(contacts) ++ if (keys.length > CONTACTS_KEPT) for (const old of keys.slice(0, keys.length - CONTACTS_KEPT)) delete contacts[old] ++ } ++ to.friendLineAt = now ++ void rt.save().catch(() => {}) ++ return first ++ } + // Roles recorded as team exchanges. const EXCHANGE_ROLES = new Set(['request', 'reply', 'stopped']) -@@ -39,12 +39,15 @@ export function install(rt) { +@@ -39,12 +39,16 @@ export function install(rt) { // exchange is recorded already too. `askedBy` is the member whose @mention called a // group turn (groups.js). `fromRoomId`: the group whose Session the sender wrote // from; the reply goes back to that Session (turns.js). @@ -746,6 +766,7 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js + const fromName = from?.name ?? (givenName === undefined ? 'You' : untrusted ? cleanName(givenName) : givenName) const room = rt.roomOf(roomId) + const origin = originOf({ from, to, fromId, remote, given: givenOrigin }) ++ const firstContact = remote?.handle && !deferred && !resumed && !retried && !resent ? noteFriendLine(to, sessionId, roomId, remote.handle) : false // Session events reject `undefined` members, so optional fields are omitted instead. const source = { kind: SOURCE_KIND, @@ -758,13 +779,14 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js ...(fromId ? { senderSessionId: fromId } : {}), ...(replyTo ? { replyTo } : {}), ...(roomId ? { roomId } : {}), -@@ -62,6 +62,11 @@ export function install(rt) { +@@ -62,6 +62,12 @@ export function install(rt) { ...(retried ? { retried: true } : {}), ...(askedBy ? { askedBy } : {}), ...(fromRoomId ? { senderRoomId: fromRoomId } : {}), + // A remote member's message (FlyBot): the chat draws it as that member's bubble. + // `typedBy`: the plugin has the server's attestation that this friend typed the line themselves. -+ ...(remote?.handle ? { remote: { handle: String(remote.handle), ...(remote.home ? { home: String(remote.home) } : {}), ...(remote.typedBy ? { typedBy: cleanName(remote.typedBy) } : {}) } } : {}), ++ // `firstContact`: this sender's first line in the Bot's own chat; the chat names who is talking (#420). ++ ...(remote?.handle ? { remote: { handle: String(remote.handle), ...(remote.home ? { home: String(remote.home) } : {}), ...(remote.typedBy ? { typedBy: cleanName(remote.typedBy) } : {}), ...(firstContact ? { firstContact: true } : {}) } } : {}), + // A remote member's files, as references the chat shows as chips (fetched on demand). + ...(remote?.handle && Array.isArray(attachments) && attachments.length ? { attachments: attachments.map(file => ({ id: String(file.id ?? ''), name: String(file.name), size: Number(file.size), mime: String(file.mime ?? '') })) } : {}), } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index dac324df2..a11ca7141 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -294,11 +294,13 @@ diff --git a/client.js b/client.js "Open updates": "打开更新", // Command palette Messages: "消息", -@@ -459,6 +459,7 @@ var ZH = { +@@ -459,6 +459,9 @@ var ZH = { "Replies from": "群聊回复:", "Reply from": "收到回复:", "Message from": "收到消息:", + "via {name}": "经由 {name}", ++ "{name} is talking to {bot} through Bot Social": "{name} 正通过 Bot Social 和 {bot} 对话", ++ "your Bot": "你的 Bot", "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", @@ -962,14 +964,6 @@ diff --git a/client.js b/client.js - const byId = {}; - for (const bot of value.bots) { - for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; -- } -- const roomsById = {}; -- for (const room2 of value.rooms) roomsById[room2.id] = room2; -- const agentsById = {}; -- const agentOf = {}; -- for (const agent of value.agents ?? []) { -- agentsById[agent.id] = agent; -- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -1003,29 +997,23 @@ diff --git a/client.js b/client.js + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); } -- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; +- const roomsById = {}; +- for (const room2 of value.rooms) roomsById[room2.id] = room2; +- const agentsById = {}; +- const agentOf = {}; +- for (const agent of value.agents ?? []) { +- agentsById[agent.id] = agent; +- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); - } --function isAgentSession(roster, cache, sessionId) { -- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; -- if (roster.ready) return roster.agentOf[sessionId] !== void 0; -- return cache.has(sessionId); ++} +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", + { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); - } --function countCards(state, sessionId, callIds, delta) { -- const own = { ...state[sessionId] }; -- for (const callId of callIds) { -- const count = (own[callId] ?? 0) + delta; -- if (count > 0) own[callId] = count; -- else delete own[callId]; -- } -- return { ...state, [sessionId]: own }; ++} +function PersonMark({ size = 22 }) { + return (0, import_react.createElement)( + "span", @@ -1037,109 +1025,17 @@ diff --git a/client.js b/client.js + (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) + ) + ); - } -- --// src/client/themes.js --var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; --var THEMES = { -- deepseek: { -- label: "DeepSeek", -- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, -- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } -- }, -- mono: { -- label: "Mono", -- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, -- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } -- }, -- paper: { -- label: "Paper", -- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, -- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } -- }, -- moonlight: { -- label: "Moonlight", -- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, -- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -- } --}; --var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; --function themeVars(mode, dark, accent = mode.accent) { -- return { -- "--bt-ink": mode.text, -- "--bt-ink-2": rgba(mode.text, 0.6), -- "--bt-ink-3": rgba(mode.text, 0.36), -- "--bt-bubble": mode.bubble, -- "--bt-sidebar": mode.sidebar, -- "--bt-main": mode.bg, -- "--bt-card": mode.card, -- "--bt-line": rgba(mode.text, 0.08), -- "--bt-line-2": rgba(mode.text, 0.15), -- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", -- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", -- "--bt-accent": accent, -- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", -- "--bt-user": mode.user, -- "--bt-user-ink": mode.userText, -- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), -- "--bt-tag-ink": rgba(mode.text, 0.58) -- }; ++} +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); - } --var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); --var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} --html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} --${theme.css ?? ""}`; --var shellTokens = ({ light, dark }) => ({ -- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, -- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, -- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, -- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, -- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, -- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } --}); --var customThemes = {}; --var registry = createSource(0); --var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); --function registerShape(id, shape) { -- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); -- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); -- const at = shape.eyes?.at; -- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); -- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; -- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; -- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); -- const previous = SHAPES[id]; -- SHAPES[id] = { -- label: String(shape.label ?? id), -- size, -- body: shape.body, -- eyes, -- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} -- }; -- const refresh = () => { -- markupCache.clear(); -- maskCache.delete(id); -- registry.set((value) => value + 1); -- }; -- refresh(); -- return () => { -- if (previous) SHAPES[id] = previous; -- else delete SHAPES[id]; -- refresh(); -- }; ++} +function MainStar() { + return (0, import_react.createElement)( + "svg", + { viewBox: "0 0 20 20", "aria-hidden": true }, + (0, import_react.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 registerTheme(id, theme) { -- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); -- for (const mode of ["light", "dark"]) { -- for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); ++} +function useSettled(state) { + const prev = (0, import_react.useRef)(state); + const [settled, setSettled] = (0, import_react.useState)(false); @@ -1179,16 +1075,14 @@ diff --git a/client.js b/client.js + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; } -- const previous = customThemes[id]; -- customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; -- registry.set((value) => value + 1); -- return () => { -- if (previous) customThemes[id] = previous; -- else delete customThemes[id]; -- registry.set((value) => value + 1); +- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; -+} + } +-function isAgentSession(roster, cache, sessionId) { +- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; +- if (roster.ready) return roster.agentOf[sessionId] !== void 0; +- return cache.has(sessionId); +function roomAvatarPeople(room2, roster) { + const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean); + const seen = new Set(members.map((bot) => bot.id)); @@ -1221,7 +1115,13 @@ diff --git a/client.js b/client.js + } + if (members.length === 1 && remotes.length === 0) return [members[0], null]; + return [...members, ...remotes]; -+} + } +-function countCards(state, sessionId, callIds, delta) { +- const own = { ...state[sessionId] }; +- for (const callId of callIds) { +- const count = (own[callId] ?? 0) + delta; +- if (count > 0) own[callId] = count; +- else delete own[callId]; +function RoomAvatar({ room: room2, roster, size = 36 }) { + const people = roomAvatarPeople(room2, roster); + const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); @@ -1233,7 +1133,8 @@ diff --git a/client.js b/client.js + people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), + people.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null + ); -+ } + } +- return { ...state, [sessionId]: own }; + const seats = clusterSeats(Math.min(people.length, 4), size); + const overflow = people.length > 4 ? people.length - 3 : 0; + const gap = size / 14; @@ -1252,8 +1153,30 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); -+} -+ + } + +-// src/client/themes.js +-var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +-var THEMES = { +- deepseek: { +- label: "DeepSeek", +- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, +- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } +- }, +- mono: { +- label: "Mono", +- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, +- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } +- }, +- paper: { +- label: "Paper", +- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, +- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } +- }, +- moonlight: { +- label: "Moonlight", +- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, +- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } +// src/client/flybot-slots.js +var import_react2 = require("react"); +var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); @@ -1285,8 +1208,29 @@ diff --git a/client.js b/client.js + } + render() { + return this.state.failed ? this.props.fallback ?? null : this.props.children; -+ } -+}; + } + }; +-var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +-function themeVars(mode, dark, accent = mode.accent) { +- return { +- "--bt-ink": mode.text, +- "--bt-ink-2": rgba(mode.text, 0.6), +- "--bt-ink-3": rgba(mode.text, 0.36), +- "--bt-bubble": mode.bubble, +- "--bt-sidebar": mode.sidebar, +- "--bt-main": mode.bg, +- "--bt-card": mode.card, +- "--bt-line": rgba(mode.text, 0.08), +- "--bt-line-2": rgba(mode.text, 0.15), +- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", +- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", +- "--bt-accent": accent, +- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", +- "--bt-user": mode.user, +- "--bt-user-ink": mode.userText, +- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), +- "--bt-tag-ink": rgba(mode.text, 0.58) +- }; +function SlotIcon({ id, icon: icon2, fallback }) { + return (0, import_react2.createElement)(SlotBoundary, { id, fallback }, (0, import_react2.createElement)(icon2)); +} @@ -1338,7 +1282,42 @@ diff --git a/client.js b/client.js + }; + }, []); + return claimed; -+} + } +-var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +-var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +-html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} +-${theme.css ?? ""}`; +-var shellTokens = ({ light, dark }) => ({ +- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, +- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, +- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, +- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, +- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, +- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } +-}); +-var customThemes = {}; +-var registry = createSource(0); +-var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); +-function registerShape(id, shape) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); +- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); +- const at = shape.eyes?.at; +- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); +- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; +- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; +- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); +- const previous = SHAPES[id]; +- SHAPES[id] = { +- label: String(shape.label ?? id), +- size, +- body: shape.body, +- eyes, +- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} +- }; +- const refresh = () => { +- markupCache.clear(); +- maskCache.delete(id); +- registry.set((value) => value + 1); +function FlyBotSessionDock({ sessionId, useRoster, actions }) { + const entries2 = useSlot("flybot.session.dock"); + (0, import_react2.useEffect)(() => { @@ -1424,6 +1403,11 @@ diff --git a/client.js b/client.js + const chat = chatOf(approval, roster); + return Boolean(chat) && (claimed.chats?.has(chat) === true || claimed.chats?.has(roster?.byId?.[chat]?.sessionId) === true); }; +- refresh(); +- return () => { +- if (previous) SHAPES[id] = previous; +- else delete SHAPES[id]; +- refresh(); + const away = open.filter((approval) => policies.has(approval.by) && approval.status !== "failed" && !inOpenChat(approval)); + const awayKey = away.map((approval) => approval.id).join(","); + (0, import_react2.useEffect)(() => { @@ -1447,12 +1431,25 @@ diff --git a/client.js b/client.js + setBusy(""); + await refresh(); + } -+ }; + }; +-} +-function registerTheme(id, theme) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); +- for (const mode of ["light", "dark"]) { +- for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); + const byChat = /* @__PURE__ */ new Map(); + for (const approval of pointers) { + const key = chatOf(approval, roster) ?? approval.id; + byChat.set(key, [...byChat.get(key) ?? [], approval]); -+ } + } +- const previous = customThemes[id]; +- customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; +- registry.set((value) => value + 1); +- return () => { +- if (previous) customThemes[id] = previous; +- else delete customThemes[id]; +- registry.set((value) => value + 1); +- }; + return (0, import_react2.createElement)( + import_react2.Fragment, + null, @@ -1631,7 +1628,7 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1604,29 +1604,188 @@ function previewOf(list, id, hiddenTurns = []) { +@@ -1604,7 +1604,7 @@ function previewOf(list, id, hiddenTurns = []) { const entry = outline[index]; if (hiddenTurns.includes(entry.turn)) continue; const text = entry.response || entry.prompt || ""; @@ -1640,29 +1637,13 @@ diff --git a/client.js b/client.js } return ""; } --var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); --function usageOf(list, ids) { -- let input = 0; -- let cacheRead = 0; -- let output = 0; -- for (const id of ids) { -- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; -- if (!usage) continue; -- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); -- cacheRead += usage.cacheReadTokens ?? 0; -- output += usage.outputTokens ?? 0; -+var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); -+function usageOf(list, ids) { -+ let input = 0; -+ let cacheRead = 0; -+ let output = 0; -+ for (const id of ids) { -+ const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; -+ if (!usage) continue; -+ input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); -+ cacheRead += usage.cacheReadTokens ?? 0; -+ output += usage.outputTokens ?? 0; -+ } +@@ -1620,13 +1620,172 @@ function usageOf(list, ids) { + cacheRead += usage.cacheReadTokens ?? 0; + output += usage.outputTokens ?? 0; + } +- if (input + output === 0) return ""; +- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; +- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); + if (input + output === 0) return ""; + const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; + return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); @@ -1815,10 +1796,7 @@ diff --git a/client.js b/client.js + if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); + for (const mode of ["light", "dark"]) { + for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); - } -- if (input + output === 0) return ""; -- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; -- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); ++ } + const previous = customThemes[id]; + customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; + registry3.set((value) => value + 1); @@ -1950,7 +1928,7 @@ diff --git a/client.js b/client.js @keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} /* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -@@ -1933,7 +1933,14 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1933,15 +1933,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} .bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} @@ -1966,6 +1944,16 @@ diff --git a/client.js b/client.js .bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} .bt-round:hover{background:var(--bt-hover)} .bt-round:active{transform:scale(.94)} + .bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} + .bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px} +-.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} ++.bt-tile{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} + .bt-tile:hover{background:var(--bt-hover)} + .bt-tile[aria-current=page]{background:var(--bt-active)} ++.bt-tile-unread{position:absolute;top:8px;right:12px} + .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} + .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} @@ -1950,21 +1950,24 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} @@ -3353,7 +3341,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +3940,198 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +3940,204 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -3523,6 +3511,12 @@ diff --git a/client.js b/client.js + files.length ? (0, import_react7.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; ++ const bot = roster.byId[sessionId]?.name ?? t("your Bot"); ++ return (0, import_react7.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react7.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); ++} +function GlobeMark() { + return (0, import_react7.createElement)( + "svg", @@ -3697,7 +3691,7 @@ diff --git a/client.js b/client.js if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); }, [relayed, sessionId, turn, source.senderSessionId]); const order = useSessions((list) => { -@@ -4135,23 +4135,29 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4135,23 +4135,30 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); const body = triggerBody(); @@ -3706,7 +3700,8 @@ diff --git a/client.js b/client.js function triggerBody() { + if (source.form === "remote-chat") return null; + if (source.remote?.handle) { -+ return (0, import_react7.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); ++ const message = (0, import_react7.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); ++ return source.remote.firstContact === true ? (0, import_react7.createElement)(import_react7.Fragment, null, (0, import_react7.createElement)(FirstContact, { source, roster, sessionId }), message) : message; + } if (source.kind === "bot") { - if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); @@ -3937,8 +3932,7 @@ diff --git a/client.js b/client.js + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react7.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react7.createElement)(RemoteMessage, { @@ -3951,7 +3945,8 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react7.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); }), pending.map((id) => { @@ -4297,7 +4292,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4785,12 +4785,13 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4785,12 +4785,15 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, @@ -4306,6 +4301,8 @@ diff --git a/client.js b/client.js - bot.role ? (0, import_react8.createElement)("span", { className: "bt-chip" }, bot.role) : null + (0, import_react9.createElement)(BotAvatar, { bot, size: 60, state, main: true }), + (0, import_react9.createElement)("span", { className: "bt-tile-name" }, shown), ++ // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. ++ unread && !waiting && !active ? (0, import_react9.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, + bot.role ? (0, import_react9.createElement)("span", { className: "bt-chip" }, bot.role) : null )); } @@ -5241,14 +5238,12 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5703,9 +5703,9 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { - else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); +@@ -5704,8 +5704,8 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); -- return (0, import_react_dom4.createPortal)( + return (0, import_react_dom4.createPortal)( - (0, import_react9.createElement)( - import_react9.Fragment, -+ return (0, import_react_dom4.createPortal)( + (0, import_react10.createElement)( + import_react10.Fragment, null, @@ -8478,8 +8473,7 @@ diff --git a/client.js b/client.js ), - (0, import_react23.createElement)( + face ? (0, import_react25.createElement)( - Card, -- { title: t("Members · {count}", { count: members.length }) }, ++ Card, + { title: t("Members · {count}", { count: seats.length }) }, + seats.map((seat) => (0, import_react25.createElement)( + "div", @@ -8493,7 +8487,8 @@ diff --git a/client.js b/client.js + )) + ) : null, + face ? null : (0, import_react25.createElement)( -+ Card, + Card, +- { title: t("Members · {count}", { count: members.length }) }, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react25.createElement)( + "div", @@ -10447,6 +10442,14 @@ diff --git a/client.js b/client.js )) ); } +@@ -10978,6 +10978,7 @@ var STORAGE = { + unread: "ds-bot:unread", + reactions: "ds-bot:reactions", + seen: "ds-bot:seen", ++ friendLines: "ds-bot:friend-lines", + prefs: "ds-bot:prefs", + surface: "ds-bot:surface", + agentSessions: "ds-bot:agent-sessions", @@ -11039,7 +11039,7 @@ function migrateStorage(storage = globalThis.localStorage) { } @@ -10752,8 +10755,11 @@ diff --git a/client.js b/client.js useMorph(body, open); - return (0, import_react31.createElement)( + return (0, import_react33.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_react33.createElement)("div", { className: "bt-section-title" }, t(title)), + (0, import_react33.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), + long ? (0, import_react33.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null @@ -10805,11 +10811,8 @@ diff --git a/client.js b/client.js +function FaceMembers({ room: room2, roster, actions }) { + const seats = faceSeats(room2, roster); + return (0, import_react33.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_react33.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: seats.length })), + (0, import_react33.createElement)("div", { className: "bt-members", role: "list" }, seats.map((seat) => { + const label = faceSeatLabel(seat); @@ -11595,6 +11598,29 @@ diff --git a/client.js b/client.js ); } +@@ -13052,6 +13052,22 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random + }; + } + ++// src/client/friend-lines.js ++function friendLineNews(bots, known, isOpen, selfId) { ++ const next = { ...known ?? {} }; ++ const unread = []; ++ for (const bot of bots ?? []) { ++ const at = bot?.friendLineAt; ++ if (bot?.remote || !Number.isFinite(at)) continue; ++ if (selfId && bot.ownerId && bot.ownerId !== selfId) continue; ++ const shown = known?.[bot.id]; ++ if (shown === at) continue; ++ next[bot.id] = at; ++ if (known && (shown === void 0 || at > shown) && !isOpen(bot)) unread.push(bot.id); ++ } ++ return { known: next, unread }; ++} ++ + // src/client/surface.js + function groupsFor({ mode, plain = false }) { + return { shell: mode === "bot", conversation: mode === "bot" && !plain }; @@ -13105,7 +13105,7 @@ function writeMode(mode, storage = globalThis.localStorage) { } @@ -11783,16 +11809,41 @@ diff --git a/client.js b/client.js migrateStorage(); const UNREAD_KEY = STORAGE.unread; let savedUnread = {}; -@@ -13274,7 +13274,7 @@ function apply(ctx) { +@@ -13274,7 +13274,13 @@ function apply(ctx) { } catch { seenAt = {}; } - const ui2 = createSource({ details: null, view: "details", renaming: false, tab: null, newChat: null, exchange: null, menu: null, voice: null, palette: null, quote: null, settings: null, memory: null, toast: null, signIn: null, renameAgent: null, unread: savedUnread, reactions: savedReactions, visits: {} }); ++ let friendLines = null; ++ try { ++ friendLines = JSON.parse(localStorage.getItem(STORAGE.friendLines) ?? "null"); ++ } catch { ++ friendLines = null; ++ } + const ui2 = createSource({ details: null, view: "details", renaming: false, tab: null, newChat: null, exchange: null, menu: null, voice: null, palette: null, quote: null, settings: null, remoteBotSettings: null, memory: null, toast: null, signIn: null, renameAgent: null, unread: savedUnread, reactions: savedReactions, visits: {} }); const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { -@@ -13368,6 +13368,10 @@ function apply(ctx) { +@@ -13344,6 +13344,18 @@ function apply(ctx) { + } + return { ...state, unread }; + }); ++ const noteFriendLines = (value) => { ++ const visited = new Set(Object.keys(ui2.getSnapshot().visits ?? {}).map(seenKey)); ++ const news = friendLineNews(value.bots, friendLines, (bot) => visited.has(bot.id), value.self?.memberId); ++ if (JSON.stringify(news.known) !== JSON.stringify(friendLines ?? null)) { ++ friendLines = news.known; ++ try { ++ localStorage.setItem(STORAGE.friendLines, JSON.stringify(friendLines)); ++ } catch { ++ } ++ } ++ for (const id of news.unread) setUnread(id, true); ++ }; + const refreshed = /* @__PURE__ */ new Set(); + let pulls = 0; + let applied = 0; +@@ -13368,10 +13368,15 @@ function apply(ctx) { const live2 = {}; if (value.connectors !== void 0 && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live2.connectors = value.connectors; if (value.capabilityStatuses !== void 0 && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live2.capabilityStatuses = value.capabilityStatuses; @@ -11803,6 +11854,11 @@ diff --git a/client.js b/client.js if (Object.keys(live2).length > 0 && current.ready) roster.set({ ...current, ...live2 }); if (value.unchanged || value.revision === current.revision && value.modelsKey === current.modelsKey) return; if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map((entry) => entry.id)); + roster.set(indexRoster(value)); ++ noteFriendLines(value); + try { + const ids = (value.agents ?? []).flatMap((agent) => agent.sessions ?? []); + localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)); @@ -13501,7 +13501,7 @@ function apply(ctx) { }; render(); diff --git a/patches/ds-bot/files/src/client/friend-lines.js b/patches/ds-bot/files/src/client/friend-lines.js new file mode 100644 index 000000000..ad5a55c18 --- /dev/null +++ b/patches/ds-bot/files/src/client/friend-lines.js @@ -0,0 +1,24 @@ +// A friend's line in one of my Bots' own chats (#420): it lands among my own messages, so DSH's +// "finished while you were away" mark is not enough (that chat may be the one on screen, or the +// line came while this window was closed). The host stamps the Bot with `friendLineAt`; this +// window keeps the last stamp it has shown per Bot and marks a Bot unread when a newer one +// arrives while its chat is not open. Stamps are compared with stamps, never with this +// browser's clock. A browser with no record yet takes everything already there as shown. + +// `known`: the saved record, or null when this browser has none yet. `isOpen(bot)`: its chat is on +// screen now. Returns the record to save and the Bots to mark unread. +export function friendLineNews(bots, known, isOpen, selfId) { + const next = { ...(known ?? {}) } + const unread = [] + for (const bot of bots ?? []) { + const at = bot?.friendLineAt + if (bot?.remote || !Number.isFinite(at)) continue + // Another member's Bot shared in a group is theirs to read. + if (selfId && bot.ownerId && bot.ownerId !== selfId) continue + const shown = known?.[bot.id] + if (shown === at) continue + next[bot.id] = at + if (known && (shown === undefined || at > shown) && !isOpen(bot)) unread.push(bot.id) + } + return { known: next, unread } +} diff --git a/patches/ds-bot/files/test/flybot.test.js b/patches/ds-bot/files/test/flybot.test.js index 060ca24ef..a6b8320a6 100644 --- a/patches/ds-bot/files/test/flybot.test.js +++ b/patches/ds-bot/files/test/flybot.test.js @@ -268,6 +268,27 @@ test('a remote sender the plugin says typed the line is marked on the message so assert.equal('typedBy' in sources[1].remote, false) }) +test("#420: a friend's line stamps the receiving Bot for its unread mark, and only a sender's first line is first contact", async (t) => { + const { fb, agents, call, stop, grantTo } = await start() + t.after(stop) + await grantTo('a', '@bob.bot') + await grantTo('a', '@carol.bot') + const before = (await call('state', {})).value + assert.equal(before.bots.find(bot => bot.id === 'a').friendLineAt, undefined) + await fb.bot.send({ from: { kind: 'remote', handle: '@bob.bot', name: 'Bob (typed via @bob.bot, Bot Social)', typedBy: 'Bob' }, to: 'a', text: 'hi' }) + const first = (await call('state', {})).value + const stamp = first.bots.find(bot => bot.id === 'a').friendLineAt + assert.ok(Number.isFinite(stamp), 'the Bot carries the stamp') + assert.ok(first.revision > before.revision, 'the stamp moves the revision, so an open window reads it') + assert.equal(first.bots.find(bot => bot.id === 'b').friendLineAt, undefined, 'other Bots are untouched') + assert.equal('remoteContacts' in first, false, 'the senders seen stay on the host') + await fb.bot.send({ from: { kind: 'remote', handle: '@bob.bot', name: 'Bob' }, to: 'a', text: 'again' }) + await fb.bot.send({ from: { kind: 'remote', handle: '@carol.bot', name: 'Carol' }, to: 'a', text: 'me too' }) + const sources = agents.get('a').calls.map(([, m]) => m.source) + assert.deepEqual(sources.map(source => [source.remote.handle, source.remote.firstContact === true]), [['@bob.bot', true], ['@bob.bot', false], ['@carol.bot', true]]) + assert.ok((await call('state', {})).value.bots.find(bot => bot.id === 'a').friendLineAt >= stamp) +}) + test('#226: chatApprovals.changed moves the team revision so a friend chat badge refreshes', async (t) => { const { fb, call, stop } = await start() t.after(stop) diff --git a/patches/ds-bot/files/test/friend-lines.test.js b/patches/ds-bot/files/test/friend-lines.test.js new file mode 100644 index 000000000..b773c8830 --- /dev/null +++ b/patches/ds-bot/files/test/friend-lines.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { friendLineNews } from '../src/client/friend-lines.js' + +const closed = () => false + +test("#420: a friend's line in a Bot's chat that is not open marks that Bot unread once", () => { + const bots = [{ id: 'a', friendLineAt: 200 }, { id: 'b' }] + const news = friendLineNews(bots, { a: 100 }, closed) + assert.deepEqual(news.unread, ['a']) + assert.deepEqual(news.known, { a: 200 }) + // The same stamp again (the next pull) marks nothing: opening the chat cleared the mark. + assert.deepEqual(friendLineNews(bots, news.known, closed).unread, []) +}) + +test("#420: the first friend's line a Bot ever gets is news too, once this browser keeps a record", () => { + assert.deepEqual(friendLineNews([{ id: 'a', friendLineAt: 50 }], {}, closed).unread, ['a']) +}) + +test('#420: a line that arrives while its chat is on screen was seen live', () => { + const news = friendLineNews([{ id: 'a', friendLineAt: 300 }], { a: 100 }, bot => bot.id === 'a') + assert.deepEqual(news.unread, []) + assert.deepEqual(news.known, { a: 300 }, 'recorded, so leaving the chat does not mark it later') +}) + +test('#420: a browser with no record yet takes what is there as shown', () => { + const news = friendLineNews([{ id: 'a', friendLineAt: 300 }, { id: 'b' }], null, closed) + assert.deepEqual(news.unread, []) + assert.deepEqual(news.known, { a: 300 }) + assert.deepEqual(friendLineNews([{ id: 'b' }], null, closed).known, {}, 'an empty record still starts tracking') +}) + +test("#420: a friend's Bot chat and another member's shared Bot are not marked here", () => { + const bots = [{ id: 'r', remote: { handle: '@x.home' }, friendLineAt: 9 }, { id: 'theirs', ownerId: 'm2', friendLineAt: 9 }, { id: 'mine', ownerId: 'm1', friendLineAt: 9 }] + assert.deepEqual(friendLineNews(bots, {}, closed, 'm1').unread, ['mine']) +}) diff --git a/patches/ds-bot/files/test/inbound-ui.test.js b/patches/ds-bot/files/test/inbound-ui.test.js index a2237e0c0..76316afab 100644 --- a/patches/ds-bot/files/test/inbound-ui.test.js +++ b/patches/ds-bot/files/test/inbound-ui.test.js @@ -7,7 +7,7 @@ import { build } from 'esbuild' // The inbound friend conversation's display: trace lines, the thinking row, the remote speaker. const { outputFiles } = await build({ stdin: { - contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker, RemoteMessage } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople } from './src/client/mark.js'", + contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker, RemoteMessage, FirstContact } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'\nexport { roomAvatarPeople } from './src/client/mark.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { stripTrace, previewOf, nameMentions, remoteSpeaker, RemoteMessage, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople } = module.exports +const { stripTrace, previewOf, nameMentions, remoteSpeaker, RemoteMessage, FirstContact, lookOf, lookBotForRemote, remoteSeed, roomAvatarPeople } = module.exports test('a reply opening with a delegation trace shows without it', () => { assert.equal(stripTrace('Chief → 助手\n\nHi Chief! Good to hear from you.'), 'Hi Chief! Good to hear from you.') @@ -201,3 +201,14 @@ test('#275: a typed line from a friend has one short header: the person, then "v assert.ok(via) assert.doesNotMatch(header(known), /typed via|Bot Social|'s Bot/) }) + +test("#420: a sender's first line in a Bot's own chat gets one quiet line naming who talks to which Bot", () => { + const roster = { bots: [], remoteBots: [], byId: { s: { id: 'a', name: 'Chief' } } } + const text = node => flat(node).flatMap(n => (Array.isArray(n.props?.children) ? n.props.children : [])).filter(c => typeof c === 'string').join('') + const typed = FirstContact({ roster, sessionId: 's', source: { remote: { handle: '@chief.selfb1238.home', typedBy: 'SelfB1238', firstContact: true }, senderName: 'SelfB1238 (typed via @chief.selfb1238.home, Bot Social)' } }) + assert.equal(typed.props.className, 'bt-event') + assert.equal(text(typed), 'SelfB1238 is talking to Chief through Bot Social') + const known = { ...roster, remoteBots: [{ handle: '@helper.selfb1238.home', name: 'Helper', owner: 'SelfB1238' }] } + const bot = FirstContact({ roster: known, sessionId: 's', source: { remote: { handle: '@helper.selfb1238.home', firstContact: true }, senderName: 'Helper' } }) + assert.equal(text(bot), 'Helper (SelfB1238) is talking to Chief through Bot Social') +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 2f1970534..3fb2ca813 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -60,7 +60,8 @@ "src/client/create-bot.js", "test/turn-tail.test.js", "src/client/bot-draft.js", - "test/create-bot.test.js" + "test/create-bot.test.js", + "src/client/storage.js" ], "0005-social-seams": [ "src/host/channel.js", diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 0db6e36bf..b466d91d7 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1743,14 +1743,29 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } const homeArgs = () => (home ? { bot_id: home.id } : config.botId ? { bot_id: config.botId } : {}) const peerView = item => ({ relationshipId: item.relationship_id, status: item.status, handle: item.peer?.handle ? norm(item.peer.handle) : undefined, name: cleanText(item.peer?.display_name), owner: cleanText(item.peer?.owner_display_name), message: item.message ?? null, requestedAt: item.requested_at, expiresAt: item.expires_at }) + // A friendship may hang on the home or on one member (a per-Bot invite, #420), so the home and every + // member identity are asked, as setAutoAccept does; one relationship is listed once. A member that + // cannot be read costs only its own rows; the home's answer still decides whether this fails. async function friendsOverview() { const overview = await client.callTool('list_friends', homeArgs()) - return { - home: home ? home.handle : overview?.bot?.handle, - friends: (overview?.friends ?? []).map(peerView), - incoming: (overview?.pending_incoming ?? []).map(peerView), - outgoing: (overview?.pending_outgoing ?? []).map(peerView), + const members = home ? flybot.bot.list().map(bot => ({ bot, member: activeMember(bot.id) })).filter(entry => entry.member && entry.member.bot_id !== home.id) : [] + const answers = [{ overview }, ...await Promise.all(members.map(async ({ bot, member }) => ({ + bot, + overview: await client.callTool('list_friends', { bot_id: member.bot_id }).catch((error) => { warn(`friends of ${bot.name} not read`, error); return undefined }), + })))] + const listed = { friends: [], incoming: [], outgoing: [] } + const done = new Set() + for (const { bot, overview: answer } of answers) { + for (const [key, items] of [['friends', answer?.friends], ['incoming', answer?.pending_incoming], ['outgoing', answer?.pending_outgoing]]) { + for (const item of items ?? []) { + const id = item?.relationship_id + if (id !== undefined && done.has(id)) continue + if (id !== undefined) done.add(id) + listed[key].push({ ...peerView(item), ...(bot ? { botId: bot.id, botName: bot.name } : {}) }) + } + } } + return { home: home ? home.handle : overview?.bot?.handle, ...listed } } disposers.push(flybot.api.handle('social/overview', async () => friendsOverview(), { read: true })) // Invites are per Bot: an invite names one local Bot (its member identity), and a friend who diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index b42ac5ae7..f187b2f37 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -436,6 +436,38 @@ test('files: incoming attachments are chips for the user and untrusted, hash-che await assert.rejects(tool('get_friend_file', { id: 'att_1' }, 'b'), /integrity check/) }) +test('#420: social/overview lists per-Bot friendships and requests, asking the home and every member once per relationship', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = mockServer() + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@selfa.home' } }) + t.after(() => social.dispose()) + // A per-Bot invite registers Chief's member identity; the friendship hangs on it, not on the home. + assert.equal((await call('flybot/social/create-invite', { botId: 'a' })).ok, true) + const chief = server.calls.find(c => c.name === 'add_home_member' && c.args.name === 'chief') + assert.ok(chief) + server.tools.list_friends = a => (a?.bot_id === 'h1' + ? { bot: { handle: '@selfa.home' }, friends: [{ relationship_id: 'r-home', status: 'active', peer: { handle: '@bob.home', display_name: 'Bob' } }], pending_incoming: [], pending_outgoing: [] } + : { friends: [ + { relationship_id: 'r-home', status: 'active', peer: { handle: '@bob.home', display_name: 'Bob' } }, + { relationship_id: 'r-bot', status: 'active', peer: { handle: '@chief.selfb.home', display_name: 'Chief', owner_display_name: 'SelfB' } }, + ], pending_incoming: [{ relationship_id: 'r-req', status: 'pending_target', peer: { handle: '@helper.carol.home', display_name: 'Helper' } }], pending_outgoing: [] }) + server.calls.length = 0 + const overview = (await call('flybot/social/overview', {})).value + assert.equal(overview.home, '@selfa.home') + assert.deepEqual(overview.friends.map(f => [f.relationshipId, f.handle, f.botName ?? null]), [['r-home', '@bob.home', null], ['r-bot', '@chief.selfb.home', 'Chief']]) + assert.deepEqual(overview.incoming.map(f => [f.relationshipId, f.handle, f.botId]), [['r-req', '@helper.carol.home', 'a']]) + const asked = server.calls.filter(c => c.name === 'list_friends').map(c => c.args.bot_id) + assert.equal(asked.length, 2, 'the home and the one member identity, once each') + assert.ok(asked.includes('h1') && asked.some(id => /^m\d+$/.test(id))) + // A member that cannot be read costs only its own rows. + server.tools.list_friends = a => { + if (a?.bot_id !== 'h1') throw new Error('member read failed') + return { bot: { handle: '@selfa.home' }, friends: [{ relationship_id: 'r-home', status: 'active', peer: { handle: '@bob.home', display_name: 'Bob' } }] } + } + assert.deepEqual((await call('flybot/social/overview', {})).value.friends.map(f => f.relationshipId), ['r-home']) +}) + test('add friend from the UI: invite link, join by link, and answer requests (owner click = confirmation)', async (t) => { const { ctx, call, stop } = await team() t.after(stop) From dc8a3979a442e45b77e6d1c6923173cf213a839b Mon Sep 17 00:00:00 2001 From: Birfy Date: Mon, 19 Oct 2026 17:06:42 +0800 Subject: [PATCH 2/2] Note the friend-line stamp in the plugin runtime doc; drop a needless spread fallback --- core/client.js | 2 +- core/src/client/friend-lines.js | 2 +- docs/subsystems/plugin-runtime.md | 1 + patches/ds-bot/0008-client-bundle.patch | 2 +- patches/ds-bot/files/src/client/friend-lines.js | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/core/client.js b/core/client.js index 820d87023..05e0fa8d8 100644 --- a/core/client.js +++ b/core/client.js @@ -15274,7 +15274,7 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random // src/client/friend-lines.js function friendLineNews(bots, known, isOpen, selfId) { - const next = { ...known ?? {} }; + const next = { ...known }; const unread = []; for (const bot of bots ?? []) { const at = bot?.friendLineAt; diff --git a/core/src/client/friend-lines.js b/core/src/client/friend-lines.js index ad5a55c18..f69d9a3df 100644 --- a/core/src/client/friend-lines.js +++ b/core/src/client/friend-lines.js @@ -8,7 +8,7 @@ // `known`: the saved record, or null when this browser has none yet. `isOpen(bot)`: its chat is on // screen now. Returns the record to save and the Bots to mark unread. export function friendLineNews(bots, known, isOpen, selfId) { - const next = { ...(known ?? {}) } + const next = { ...known } const unread = [] for (const bot of bots ?? []) { const at = bot?.friendLineAt diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index c1c6d2d4f..04b793339 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -96,4 +96,5 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots - Changing slots without updating types or client tests. - `flybot.remote.chats.deliver({ handle, text, name?, home?, human?, ownerName? })` (#227): `human: true` is the plugin's server-attested "the friend typed this line themselves" (Bot Social `typed_by_owner`, never derived from text) and `ownerName` the authenticated owner label; the host then draws that turn's reply as the person. Plugins that cannot attest leave both out and the line stays the Bot's. - `flybot.bot.send({ from: { kind: 'remote', handle, home?, typedBy? }, ... })`: `typedBy` is the plugin's server-attested owner name for a line the friend typed (Bot Social `typed_by_owner`); `toRemote` (`src/host/hooks.js`) cleans it and the host stores it as the message source's `remote.typedBy` so the chat draws the person, not the Bot. Never derive it from text; plugins that cannot attest omit it. +- A remote sender's line that reaches a local Bot's own chat through `flybot.bot.send` stamps that Bot's `friendLineAt` in the roster (its row turns unread in a window where the chat is not open) and marks the sender's first line `remote.firstContact` (#420, `src/host/delivery.js`). A refused send stamps nothing. Covered by `core/test/flybot.test.js`. - The approval card queue (`FlyBotApprovals`) runs all its hooks on every render, before its empty-queue early return: a hook after it makes React throw when the first hold arrives and no card ever draws (#254; `core/test/overlay-approvals.test.js`). diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index a11ca7141..630e73f03 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -11604,7 +11604,7 @@ diff --git a/client.js b/client.js +// src/client/friend-lines.js +function friendLineNews(bots, known, isOpen, selfId) { -+ const next = { ...known ?? {} }; ++ const next = { ...known }; + const unread = []; + for (const bot of bots ?? []) { + const at = bot?.friendLineAt; diff --git a/patches/ds-bot/files/src/client/friend-lines.js b/patches/ds-bot/files/src/client/friend-lines.js index ad5a55c18..f69d9a3df 100644 --- a/patches/ds-bot/files/src/client/friend-lines.js +++ b/patches/ds-bot/files/src/client/friend-lines.js @@ -8,7 +8,7 @@ // `known`: the saved record, or null when this browser has none yet. `isOpen(bot)`: its chat is on // screen now. Returns the record to save and the Bots to mark unread. export function friendLineNews(bots, known, isOpen, selfId) { - const next = { ...(known ?? {}) } + const next = { ...known } const unread = [] for (const bot of bots ?? []) { const at = bot?.friendLineAt