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 792f9f50b..dfe0eb2cc 100644 --- a/core/client.js +++ b/core/client.js @@ -593,6 +593,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": "例行任务开始", @@ -3056,9 +3058,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} @@ -5244,6 +5247,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", @@ -5433,7 +5442,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 }); @@ -6140,6 +6150,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 )); } @@ -13075,6 +13087,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", @@ -15340,6 +15353,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 }; @@ -15625,6 +15654,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(); @@ -15695,6 +15730,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; @@ -15727,6 +15774,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..f69d9a3df --- /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 d02b644d1..7d7695130 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -537,6 +537,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 aa1becba3..c942a5006 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -29,6 +29,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' @@ -64,6 +65,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: {} }) @@ -139,6 +143,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 @@ -166,6 +181,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 f06e186ed..46e761bb6 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -142,3 +142,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/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/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 1e6b25277..79177db1b 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -538,11 +538,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 741ed7d64..a71f31a12 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -298,7 +298,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) } @@ -394,6 +394,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' })) @@ -430,14 +439,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. @@ -700,7 +710,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,29 @@ +@@ -1,33 +1,38 @@ +import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js' import { call } from './api.js' import { isHex, inkText, paintCss } from './inks.js' @@ -735,8 +745,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' @@ -763,8 +774,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: {} }) @@ -772,7 +788,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 @@ -781,6 +815,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') }) @@ -1292,7 +1332,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}` : ''}`) @@ -1344,6 +1384,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', { @@ -1708,6 +1750,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 @@ -1734,7 +1787,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} @@ -1750,6 +1803,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 8b8221334..eda502596 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -295,11 +295,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": "例行任务开始", @@ -963,6 +965,14 @@ 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)); @@ -996,23 +1006,29 @@ diff --git a/client.js b/client.js + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); } -- const roomsById = {}; -- for (const room2 of value.rooms) roomsById[room2.id] = room2; -- const agentsById = {}; -- const agentOf = {}; -- for (const agent of value.agents ?? []) { -- agentsById[agent.id] = agent; -- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; + 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", @@ -1024,17 +1040,109 @@ 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); @@ -1074,14 +1182,16 @@ diff --git a/client.js b/client.js + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; } -- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; +- 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); + 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)); @@ -1114,13 +1224,7 @@ 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 } }); @@ -1132,8 +1236,7 @@ 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; @@ -1152,30 +1255,8 @@ 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); @@ -1207,81 +1288,14 @@ 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)); - } --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 Safe({ entry, props }) { + return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, props)); - } --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 useSettingsPages() { + return useSlot("flybot.settings.page"); +} @@ -1387,14 +1401,7 @@ diff --git a/client.js b/client.js + const owner = bot.remote.owner || bot.remote.homeName; + if (owner) return `${bot.name} (${owner})`; + return bot.name; - } -- 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 approval.targetName ?? handle ?? String(approval.request?.to ?? ""); +} +function FlyBotApprovals({ useRoster, actions } = {}) { @@ -1964,7 +1971,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} @@ -1980,6 +1987,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} @@ -2123,10 +2140,18 @@ diff --git a/client.js b/client.js body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} .bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} [data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} -@@ -2804,277 +2804,259 @@ function sheetClassNames(sheet) { - try { - visit(sheet.cssRules); - } catch { +@@ -2796,285 +2796,267 @@ var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; + function sheetClassNames(sheet) { + const names = []; + const visit = (rules) => { +- for (const rule of rules) { +- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); +- if (rule.cssRules) visit(rule.cssRules); +- } +- }; +- try { +- visit(sheet.cssRules); +- } catch { - } - return names; -} @@ -2254,25 +2279,23 @@ diff --git a/client.js b/client.js -} -function AdminStar() { - return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); --} ++ for (const rule of rules) { ++ if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); ++ if (rule.cssRules) visit(rule.cssRules); ++ } ++ }; ++ try { ++ visit(sheet.cssRules); ++ } catch { ++ } ++ return names; + } -function MainStar() { - return (0, import_react2.createElement)( - "svg", - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); -+ } -+ return names; - } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); -- return void 0; +function compileFragmentSelectors(text, names) { + const compiled = /* @__PURE__ */ new Map(); + return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { @@ -2280,29 +2303,19 @@ diff --git a/client.js b/client.js + const exact = []; + for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); + compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); - } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; ++ } + return compiled.get(fragment); + }); } --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -- ); +-function useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; +function installCompiledStyles(text, tag) { + const style = installStyles("", tag); + const cache = /* @__PURE__ */ new WeakMap(); @@ -2316,7 +2329,13 @@ diff --git a/client.js b/client.js + cache.set(sheet, list); + } + for (const name of list) names.add(name); -+ } + } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; + const next = compileFragmentSelectors(text, names); + if (style.textContent !== next) style.textContent = next; + }; @@ -2339,13 +2358,19 @@ diff --git a/client.js b/client.js + style.remove(); + }; } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); + +// src/shared/face-title.js +var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; @@ -2374,24 +2399,17 @@ diff --git a/client.js b/client.js + seen.add(key); + const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); + rows.push({ owner, sort: handle || key }); - } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ } + rows.sort((a, b) => a.sort.localeCompare(b.sort)); + return rows.slice(0, 3).map((row) => row.owner); } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -2402,24 +2420,8 @@ diff --git a/client.js b/client.js + seen.add(key); + out.push(owner); } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + return out.slice(0, 3); +} @@ -2443,7 +2445,19 @@ diff --git a/client.js b/client.js + if (people.length) bits.push(people.join(", ")); + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); -+} + } +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -2470,7 +2484,25 @@ diff --git a/client.js b/client.js + handle: bot.remote.handle, + isChief: bot.remote.isChief + }); -+ } + } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) +- ); + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; @@ -3468,7 +3500,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 }); }), @@ -3491,28 +3523,37 @@ diff --git a/client.js b/client.js if (whole && bots.length === 1) { - return (0, import_react6.createElement)( + return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-event" }, + "div", + { className: "bt-event" }, +- (0, import_react6.createElement)( + (0, import_react7.createElement)( -+ "button", -+ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, + "button", + { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) + (0, import_react7.createElement)("span", null, prefix), + (0, import_react7.createElement)(BotMark, { bot: bots[0], size: 16 }), + (0, import_react7.createElement)("span", { className: "bt-event-name" }, bots[0].name) -+ ) -+ ); -+ } -+ const parts = []; + ) + ); + } + const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); + parts.push((0, import_react7.createElement)("span", { key: "p" }, prefix)); -+ bots.forEach((bot, index) => { + bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( + if (index > 0) parts.push((0, import_react7.createElement)("span", { key: `j${index}` }, joiner)); + parts.push((0, import_react7.createElement)( -+ "button", -+ { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, + "button", + { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), + (0, import_react7.createElement)(BotMark, { bot, size: 16 }), -+ bot.name -+ )); -+ }); + bot.name + )); + }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); + return (0, import_react7.createElement)("div", { className: "bt-event" }, parts); +} +var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); @@ -3527,13 +3568,16 @@ diff --git a/client.js b/client.js + const held = heldForm(item.text); + return held !== "" && (held === line || held.length >= 799 && line.startsWith(held)); + }); -+} -+function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { + } + function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( + const lead = (0, import_react7.createElement)( -+ "button", -+ { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, + "button", + { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + (0, import_react7.createElement)(BotMark, { bot, size: 22 }) -+ ); + ); +- return (0, import_react6.createElement)( + const name = (0, import_react7.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -3594,15 +3638,7 @@ diff --git a/client.js b/client.js + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { + return (0, import_react7.createElement)( - "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) ++ "div", + { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, + // One short header: the person, and the Bot that carried the line only when it is known (#275). + (0, import_react7.createElement)( @@ -3613,28 +3649,8 @@ diff --git a/client.js b/client.js + ), + (0, import_react7.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react7.createElement)("span", { className: "bt-lead", title: person }, (0, import_react7.createElement)(PersonMark)) }), + files.length ? (0, import_react7.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null - ); - } -- const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); -- bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( -- "button", -- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- bot.name -- )); -- }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); --} --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) -- ); -- return (0, import_react6.createElement)( ++ ); ++ } + const mark = (0, import_react7.createElement)(BotMark, { bot, size: 22 }); + const lead = chatId ? (0, import_react7.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react7.createElement)("span", { className: "bt-lead", title: handle }, mark); + return (0, import_react7.createElement)( @@ -3655,6 +3671,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", @@ -3829,7 +3851,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(); @@ -3838,7 +3860,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 }); @@ -4069,7 +4092,8 @@ 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, { @@ -4082,8 +4106,7 @@ 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) => { @@ -4429,7 +4452,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 }, @@ -4438,6 +4461,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 )); } @@ -5638,10 +5663,14 @@ diff --git a/client.js b/client.js }, onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 }; -@@ -5995,6 +5995,83 @@ function atSources({ roster, sessions, launched, iconFor }) { - return [bots, chats]; - } - +@@ -5992,7 +5992,84 @@ function atSources({ roster, sessions, launched, iconFor }) { + }, + codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) } + }; +- return [bots, chats]; ++ return [bots, chats]; ++} ++ +// src/client/face-at-filter.js +var REFERENCE_SOURCE = "reference"; +var optionId = (source, index) => `dsh-slash-option-${source}-${index}`; @@ -5717,11 +5746,9 @@ diff --git a/client.js b/client.js + unsubscribe?.(); + style.remove(); + }; -+} -+ + } + // src/client/speech.js - var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; - var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); @@ -6122,15 +6122,15 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { } @@ -8788,7 +8815,10 @@ diff --git a/client.js b/client.js + (0, import_react25.createElement)(BotMark, { bot, size: 22 }), + (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, bot.name)), + (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Add")) -+ )), + )), +- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) +- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, +- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( + ...outsideRemotes.map((bot) => (0, import_react25.createElement)( + "button", + { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { @@ -8798,10 +8828,7 @@ diff --git a/client.js b/client.js + (0, import_react25.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }), + (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), + (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot")) - )), -- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) -- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, -- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( ++ )), + (0, import_react25.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) + ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react25.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react25.createElement)(PlusIcon), t("Add member")) : null, + (0, import_react25.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react25.createElement)( @@ -9758,46 +9785,49 @@ diff --git a/client.js b/client.js + } }, t("Choose apps")) : null, + on ? (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-expanded": bots, onClick: () => setBots((value) => !value) }, bots ? t("Done") : t("Choose Bots")) : null, + on ? (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorDisconnect(connector.id)) }, t("Disconnect")) : form ? null : (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) - ) - ), -- !on && form ? (0, import_react25.createElement)( ++ ) ++ ), + on ? (0, import_react27.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, + on && bots ? (0, import_react27.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, + !on && form ? (0, import_react27.createElement)( - "div", - { className: "bt-connector-form" }, -- (0, import_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), -- (0, import_react25.createElement)( ++ "div", ++ { className: "bt-connector-form" }, + (0, import_react27.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react27.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), + gateway ? (0, import_react27.createElement)(AppPicker, { apps, onChange: setApps }) : null, + (0, import_react27.createElement)( - "div", - { className: "bt-connector-form-row" }, -- (0, import_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), -- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { ++ "div", ++ { className: "bt-connector-form-row" }, + (0, import_react27.createElement)("a", { className: "bt-connector-link", href: gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, gateway ? t("Get a Composio API key") : t("Create a token on GitHub")), + (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { - setForm(false); - setToken(""); - setError(null); - } }, t("Cancel")), -- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) ++ setForm(false); ++ setToken(""); ++ setError(null); ++ } }, t("Cancel")), + (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) -+ ) + ) +- ), +- !on && form ? (0, import_react25.createElement)( + ) : null, + on && gateway && picking ? (0, import_react27.createElement)( -+ "div", -+ { className: "bt-connector-form" }, + "div", + { className: "bt-connector-form" }, +- (0, import_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), +- (0, import_react25.createElement)( + (0, import_react27.createElement)(AppPicker, { apps, onChange: setApps }), + (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("The first time a Bot uses an app, it shows a sign-in link in the chat.")), + (0, import_react27.createElement)( -+ "div", -+ { className: "bt-connector-form-row" }, + "div", + { className: "bt-connector-form-row" }, +- (0, import_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), +- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { +- setForm(false); +- setToken(""); + (0, import_react27.createElement)("span", { className: "bt-connector-link" }), + (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + setPicking(false); -+ setError(null); -+ } }, t("Cancel")), + setError(null); + } }, t("Cancel")), +- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) + (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) ) ) : null, @@ -10668,6 +10698,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) { } @@ -11816,6 +11854,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) { } @@ -12004,16 +12065,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; @@ -12024,6 +12110,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..f69d9a3df --- /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)