diff --git a/CHANGELOG.md b/CHANGELOG.md index c44f52a98..b2530d1a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ All notable changes to FlyBot (earlier: DS Bot) are listed here. The format foll ### Changed -- **Face-to-face group lives under + Add (#92).** Friends' Bots no longer has an inline **Join with a number** form. **+ Add** opens a **Face-to-face group** screen: pick one or more of your Bots (multi-select; default: the Main Bot), type the 6-digit number, and join at once (no preview). Several selected Bots join the same room (one `social/face-join` per Bot; partial failures stay on screen). A live waiting room lists who joined (Bot and owner; yours marked **mine**) with the countdown, **Enter chat** (starts the room and opens it), and **Leave**. If someone else starts the chat, this screen opens it. Joining an unregistered Bot still registers it for that room only; the screen does not mention published. ([#92](https://github.com/ThinkFlowLab/FlyBot/issues/92)) +- **Friends' Bots on a high-latency link.** The panel no longer blocks on `Loading…`. It paints a cached roster immediately (stale-while-revalidate) with a small **Refreshing…** mark, and the mount path starts `social/remote-bots`, `social/settings`, and `social/status` together. Grant listing no longer holds the roster (and runs with a concurrency cap). MCP/REST share a keep-alive HTTP agent instead of a new TLS handshake per call. Approvals poll every 15s and pause while the panel is collapsed or the window is hidden; face-room cards do not hammer `GET /v1/face-rooms` unless a room is still a candidate. ([#96](https://github.com/ThinkFlowLab/FlyBot/pull/96) follow-up) +- **Face-to-face group lives on the sidebar + menu (#92).** The global **+** (next to Search) opens the new-chat picker with **Face-to-face group** / **面对面建群** right after **Create group chat** / **新建群聊**. That row uses the same `t()` map as its neighbours (`flybot.new-chat.action`). It still appears under Friends' Bots **+ Add**, including while the roster is loading. ([#92](https://github.com/ThinkFlowLab/FlyBot/issues/92)) +- **Face-to-face group join screen (#92).** Friends' Bots no longer has an inline **Join with a number** form. **Face-to-face group** opens a screen: pick one or more of your Bots (multi-select; default: the Main Bot), type the 6-digit number, and join at once (no preview). Several selected Bots join the same room (one `social/face-join` per Bot; partial failures stay on screen). A live waiting room lists who joined (Bot and owner; yours marked **mine**) with the countdown, **Enter chat** (starts the room and opens it), and **Leave**. If someone else starts the chat, this screen opens it. Joining an unregistered Bot still registers it for that room only; the screen does not mention published. ([#92](https://github.com/ThinkFlowLab/FlyBot/issues/92)) - **Per-Bot grants replace `published` (#90).** There is no global “Published to friends” toggle and no “publish all my Bots” concept. Every access is an independent grant: one of my Bots → one person (optionally scoped to a group). Default deny, Chief included. Accepting **Invite to this Bot** creates the grant; **Manage access** on that Bot lists who has it, in which groups, and **Revoke**. Friends' Bots lists only Bots granted to me. A granted Bot can be chatted with and added to a group; inside that group every human may talk to it only there and only within the Bot's own authority. Bringing my own Bot into a face-to-face room by its 6-digit number is an implicit room-scoped grant (room-only auto-registration from #89), never visible outside the room. Upgrading means no Bot is shared until it is granted. Core `reach()` is grant-based; `@flybot/social` isolates grant/invite server calls in `src/mcp-client.js`. ([#90](https://github.com/ThinkFlowLab/FlyBot/issues/90)) ### Added @@ -20,6 +22,7 @@ All notable changes to FlyBot (earlier: DS Bot) are listed here. The format foll ### Fixed +- **Remove this Bot** no longer comes back after ×. A hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle (or a real revoke), never on a failed, partial, or legacy `list_friends` fetch. Hidden handles stay out of `flybot.remote.publish`; gone and home-level remotes (`Friend` at `@something.home`) are hidden in the sidebar and the **发给 / 和谁聊…** picker, with history kept. Picker rows use the same owner label as the sidebar (` · 's Bot`, Chief → `'s Chief`). - **Remove this Bot** no longer waits for room leave or a roster refetch: the hide is saved and the filtered list returns immediately; rooms leave in parallel and a fresh fetch continues in the background. Concurrent removes keep their own “Removing…” state. The control is a compact × on row hover/focus (keyboard and the right-click menu still reach it). - Peer-published Bot labels (`name`, `homeName`, `owner`) are sanitized at `cleanRemote`: `@` and control characters/newlines are stripped, then the 60-character cap still applies, so a display name cannot impersonate another member's handle. ([#75](https://github.com/ThinkFlowLab/FlyBot/issues/75)) - A chat with a friend's Bot that Bot Social has paused (turn budget reached, loop suspicion, emergency stop) now says so and points at the Bot Social console, instead of failing with a bare "Not sent: …" card. A paused conversation is no longer retried with the friendship's other Bots. ([#72](https://github.com/ThinkFlowLab/FlyBot/issues/72)) diff --git a/README.md b/README.md index 4a54d0cd6..431738d8c 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Every local Bot can chat with friends' Bots through a [Bot Social](https://githu - **One-click onboarding.** On first start with a `server` configured, FlyBot calls `POST /v1/onboard/flybot`, creates your home (`@.home`), and stores credentials in `$DSH_HOME/bot/flybot-social.json` (mode 600). - **Members.** Access is a per-Bot **grant** (default deny, Chief included). A local Bot becomes `@.` on the server when you invite someone to that Bot, when it joins an invite, or when you bring it into a face-to-face room. Friends' Bots lists only Bots granted to you. **Manage access** on a Bot shows who has it and **Revoke**. Deleted Bots are revoked. Ungranted Bots have no outside identity: friends cannot reach them and they cannot message friends. -- **Friends' Bots sidebar.** Friends appear under **Friends' Bots**, grouped by home, with a remote badge. Click one to open a normal chat. Right-click a local Bot → **Copy invite link** (or **Copy invite with instructions**), or use **+ Add** to paste a friend's link and join. +- **Friends' Bots sidebar.** Friends appear under **Friends' Bots**, grouped by home, with a remote badge. Click one to open a normal chat. Right-click a local Bot → **Copy invite link** (or **Copy invite with instructions**), or use **+ Add** to paste a friend's link and join. **Face-to-face group** is on the sidebar **+** (next to Search) and under **+ Add**. - **Approvals.** Inbound messages from a friend arrive immediately. Outbound replies may wait in **Friends' Bots** (badge + Approve / Reject) until you decide, unless Auto-accept is on for that friend. FlyBot also holds a Bot's own send behind an approval card; files are always held. - **Tools.** Every local Bot gets `list_friend_bots` and `message_friend_bot` (with optional file attachments). diff --git a/README.zh.md b/README.zh.md index bd4316d29..3b0de834b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -62,7 +62,7 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab - **一键入驻。** 配置好 `server` 后首次启动会调用 `POST /v1/onboard/flybot`,创建家(`@.home`),凭证写在 `$DSH_HOME/bot/flybot-social.json`(权限 600)。 - **成员。** 访问是按 Bot 的**授权**(默认拒绝,Chief 也一样)。本地 Bot 在你邀请别人使用它、它加入邀请、或你把它带进面对面房间时,才会在服务器上成为 `@.`。**「好友的 Bot」** 只列出别人授权给你的 Bot。每个 Bot 的 **管理访问** 列出谁有它,并可 **撤销**。删除 Bot 会撤销其身份。未授权的 Bot 没有对外身份:朋友够不到它,它也不能给朋友发消息。 -- **「好友的 Bot」侧栏。** 按家分组,带远端标记;点开就是普通对话。本地 Bot 右键 → **复制邀请链接**(或带说明的邀请),或用 **+ 添加** 粘贴好友的链接加入。 +- **「好友的 Bot」侧栏。** 按家分组,带远端标记;点开就是普通对话。本地 Bot 右键 → **复制邀请链接**(或带说明的邀请),或用 **+ 添加** 粘贴好友的链接加入。**面对面建群** 在侧栏顶部 **+**(搜索旁边)以及 **+ 添加** 里。 - **审批。** 好友发来的消息会立刻送达。你的 Bot 发出的回复可能停在 **「好友的 Bot」**(角标 + 批准 / 拒绝),除非对该好友开了自动接受。FlyBot 本地也会暂扣 Bot 自己发起的发送;文件一律先审批。 - **工具。** 每个本地 Bot 都有 `list_friend_bots` 与 `message_friend_bot`(可带附件)。 diff --git a/core/client.js b/core/client.js index a2018fab8..975f63898 100644 --- a/core/client.js +++ b/core/client.js @@ -99,6 +99,9 @@ var ZH = { "Remove Main Bot role": "取消主 Bot", "Bot settings": "Bot 设置", "Create group chat": "新建群聊", + "Face-to-face group": "面对面建群", + "Meet the people next to you. Pick your Bots and type the same 6-digit number.": "和身边的人建群。选你的 Bot,输入同一组 6 位数字。", + "Refreshing…": "正在刷新…", "Delete group": "删除群聊", "Open {name}'s chat": "打开 {name} 的对话", "New Bot": "新 Bot", @@ -1147,8 +1150,84 @@ var call = async (endpoint, payload, signal) => { return result.value; }; +// src/shared/remote-label.js +function cleanPeerName(name, fallback = "") { + const text = String(name ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"`<>]/g, "").replace(/\s+/g, " ").trim().slice(0, 60); + return text || fallback; +} +function normRemoteHandle(handle) { + return `@${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`; +} +function ownerFromHandle(handle) { + const parts = String(handle ?? "").replace(/^@/, "").split("."); + if (parts.length >= 3 && parts.at(-1) === "home") return parts.at(-2); + if (parts.length === 2 && parts[1] === "home") return parts[0]; + return ""; +} +function isHomeLevelRemote(record) { + const handle = String(record?.handle ?? record?.remote?.handle ?? ""); + const key = normRemoteHandle(handle); + const parts = key.replace(/^@/, "").split("."); + return parts.length === 2 && parts[1] === "home"; +} +function isListedRemoteChat(bot) { + if (!bot) return false; + if (bot.hidden) return false; + const remote = bot.remote; + if (!remote || typeof remote.handle !== "string") return true; + if (remote.gone || remote.hidden) return false; + if (isHomeLevelRemote(remote)) return false; + return true; +} +function listedChatBots(roster) { + return (roster?.bots ?? []).filter(isListedRemoteChat); +} +function pickerBotLabel(bot) { + if (!bot) return ""; + if (!bot.remote) return bot.name ?? ""; + return remoteBotTitle({ + displayName: bot.name, + name: bot.name, + ownerName: bot.remote.owner || bot.remote.homeName, + owner: bot.remote.owner, + homeName: bot.remote.homeName, + home: bot.remote.home, + handle: bot.remote.handle, + isChief: bot.remote.isChief + }); +} +function ownerNameOf(record) { + if (!record) return ""; + return cleanPeerName(record.ownerName || record.homeName || record.owner || record.ownerHome || record.home || ownerFromHandle(record.handle), ""); +} +function isChiefRecord(record) { + if (!record) return false; + if (record.isChief === true) return true; + return /^chief$/i.test(String(record.displayName || record.name || "")); +} +function remoteBotTitle(record) { + const owner = ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); + const name = cleanPeerName(record?.displayName || record?.name, cleanPeerName(record?.handle, "Bot")); + if (isChiefRecord(record)) return `${owner}'s Chief`; + return `${name} · ${owner}'s Bot`; +} +function remoteOwnerBadge(record) { + const owner = ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); + return isChiefRecord(record) ? `${owner}'s Chief` : `${owner}'s Bot`; +} +function sharedRoomTitle(inviterName) { + return `Shared room from ${cleanPeerName(inviterName, "a friend")}`; +} +function disambiguatedRemoteTitle(record, others = []) { + const title = remoteBotTitle(record); + const name = cleanPeerName(record?.displayName || record?.name); + if (!name) return title; + const clash = others.some((item) => item !== record && cleanPeerName(item?.displayName || item?.name) === name && ownerNameOf(item) !== ownerNameOf(record)); + return clash ? title : title; +} + // src/client/flybot-registry.js -var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock"]; +var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action"]; function createRegistry() { const entries2 = new Map(SLOTS.map((name) => [name, []])); const listeners2 = /* @__PURE__ */ new Set(); @@ -1185,6 +1264,25 @@ function registry() { root.__flybot__ ??= { slots: createRegistry() }; return root.__flybot__.slots; } +function newChatActionRows(context = {}) { + const rows = []; + for (const entry of registry().entries("flybot.new-chat.action")) { + try { + if (entry.visible && entry.visible(context) === false) continue; + rows.push({ + key: entry.id, + order: entry.order ?? 0, + label: entry.label, + hint: entry.hint, + run: entry.run ?? (() => { + }) + }); + } catch (error) { + console.error(`flybot: ${entry.id} new-chat action failed`, error); + } + } + return rows.sort((a, b) => a.order - b.order); +} async function mentionCandidates(query, context) { const rows = []; for (const entry of registry().entries("flybot.composer.mention")) { @@ -1354,7 +1452,7 @@ function useSidebarStructure() { function FlyBotSections({ roster, actions, renderBot }) { const shell = (actions.flybotSlotEntries?.("flybot.sidebar.section") ?? []).map((entry, index) => ({ id: entry.id ?? `slot-${index}`, order: entry.order ?? 0, component: entry.component })); const entries2 = [...useSlot("flybot.sidebar.section"), ...shell].sort((a, b) => a.order - b.order); - const remoteChats = (roster.bots ?? []).filter((bot) => bot.remote); + const remoteChats = (roster.bots ?? []).filter((bot) => bot.remote && isListedRemoteChat(bot)); return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( "div", { key: entry.id, className: "bt-flybot-section", "data-flybot": entry.id }, @@ -4149,47 +4247,6 @@ var SECRET_CSS = ` @media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} `; -// src/shared/remote-label.js -function cleanPeerName(name, fallback = "") { - const text = String(name ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"`<>]/g, "").replace(/\s+/g, " ").trim().slice(0, 60); - return text || fallback; -} -function ownerFromHandle(handle) { - const parts = String(handle ?? "").replace(/^@/, "").split("."); - if (parts.length >= 3 && parts.at(-1) === "home") return parts.at(-2); - if (parts.length === 2 && parts[1] === "home") return parts[0]; - return ""; -} -function ownerNameOf(record) { - if (!record) return ""; - return cleanPeerName(record.ownerName || record.homeName || record.owner || record.ownerHome || record.home || ownerFromHandle(record.handle), ""); -} -function isChiefRecord(record) { - if (!record) return false; - if (record.isChief === true) return true; - return /^chief$/i.test(String(record.displayName || record.name || "")); -} -function remoteBotTitle(record) { - const owner = ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); - const name = cleanPeerName(record?.displayName || record?.name, cleanPeerName(record?.handle, "Bot")); - if (isChiefRecord(record)) return `${owner}'s Chief`; - return `${name} · ${owner}'s Bot`; -} -function remoteOwnerBadge(record) { - const owner = ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); - return isChiefRecord(record) ? `${owner}'s Chief` : `${owner}'s Bot`; -} -function sharedRoomTitle(inviterName) { - return `Shared room from ${cleanPeerName(inviterName, "a friend")}`; -} -function disambiguatedRemoteTitle(record, others = []) { - const title = remoteBotTitle(record); - const name = cleanPeerName(record?.displayName || record?.name); - if (!name) return title; - const clash = others.some((item) => item !== record && cleanPeerName(item?.displayName || item?.name) === name && ownerNameOf(item) !== ownerNameOf(record)); - return clash ? title : title; -} - // src/client/turn-reasoning.js function turnReasoning(steps, current) { const members = steps.length ? steps : [current]; @@ -5298,7 +5355,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio const away = !running && groups.length > 0; const state = botState(running || away, doing, alert); const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; - if (bot.hidden && !active && !main) return null; + if (!isListedRemoteChat(bot) && !active && !main) return null; const onContextMenu = (event) => { event.preventDefault(); actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); @@ -6494,7 +6551,7 @@ function mentionRows(roster, sessionId, query = "") { ...here.room.members.map((id) => roster.byId[id]).filter(Boolean), ...(here.room.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), ...(here.room.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, handle: p.handle })) - ] : roster.bots.filter((bot) => bot.id !== here.self.id); + ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id); const needle = query.trim().toLowerCase(); const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` })); const everyone = t("Everyone"); @@ -6774,10 +6831,10 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted)); const sections = []; const people = [ - ...roster.bots.filter((bot) => matchesName(bot.name, bot.role)).map((bot) => ({ + ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({ key: bot.id, avatar: (0, import_react13.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), - title: bot.name, + title: pickerBotLabel(bot), subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "), badge: bot.hidden ? t("Hidden") : void 0, run: async () => { @@ -8945,9 +9002,10 @@ function NewChat({ mode, roster, actions }) { useEscape(close); const paneLeft = usePaneLeft(); const wanted = query.trim().toLowerCase(); - const matches = roster.bots.filter((bot) => !picked.includes(bot.id) && bot.id !== admin && (wanted === "" || bot.name.toLowerCase().includes(wanted) || (bot.role ?? "").toLowerCase().includes(wanted))); + const bots = listedChatBots(roster); + const matches = bots.filter((bot) => !picked.includes(bot.id) && bot.id !== admin && (wanted === "" || bot.name.toLowerCase().includes(wanted) || pickerBotLabel(bot).toLowerCase().includes(wanted) || (bot.role ?? "").toLowerCase().includes(wanted))); const choosingAdmin = group && admin === null; - const exact = roster.bots.some((bot) => bot.name.toLowerCase() === wanted); + const exact = bots.some((bot) => bot.name.toLowerCase() === wanted); const pickedBots = picked.map((id) => roster.byId[id]).filter(Boolean); const run = async (task) => { if (busy) return; @@ -9032,6 +9090,15 @@ function NewChat({ mode, roster, actions }) { setCursor(0); inputRef.current?.focus(); } }); + for (const action of newChatActionRows({ mode, group, picked, query, roster })) { + options.push({ + key: action.key, + icon: (0, import_react21.createElement)(PeopleIcon), + label: t(action.label), + hint: action.hint ? t(action.hint) : void 0, + run: () => action.run({ close, actions, roster }) + }); + } if ((roster.agents ?? []).length === 0) { options.push({ key: "agent", icon: (0, import_react21.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => { await actions.addAgent(); @@ -9040,7 +9107,7 @@ function NewChat({ mode, roster, actions }) { } } for (const bot of matches) { - options.push({ key: bot.id, bot, label: bot.name, hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) }); + options.push({ key: bot.id, bot, label: pickerBotLabel(bot), hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) }); } if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react21.createElement)(PlusIcon), label: t("Create new Bot “{name}”", { name: query.trim() }), run: createBot }); const active = Math.min(cursor, Math.max(options.length - 1, 0)); diff --git a/core/src/client/at-menu.js b/core/src/client/at-menu.js index aa7073af1..50129fbec 100644 --- a/core/src/client/at-menu.js +++ b/core/src/client/at-menu.js @@ -4,7 +4,7 @@ // Both are sources of the shell's trigger menu; the shell's own file and session // rows stay beside them, because a source cannot be hidden for one Session. import { mentionCandidates, remoteMentionRows } from './flybot-registry.js' -import { remoteBotTitle } from '../shared/remote-label.js' +import { remoteBotTitle, listedChatBots } from '../shared/remote-label.js' import { t } from './i18n.js' import { currentPart, relativeShort } from './text.js' @@ -35,7 +35,7 @@ export function mentionRows(roster, sessionId, query = '') { ...(here.room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) .map(p => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle })), ] - : roster.bots.filter(bot => bot.id !== here.self.id) + : listedChatBots(roster).filter(bot => bot.id !== here.self.id) const needle = query.trim().toLowerCase() const rows = bots.filter(bot => bot.name.toLowerCase().includes(needle)).map(bot => ({ bot, text: `@${bot.name} ` })) const everyone = t('Everyone') diff --git a/core/src/client/flybot-registry.js b/core/src/client/flybot-registry.js index f00f04785..891720798 100644 --- a/core/src/client/flybot-registry.js +++ b/core/src/client/flybot-registry.js @@ -1,6 +1,6 @@ // The FlyBot slot registry, free of React so the "@" menu and tests can use it. // See flybot-slots.js for what each slot carries. -export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock'] +export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action'] export function createRegistry() { const entries = new Map(SLOTS.map(name => [name, []])) @@ -41,6 +41,26 @@ export function registry() { } // Extra "@" rows from plugins (remote Bots and the like), after the team's own Bots. +// Extra rows in the sidebar '+' new-chat menu (Create new Bot / Create group chat / …). +export function newChatActionRows(context = {}) { + const rows = [] + for (const entry of registry().entries('flybot.new-chat.action')) { + try { + if (entry.visible && entry.visible(context) === false) continue + rows.push({ + key: entry.id, + order: entry.order ?? 0, + label: entry.label, + hint: entry.hint, + run: entry.run ?? (() => {}), + }) + } catch (error) { + console.error(`flybot: ${entry.id} new-chat action failed`, error) + } + } + return rows.sort((a, b) => a.order - b.order) +} + export async function mentionCandidates(query, context) { const rows = [] for (const entry of registry().entries('flybot.composer.mention')) { diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 9cbda2bed..8a7c962f0 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -13,16 +13,18 @@ // flybot.approvals { id, order, component({ approval, decide, call }) } custom card for a held send // flybot.room.overview { id, order, component({ room, roster, actions, call }) } top of a group's overview // flybot.session.dock { id, order, component({ sessionId, roster, room, bot, actions, call }) } above the composer +// flybot.new-chat.action { id, order, label, hint?, visible?, run({ close, actions, roster }) } a row in the sidebar '+' menu (next to Create group chat) // // Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry). import { Component, createElement as h, Fragment, useEffect, useState } from 'react' import { call as callHost } from './api.js' +import { isListedRemoteChat } from '../shared/remote-label.js' import { registry as baseRegistry } from './flybot-registry.js' import { t } from './i18n.js' import { createClientApi as createClientApiWith } from './flybot-registry.js' -export { SLOTS, createRegistry, mentionCandidates, botTree } from './flybot-registry.js' +export { SLOTS, createRegistry, mentionCandidates, newChatActionRows, botTree } from './flybot-registry.js' // Calls a FlyBot endpoint on the host: "approvals", "decide", or a plugin's "social/...". export const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payload) @@ -72,7 +74,7 @@ export function FlyBotSections({ roster, actions, renderBot }) { const shell = (actions.flybotSlotEntries?.('flybot.sidebar.section') ?? []) .map((entry, index) => ({ id: entry.id ?? `slot-${index}`, order: entry.order ?? 0, component: entry.component })) const entries = [...useSlot('flybot.sidebar.section'), ...shell].sort((a, b) => a.order - b.order) - const remoteChats = (roster.bots ?? []).filter(bot => bot.remote) + const remoteChats = (roster.bots ?? []).filter(bot => bot.remote && isListedRemoteChat(bot)) return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-section', 'data-flybot': entry.id }, h(Safe, { entry, props: { roster, actions, call, renderBot, remoteChats } })))) } diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 78a3f6b3a..9cfe561fe 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -62,6 +62,9 @@ const ZH = { 'Remove Main Bot role': '取消主 Bot', 'Bot settings': 'Bot 设置', 'Create group chat': '新建群聊', + 'Face-to-face group': '面对面建群', + 'Meet the people next to you. Pick your Bots and type the same 6-digit number.': '和身边的人建群。选你的 Bot,输入同一组 6 位数字。', + 'Refreshing…': '正在刷新…', 'Delete group': '删除群聊', "Open {name}'s chat": '打开 {name} 的对话', 'New Bot': '新 Bot', diff --git a/core/src/client/new-chat.js b/core/src/client/new-chat.js index 4f87e382c..69653541c 100644 --- a/core/src/client/new-chat.js +++ b/core/src/client/new-chat.js @@ -6,6 +6,8 @@ import { useEscape, usePaneLeft } from './overlay-hooks.js' import { CreateBot } from './create-bot.js' import { QUICK_EDGES, useFlip, useGlide, useMorph } from './motion.js' import { t } from './i18n.js' +import { newChatActionRows } from './flybot-registry.js' +import { listedChatBots, pickerBotLabel } from '../shared/remote-label.js' export function NewChat({ mode, roster, actions }) { const [query, setQuery] = useState('') @@ -24,9 +26,10 @@ export function NewChat({ mode, roster, actions }) { useEscape(close) const paneLeft = usePaneLeft() const wanted = query.trim().toLowerCase() - const matches = roster.bots.filter(bot => !picked.includes(bot.id) && bot.id !== admin && (wanted === '' || bot.name.toLowerCase().includes(wanted) || (bot.role ?? '').toLowerCase().includes(wanted))) + const bots = listedChatBots(roster) + const matches = bots.filter(bot => !picked.includes(bot.id) && bot.id !== admin && (wanted === '' || bot.name.toLowerCase().includes(wanted) || pickerBotLabel(bot).toLowerCase().includes(wanted) || (bot.role ?? '').toLowerCase().includes(wanted))) const choosingAdmin = group && admin === null - const exact = roster.bots.some(bot => bot.name.toLowerCase() === wanted) + const exact = bots.some(bot => bot.name.toLowerCase() === wanted) const pickedBots = picked.map(id => roster.byId[id]).filter(Boolean) const run = async (task) => { if (busy) return @@ -96,13 +99,22 @@ export function NewChat({ mode, roster, actions }) { if (!group && picked.length === 0 && mode !== 'search' && wanted === '') { options.push({ key: 'new', icon: h(PlusIcon), label: t('Create new Bot'), run: createBot }) options.push({ key: 'group', icon: h(PeopleIcon), label: t('Create group chat'), run: () => { setGroup(true); setCursor(0); inputRef.current?.focus() } }) + for (const action of newChatActionRows({ mode, group, picked, query, roster })) { + options.push({ + key: action.key, + icon: h(PeopleIcon), + label: t(action.label), + hint: action.hint ? t(action.hint) : undefined, + run: () => action.run({ close, actions, roster }), + }) + } // A team holds at most one DSH Agent; the entry exists only while there is none. if ((roster.agents ?? []).length === 0) { options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add DSH Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) } } for (const bot of matches) { - options.push({ key: bot.id, bot, label: bot.name, hint: choosingAdmin ? t('Make admin') : group || picked.length > 0 ? t('Add to group chat') : undefined, run: () => openBot(bot) }) + options.push({ key: bot.id, bot, label: pickerBotLabel(bot), hint: choosingAdmin ? t('Make admin') : group || picked.length > 0 ? t('Add to group chat') : undefined, run: () => openBot(bot) }) } if (wanted !== '' && !exact && mode !== 'search') options.push({ key: 'create', icon: h(PlusIcon), label: t('Create new Bot “{name}”', { name: query.trim() }), run: createBot }) const active = Math.min(cursor, Math.max(options.length - 1, 0)) diff --git a/core/src/client/palette.js b/core/src/client/palette.js index 7de15ff58..04457974e 100644 --- a/core/src/client/palette.js +++ b/core/src/client/palette.js @@ -7,6 +7,7 @@ import { BotAvatar, RoomAvatar } from './mark.js' import { useEscape } from './overlay-hooks.js' import { QUICK_EDGES, useFlip, useGlide } from './motion.js' import { t } from './i18n.js' +import { isListedRemoteChat, pickerBotLabel } from '../shared/remote-label.js' // Search is a command palette: a 560px dialog, a search header, and 49px // rows (24px avatar, title, subtitle). Bots and groups match on name and role; past @@ -49,8 +50,8 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) { const matchesName = (...fields) => wanted === '' || fields.some(field => (field ?? '').toLowerCase().includes(wanted)) const sections = [] const people = [ - ...roster.bots.filter(bot => matchesName(bot.name, bot.role)).map(bot => ({ - key: bot.id, avatar: h(BotAvatar, { bot, size: 24, badge: false, live: false }), title: bot.name, + ...roster.bots.filter(bot => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map(bot => ({ + key: bot.id, avatar: h(BotAvatar, { bot, size: 24, badge: false, live: false }), title: pickerBotLabel(bot), subtitle: [bot.role, isMainOf(roster, bot.id) ? t('Main Bot') : ''].filter(Boolean).join(' · '), badge: bot.hidden ? t('Hidden') : undefined, run: async () => { if (bot.hidden) await actions.setFlags(bot.id, { hidden: false }); actions.openSession(bot.id) }, })), diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index 72cb933ad..bf0df215f 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -11,7 +11,7 @@ import { reducedMotion } from './activity-row.js' import { Shimmer, workPhrase } from './cells.js' import { springEasing, SPRING_SHAPE } from './motion.js' import { hostText, t } from './i18n.js' -import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { remoteBotTitle, sharedRoomTitle, isListedRemoteChat } from '../shared/remote-label.js' import { showsDshRow } from './dsh-offer.js' // ------------------------------------------------------------------------- @@ -57,7 +57,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio const away = !running && groups.length > 0 const state = botState(running || away, doing, alert) const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t('a group chat'), groups[0].tool, groups.length - 1) : null - if (bot.hidden && !active && !main) return null + if (!isListedRemoteChat(bot) && !active && !main) return null const onContextMenu = (event) => { event.preventDefault() actions.openMenu({ kind: 'bot', id, x: event.clientX, y: event.clientY }) diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 79f6a1749..5398a8b10 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -12,6 +12,7 @@ import { randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { mentionSpans } from '../shared/mentions.js' +import { isHomeLevelRemote } from '../shared/remote-label.js' import { cleanName } from './delivery.js' import { SOURCE_KIND } from './constants.js' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -131,7 +132,7 @@ export function install(rt) { granted: rt.hasAnyGrant?.(bot.id) === true, systemPrompt: bot.systemPrompt ?? '', fields: clone(bot.fields ?? {}), }) - const persist = () => { void rt.save().catch(error => warn('flybot: save failed: %s', error)) } + const persist = () => rt.save().catch(error => warn('flybot: save failed: %s', error)) async function create({ name, systemPrompt, fields, role, instructions, createdBy, plugin = SHARED } = {}) { checkName('plugin', plugin) @@ -552,16 +553,21 @@ export function install(rt) { // Replaces this plugin's list; saves only when it changed. publish(plugin, list) { checkName('plugin', plugin) - const next = (list ?? []).filter(bot => typeof bot?.handle === 'string').map(bot => cleanRemote(plugin, bot)) + const next = (list ?? []).filter(bot => typeof bot?.handle === 'string' && !isHomeLevelRemote(bot)).map(bot => cleanRemote(plugin, bot)) // A read-only host shows what the writer saved; it changes nothing. if (rt.readOnly() !== undefined) return const all = remoteStore() - // Every granted remote Bot gets a chat of its own (remote-chat.js). - void rt.remoteChats?.sync(plugin, next).catch(error => warn('flybot: remote chats not synced: %s', error)) - if (same(all[plugin] ?? [], next)) return - if (next.length === 0) delete all[plugin] - else all[plugin] = next - persist() + // Every granted remote Bot gets a chat of its own (remote-chat.js). Hidden + // and home-level handles are not in `next`, so those sessions go gone/hidden. + const synced = rt.remoteChats?.sync(plugin, next)?.catch(error => warn('flybot: remote chats not synced: %s', error)) + const jobs = [] + if (synced && typeof synced.then === 'function') jobs.push(synced) + if (!same(all[plugin] ?? [], next)) { + if (next.length === 0) delete all[plugin] + else all[plugin] = next + jobs.push(Promise.resolve(persist())) + } + return jobs.length ? Promise.all(jobs) : synced }, list: () => Object.values(state.flybot?.remote ?? {}).flat().map(clone), get: handle => remoteMembers.list().find(bot => bot.handle === toRemote(handle)?.handle), diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index 93ee0120d..99f80f1a3 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -13,6 +13,7 @@ import { randomUUID } from 'node:crypto' import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' import { cleanName, fenceUntrusted } from './delivery.js' +import { isHomeLevelRemote, normRemoteHandle } from '../shared/remote-label.js' export const REMOTE_PROVIDER = 'flybot-remote' export const REMOTE_FORM = 'remote-chat' @@ -52,7 +53,7 @@ export function install(rt) { const { ctx, state } = rt const warn = (...args) => rt.warn?.(...args) const chats = () => Object.values(state.bots ?? {}).filter(isRemoteChat) - const chatOfHandle = handle => chats().find(bot => bot.remote.handle === handle) + const chatOfHandle = handle => chats().find(bot => normRemoteHandle(bot.remote.handle) === normRemoteHandle(handle)) const senders = new Map() const pending = new Map() @@ -116,6 +117,7 @@ export function install(rt) { let opening = Promise.resolve() // Creates the chat for a remote Bot (once), or refreshes its name and home label. function open({ handle, name, home, homeName, plugin }) { + if (isHomeLevelRemote({ handle, home })) return Promise.resolve(undefined) const run = opening.then(async () => { await rt.load() const existing = chatOfHandle(handle) @@ -143,14 +145,23 @@ export function install(rt) { async function sync(plugin, list) { const handles = new Set() for (const member of list) { - handles.add(member.handle) + if (isHomeLevelRemote(member)) continue + handles.add(normRemoteHandle(member.handle)) await open({ ...member, plugin }).catch(error => warn('ds-bot: chat for %s not opened: %s', member.handle, error)) } let changed = false for (const bot of chats()) { if (bot.remote.plugin !== plugin) continue - const gone = !handles.has(bot.remote.handle) - if (Boolean(bot.remote.gone) !== gone) { bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) }; if (!gone) delete bot.remote.gone; changed = true } + const gone = isHomeLevelRemote(bot.remote) || !handles.has(normRemoteHandle(bot.remote.handle)) + const wasGone = Boolean(bot.remote.gone) + const wasHidden = Boolean(bot.hidden) + if (wasGone !== gone || wasHidden !== gone) { + bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) } + if (!gone) delete bot.remote.gone + if (gone) bot.hidden = true + else delete bot.hidden + changed = true + } } if (changed) await rt.save() } diff --git a/core/src/host/tools.js b/core/src/host/tools.js index a1ae014ee..f1703ae6e 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto' import { defineTool } from '@deepseek-ai/dsh-tools' import { COLORS, EXTRA_COLORS, MAX_HOPS, MAX_UNREAD_REQUESTS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js' import { unquote } from './text.js' +import { isListedRemoteChat } from '../shared/remote-label.js' export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] } export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' } @@ -65,7 +66,7 @@ export function install(rt) { }) if (lines.length === 0) return 'No Bots yet.' const rooms = visibleRooms(self) - const friends = Object.values(state.bots).filter(bot => bot.remote?.handle && !bot.remote.gone).map(bot => `- ${bot.name} (${bot.remote.handle}${bot.remote.homeName ? `, ${bot.remote.homeName}'s home` : ''})`) + const friends = Object.values(state.bots).filter(bot => bot.remote?.handle && isListedRemoteChat(bot)).map(bot => `- ${bot.name} (${bot.remote.handle}${bot.remote.homeName ? `, ${bot.remote.homeName}'s home` : ''})`) return [ 'Bots:', ...lines, @@ -121,7 +122,7 @@ export function install(rt) { // A friend's Bot (by name or @handle) is reached through FlyBot's send path, so the // friend's auto-accept setting decides whether it goes now or waits for the user. const wanted = String(args.to ?? '').trim().replace(/^@/, '').toLowerCase() - const friend = target === undefined ? Object.values(state.bots).find(bot => bot.remote?.handle && !bot.remote.gone && (bot.name.toLowerCase() === wanted || bot.remote.handle.slice(1).toLowerCase() === wanted)) : undefined + const friend = target === undefined ? Object.values(state.bots).find(bot => bot.remote?.handle && isListedRemoteChat(bot) && (bot.name.toLowerCase() === wanted || bot.remote.handle.slice(1).toLowerCase() === wanted)) : undefined if (friend !== undefined && rt.flybotSend) { const result = await rt.flybotSend({ from: sender.id, to: friend.remote.handle, text: args.message, via: 'message_bot' }) if (result?.action === 'deny') refuse(`Not sent to ${friend.name}: ${result.reason}`) diff --git a/core/src/shared/remote-label.js b/core/src/shared/remote-label.js index d4cee82bc..25ace2eb0 100644 --- a/core/src/shared/remote-label.js +++ b/core/src/shared/remote-label.js @@ -7,6 +7,10 @@ export function cleanPeerName(name, fallback = '') { return text || fallback } +export function normRemoteHandle(handle) { + return `@${String(handle ?? '').trim().replace(/^@+/, '').toLowerCase()}` +} + function ownerFromHandle(handle) { const parts = String(handle ?? '').replace(/^@/, '').split('.') if (parts.length >= 3 && parts.at(-1) === 'home') return parts.at(-2) @@ -14,6 +18,42 @@ function ownerFromHandle(handle) { return '' } +export function isHomeLevelRemote(record) { + const handle = String(record?.handle ?? record?.remote?.handle ?? '') + const key = normRemoteHandle(handle) + const parts = key.replace(/^@/, '').split('.') + return parts.length === 2 && parts[1] === 'home' +} + +export function isListedRemoteChat(bot) { + if (!bot) return false + if (bot.hidden) return false + const remote = bot.remote + if (!remote || typeof remote.handle !== 'string') return true + if (remote.gone || remote.hidden) return false + if (isHomeLevelRemote(remote)) return false + return true +} + +export function listedChatBots(roster) { + return (roster?.bots ?? []).filter(isListedRemoteChat) +} + +export function pickerBotLabel(bot) { + if (!bot) return '' + if (!bot.remote) return bot.name ?? '' + return remoteBotTitle({ + displayName: bot.name, + name: bot.name, + ownerName: bot.remote.owner || bot.remote.homeName, + owner: bot.remote.owner, + homeName: bot.remote.homeName, + home: bot.remote.home, + handle: bot.remote.handle, + isChief: bot.remote.isChief, + }) +} + export function ownerNameOf(record) { if (!record) return '' return cleanPeerName(record.ownerName || record.homeName || record.owner || record.ownerHome || record.home || ownerFromHandle(record.handle), '') diff --git a/core/test/flybot-client.test.js b/core/test/flybot-client.test.js index d20eb9d08..9c0172a87 100644 --- a/core/test/flybot-client.test.js +++ b/core/test/flybot-client.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { createClientApi, createRegistry, mentionCandidates, registry, SLOTS } from '../src/client/flybot-registry.js' +import { createClientApi, createRegistry, mentionCandidates, newChatActionRows, registry, SLOTS } from '../src/client/flybot-registry.js' test('the client registry orders entries, replaces by id, and notifies', () => { const slots = createRegistry() @@ -15,7 +15,29 @@ test('the client registry orders entries, replaces by id, and notifies', () => { // The replaced entry stays: disposing the old one does not remove its successor. assert.deepEqual(slots.entries('flybot.sidebar.section').map(e => e.id), ['b', 'a']) assert.throws(() => slots.register('nope', { id: 'x' }), /unknown slot/) - assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock']) + assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action']) +}) + +test('new-chat action rows sit next to Create group chat and skip a failing plugin', () => { + const slots = registry() + const original = console.error + console.error = () => {} + const offs = [ + slots.register('flybot.new-chat.action', { + id: 'social-face-group', order: 15, label: 'Face-to-face group', hint: 'Meet next to you', + run: () => {}, + }), + slots.register('flybot.new-chat.action', { id: 'broken', order: 1, label: 'x', visible: () => { throw new Error('x') } }), + ] + try { + const rows = newChatActionRows({ mode: 'new' }) + assert.deepEqual(rows.map(row => row.key), ['social-face-group']) + assert.equal(rows[0].label, 'Face-to-face group') + assert.equal(rows[0].hint, 'Meet next to you') + } finally { + console.error = original + for (const off of offs) off() + } }) test('mention candidates come from every plugin and one failing source is skipped', async () => { diff --git a/core/test/new-chat.test.js b/core/test/new-chat.test.js new file mode 100644 index 000000000..b0b530124 --- /dev/null +++ b/core/test/new-chat.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat } from '../src/shared/remote-label.js' +import { DICTIONARY } from '../src/client/i18n.js' + +test('picker hides gone, hidden, and home-level remotes; labels include the owner', () => { + const roster = { + bots: [ + { id: 'chief', name: 'Chief' }, + { id: 'r1', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home', homeName: 'hsliu', owner: 'hsliu' } }, + { id: 'r2', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', homeName: 'Friend', owner: 'Friend' } }, + { id: 'r3', name: 'Chief', remote: { handle: '@chief.friend.home', home: '@friend.home', homeName: 'Friend', gone: true } }, + { id: 'r4', name: 'Friend', remote: { handle: '@friend.home', home: '@friend.home', homeName: 'Friend' } }, + { id: 'r5', name: 'Coder', hidden: true, remote: { handle: '@coder.bob.home', home: '@bob.home', homeName: 'Bob' } }, + ], + } + assert.equal(isHomeLevelRemote({ handle: '@friend.home', home: '@friend.home' }), true) + assert.equal(isHomeLevelRemote({ handle: '@coder.bob.home', home: '@bob.home' }), false) + assert.equal(isHomeLevelRemote({ handle: '@bob.bot', home: '@bob.bot' }), false) + assert.equal(isListedRemoteChat(roster.bots[3]), false) + assert.equal(isListedRemoteChat(roster.bots[4]), false) + const listed = listedChatBots(roster) + assert.deepEqual(listed.map(bot => bot.id), ['chief', 'r1', 'r2']) + assert.equal(pickerBotLabel(listed[0]), 'Chief') + assert.equal(pickerBotLabel(listed[1]), "hsliu's Chief") + assert.equal(pickerBotLabel(listed[2]), "助手 · Friend's Bot") +}) + +test('the sidebar + menu wires plugin actions in after Create group chat', () => { + const src = readFileSync(new URL('../src/client/new-chat.js', import.meta.url), 'utf8') + const group = src.indexOf("t('Create group chat')") + const actions = src.indexOf('for (const action of newChatActionRows') + const agent = src.indexOf("t('Add DSH Agent')") + assert.ok(group > 0 && actions > group && agent > actions, `order group=${group} actions=${actions} agent=${agent}`) + assert.match(src, /listedChatBots/) + assert.match(src, /pickerBotLabel/) + assert.match(src, /t\(action\.label\)/) + const slots = readFileSync(new URL('../src/client/flybot-slots.js', import.meta.url), 'utf8') + assert.match(slots, /isListedRemoteChat/) + const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') + assert.match(sidebar, /isListedRemoteChat/) + assert.equal(DICTIONARY.zh['Face-to-face group'], '面对面建群') + assert.equal(DICTIONARY.zh['Create group chat'], '新建群聊') + assert.match(DICTIONARY.zh['Meet the people next to you. Pick your Bots and type the same 6-digit number.'], /身边/) +}) diff --git a/core/test/remote-chat.test.js b/core/test/remote-chat.test.js index e96ef4d40..a859ece74 100644 --- a/core/test/remote-chat.test.js +++ b/core/test/remote-chat.test.js @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { remoteTurn, isRemoteChat, REMOTE_PROVIDER } from '../src/host/remote-chat.js'; +import { remoteTurn, isRemoteChat, REMOTE_PROVIDER, install } from '../src/host/remote-chat.js'; +import { isHomeLevelRemote } from '../src/shared/remote-label.js'; const user = (content, source, id = 'm1') => ({ role: 'user', id, content, ...(source ? { source } : {}) }); @@ -26,6 +27,41 @@ test('remote chats are recognised by their remote field', () => { assert.equal(REMOTE_PROVIDER, 'flybot-remote'); }); +test('sync hides gone and home-level remotes without deleting the chat', async () => { + assert.equal(isHomeLevelRemote({ handle: '@friend.home', home: '@friend.home' }), true) + const state = { + bots: { + a: { id: 'a', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home', plugin: 'social' } }, + b: { id: 'b', name: 'Friend', remote: { handle: '@friend.home', home: '@friend.home', plugin: 'social' } }, + c: { id: 'c', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + await rt.remoteChats.sync('social', [ + { handle: '@chief.hsliu.home', name: 'Chief', home: '@hsliu.home', homeName: 'hsliu' }, + ]) + assert.equal(state.bots.a.remote.gone, undefined) + assert.equal(state.bots.a.hidden, undefined) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, true) + assert.equal(state.bots.c.remote.gone, true) + assert.equal(state.bots.c.hidden, true) + assert.equal(state.bots.b.name, 'Friend') + await rt.remoteChats.sync('social', [ + { handle: '@chief.hsliu.home', name: 'Chief', home: '@hsliu.home' }, + { handle: '@assistant.friend.home', name: '助手', home: '@friend.home', homeName: 'Friend' }, + ]) + assert.equal(state.bots.c.remote.gone, undefined) + assert.equal(state.bots.c.hidden, undefined) + await rt.remoteChats.sync('social', [ + { handle: '@CHIEF.hsliu.home', name: 'Chief', home: '@HSLIU.home' }, + ]) + assert.equal(state.bots.a.remote.gone, undefined) + assert.equal(state.bots.a.hidden, undefined) + assert.equal(state.bots.c.hidden, true) +}) + test('roster and model guards keep a friend\'s Bot on its relay route', async () => { const { readFileSync } = await import('node:fs') const src = f => readFileSync(new URL('../src/host/' + f, import.meta.url), 'utf8') diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 5ec1f8df5..2f85e4fc5 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -320,6 +320,9 @@ export type FlyBotCall = (endpoint: string, payload?: unknown) => Promise * face-to-face room). * - `flybot.session.dock`: a card above the composer in a Bot or group chat (held Bot * Social replies waiting for approval). + * - `flybot.new-chat.action`: a row in the sidebar '+' new-chat menu, next to + * Create group chat (e.g. Face-to-face group). `label` / `hint` are English keys + * looked up with the same `t()` map as the neighbouring rows. */ export interface FlyBotSlots { 'flybot.sidebar.section': { id: string; order?: number; component(props: SidebarSectionProps): any } @@ -336,6 +339,14 @@ export interface FlyBotSlots { 'flybot.approvals': { id: string; order?: number; policy?: string; component(props: ApprovalCardProps): any } 'flybot.room.overview': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall }): any } 'flybot.session.dock': { id: string; order?: number; component(props: { sessionId?: string; roster?: any; room?: any; bot?: any; actions?: any; call: FlyBotCall }): any } + 'flybot.new-chat.action': { + id: string + order?: number + label: string + hint?: string + visible?(context: { mode?: string; group?: boolean; picked?: string[]; query?: string; roster?: any }): boolean + run(context: { close?: () => void; actions?: any; roster?: any }): void + } } /** Browser ctx.flybot (client plugins inject 'flybot'). */ diff --git a/docs/subsystems/ds-bot-core.md b/docs/subsystems/ds-bot-core.md index 026b095df..658de62bb 100644 --- a/docs/subsystems/ds-bot-core.md +++ b/docs/subsystems/ds-bot-core.md @@ -24,7 +24,7 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c ## Owns - Core server: roster/store, tools, turns/delivery, groups, conversation parts, search index, images, browser bridge, `api/bot` channel. -- Browser client: sidebar, chat, panels, themes, characters, Bot browser panel (`core/client.js` generated from `core/src/client/`). +- Browser client: sidebar, chat, panels, themes, characters, Bot browser panel (`core/client.js` generated from `core/src/client/`). The sidebar **+** new-chat picker (`core/src/client/new-chat.js`) takes extra rows from slot `flybot.new-chat.action` (next to Create group chat; `label` / `hint` go through the same `t()` map) and lists only `listedChatBots` (gone, hidden, and home-level remotes stay out; owner labels match the sidebar). Covered by `core/test/new-chat.test.js` in patch `0003-flybot-api-client`. - `core/web-search.js` — standalone `auto` web-search provider. - `core/dev/` — harness packages, mock model, test bench, smoke test. - `core/test/identity.test.js` — the state identity schema tests (store `state.json` v2, #47). @@ -51,7 +51,9 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c | `scripts/ds-bot-patches.mjs` | generate / check patches. | | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`). | | `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. | -| `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. | +| `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the sidebar, the **发给** picker, mentions, and `list_bots` friends lines; history stays on disk. | +| `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); Face-to-face group is the social plugin's row. | +| `core/test/new-chat.test.js` | Picker filters and the **+** menu order (Create group chat, then plugin actions, then Add DSH Agent). | ## Entry points @@ -62,7 +64,7 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c ## Invariants 1. `ds-bot/` stays pristine; CI fails if it differs from the pin. -2. `core/` == `ds-bot/` + `patches/ds-bot/`; regenerate after every `core/` change. +2. `core/` == `ds-bot/` + `patches/ds-bot/`; regenerate after every `core/` change. A new file under `core/` (for example `test/new-chat.test.js`) must be listed in `patches/ds-bot/topics.json` and present in the generated patch, or `scripts/ds-bot-patches.mjs check` fails with “committed but not produced by the patches”. 3. `client.js` matches `core/src/client/`; `npm run build` + `--check`. 4. Never touch the user's `~/.dsh`. 5. The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). @@ -72,6 +74,8 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c 9. `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). 10. A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id: the chat record (`src/host/remote-chat.js`) carries no look, and every consumer funnels through `lookOf`/`colorOf`, so the record and `remoteSpeaker`'s no-record fallback (`src/client/cells.js`) render one stable look per handle — across surfaces, after a delete/re-add, and identically in the friend's home. A stored `color`/`avatar` on the record still wins. Carrying the friend side's real look over the wire needs Bot Social member-profile fields (#84). 11. 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. +12. Slot `flybot.new-chat.action` (`0003-flybot-api-client`, types in `flybot.d.ts`) is a row on the sidebar **+** new-chat menu, next to Create group chat. Plugins register `{ id, order, label, hint?, visible?, run }`; the picker looks `label` / `hint` up with `t()` like the neighbouring rows. The social plugin's Face-to-face group row is the first consumer; see [plugin-runtime](plugin-runtime.md) and [social](social.md). +13. A friend's Bot the owner hid, a remote chat marked `gone`/`hidden`, and a home-level handle (`@friend.home`, not `@bot.friend.home`) must not reappear in the sidebar, the **发给 / 和谁聊…** picker, `@` mentions, or `list_bots` friends lines (`isListedRemoteChat`). `flybot.remote.publish` skips those handles so remote-chat sync marks the persisted session gone/hidden instead of opening it again; history is kept. Handle comparison is case-insensitive (`normRemoteHandle`). ## Tests diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index cbf93abfa..d324aa809 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -50,7 +50,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. 7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. 8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. -9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postRemote`). Slot `flybot.room.overview` is a block at the top of a group's overview (Leave / Start chat / Auto-send for origin `face-to-face`). Still not team mode. +9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postRemote`). Slot `flybot.room.overview` is a block at the top of a group's overview (Leave / Start chat / Auto-send for origin `face-to-face`). Slot `flybot.new-chat.action` adds a row to the sidebar **+** new-chat menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. ## Tests diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index fd189b32e..cbd9da9ec 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and shown in Friends' Bots until the owner Approves or Rejects them. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83, #92) also live here: **+ Add** in Friends' Bots opens a **Face-to-face group** screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the existing Approve / Reject path. **Auto-send my Bot's room messages** (room header and pending-approval) maps to `PATCH /v1/me { auto_approve_face_rooms }` so the owner's Bots can post without asking each time; default off. **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** hides a friend's Bot locally without unfriending: the hide is saved and the row drops immediately; room leave and a roster refresh continue in the background. +Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map) and Friends' Bots **+ Add** both open the same **Face-to-face group** screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the existing Approve / Reject path. **Auto-send my Bot's room messages** (room header and pending-approval) maps to `PATCH /v1/me { auto_approve_face_rooms }` so the owner's Bots can post without asking each time; default off. **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** hides a friend's Bot locally without unfriending: the hide is saved and the row drops immediately; room leave and a roster refresh continue in the background. A hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle; failed/legacy fetches must not un-hide it. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns @@ -30,7 +30,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/src/social.js` | Host: roster, grants, inbox, `social/status` pending-approval queue, Approve / Reject, hide-remote, Manage access, shared-room approvals provider, face-to-face join/poll/leave/start/send and `social/face-auto`. | | `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, invites). | | `plugins/social/src/face-rooms.js` | Face-to-face REST client, error mapping, event rendering, peer-string sanitization, GET/PATCH `/v1/me` auto-approve (#83). | -| `plugins/social/client.js` | Friends' Bots: badge, pending section, Approve / Reject, Remove this Bot, + Add Face-to-face group (Bot picker, 6-digit join, waiting room, Enter chat), face-room cards, `flybot.room.overview` Leave / Start / Auto-send, Bot settings Invite / Manage access. | +| `plugins/social/client.js` | Friends' Bots: badge, pending section, Approve / Reject, Remove this Bot, cached roster / Refreshing…, + Add and sidebar + Face-to-face group (Bot picker, 6-digit join, waiting room, Enter chat), face-room cards, `flybot.room.overview` Leave / Start / Auto-send, Bot settings Invite / Manage access. | ## Entry points @@ -43,7 +43,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 3. A server policy pause (`conversation_paused`: turn budget reached, loop suspected, emergency stop) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to an error that says resending cannot help and names the Bot Social console, and no other member Bot is tried for it. 4. Access is a per-Bot **grant** (#90): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted to me). Accepting **Invite to this Bot** is the grant; `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, MCP then REST) let the owner see where the Bot is in use and revoke one person without touching other grants. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`). A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. 5. When start fails (including Bot Social onboarding HTTP 409 `handle_taken`), Friends' Bots never shows the host's `Unknown FlyBot endpoint social/…` message. `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal, `flybot.warn` records the failure in diagnostics, and a 409 names the taken handle (`@.home`) and says to change `displayName` in the flybot-social config. The client also maps `bot/not-found` on `social/*` to that idle state. -6. Face-to-face join (#83, #92) is direct (no preview). The owner picks one or more local Bots in the Face-to-face group screen (+ Add); the client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social) and marks the owner's Bots as mine; **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). **Auto-send my Bot's room messages** is one click (`PATCH /v1/me { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. +6. Face-to-face join (#83, #92) is direct (no preview). The owner picks one or more local Bots in the Face-to-face group screen (sidebar **+** or Friends' Bots + Add); the client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social) and marks the owner's Bots as mine; **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). **Auto-send my Bot's room messages** is one click (`PATCH /v1/me { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. ## Tests diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index d3fd8df58..d42c1b3e3 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -18,7 +18,7 @@ index 05a42205df8a96369ec30f5c80ff86a109eb5646..f0a0442bd2a50af8de45b56400c0bfc6 } diff --git a/src/client/i18n.js b/src/client/i18n.js -index 6cf2df850b742d4f47b119bbedbf4f5dd4ec4b22..78a3f6b3a1b12e284ab20c0a3f397d1cb853ba36 100644 +index 6cf2df850b742d4f47b119bbedbf4f5dd4ec4b22..9cfe561fee0915631161d16dced64a480df9ec7d 100644 --- a/src/client/i18n.js +++ b/src/client/i18n.js @@ -26,6 +26,21 @@ const ZH = { @@ -43,7 +43,16 @@ index 6cf2df850b742d4f47b119bbedbf4f5dd4ec4b22..78a3f6b3a1b12e284ab20c0a3f397d1c Add: '添加', Close: '关闭', Copy: '复制', -@@ -53,9 +68,20 @@ const ZH = { +@@ -47,15 +62,29 @@ const ZH = { + 'Remove Main Bot role': '取消主 Bot', + 'Bot settings': 'Bot 设置', + 'Create group chat': '新建群聊', ++ 'Face-to-face group': '面对面建群', ++ 'Meet the people next to you. Pick your Bots and type the same 6-digit number.': '和身边的人建群。选你的 Bot,输入同一组 6 位数字。', ++ 'Refreshing…': '正在刷新…', + 'Delete group': '删除群聊', + "Open {name}'s chat": '打开 {name} 的对话', + 'New Bot': '新 Bot', // Sidebar and account menu 'Bot list': 'Bot 列表', @@ -65,7 +74,7 @@ index 6cf2df850b742d4f47b119bbedbf4f5dd4ec4b22..78a3f6b3a1b12e284ab20c0a3f397d1c 'Automation tasks': '自动化任务', Connectors: '连接器', 'Search connectors': '搜索连接器', -@@ -109,6 +135,7 @@ const ZH = { +@@ -109,6 +138,7 @@ const ZH = { 'Switch': '切换', 'Switch to classic Agent mode': '切换经典 Agent 模式', '{model} is not available anymore. Pick another model for me.': '{model} 已经不可用了。给我换一个模型吧。', @@ -73,7 +82,7 @@ index 6cf2df850b742d4f47b119bbedbf4f5dd4ec4b22..78a3f6b3a1b12e284ab20c0a3f397d1c "Hi, I'm {name}. I don't have a model yet. How do you want to set one up?": '你好,我是 {name}。我还没有可用的模型。你想怎么配置?', "Hi, I'm {name}. Pick a model for me first. You can change it later in my details.": '你好,我是 {name}。先给我选一个模型吧,以后可以在我的详情里换。', 'Mode': '模式', -@@ -453,6 +480,9 @@ const ZH = { +@@ -453,6 +483,9 @@ const ZH = { '{first} and {second} are waiting for your approval': '{first}、{second} 等你批准', '{first}, {second} and {count} more are waiting for your approval': '{first}、{second} 等 {total} 个等你批准', 'Waiting for your approval': '等你批准', @@ -83,7 +92,7 @@ index 6cf2df850b742d4f47b119bbedbf4f5dd4ec4b22..78a3f6b3a1b12e284ab20c0a3f397d1c 'in its own chat': '在自己的对话里', Reject: '拒绝', 'Allow once': '允许一次', -@@ -883,6 +913,20 @@ const HOST_ZH = { +@@ -883,6 +916,20 @@ const HOST_ZH = { 'Unknown Bot': '找不到这个 Bot', 'Unknown conversation': '找不到这个对话', 'A group chat needs at least two Bots': '群聊至少要两个 Bot', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index edb461fe1..21024f419 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -59,10 +59,10 @@ index 9cdb56ac60dda8bb9f364eb0791908a867412210..6f275b131462facfa0e83d05128f1a88 export function createRuntime(ctx, config = {}) { diff --git a/src/host/hooks.js b/src/host/hooks.js new file mode 100644 -index 0000000000000000000000000000000000000000..79f6a1749c812b14ad4fbca138ed610ca04d31ab +index 0000000000000000000000000000000000000000..5398a8b10566e13226a238298ea0cbb0bf055fdc --- /dev/null +++ b/src/host/hooks.js -@@ -0,0 +1,795 @@ +@@ -0,0 +1,801 @@ +// FlyBot hooks: the `flybot` service feature plugins (plugins/social, plugins/tree) +// build on. Installed after resume.js, so its gate around rt.deliver is the outermost +// wrapper: every Bot-to-Bot message, including message_bot, stop_bot, and replies, @@ -77,6 +77,7 @@ index 0000000000000000000000000000000000000000..79f6a1749c812b14ad4fbca138ed610c +import { randomUUID } from 'node:crypto' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { mentionSpans } from '../shared/mentions.js' ++import { isHomeLevelRemote } from '../shared/remote-label.js' +import { cleanName } from './delivery.js' +import { SOURCE_KIND } from './constants.js' +import { defineTool } from '@deepseek-ai/dsh-tools' @@ -196,7 +197,7 @@ index 0000000000000000000000000000000000000000..79f6a1749c812b14ad4fbca138ed610c + granted: rt.hasAnyGrant?.(bot.id) === true, + systemPrompt: bot.systemPrompt ?? '', fields: clone(bot.fields ?? {}), + }) -+ const persist = () => { void rt.save().catch(error => warn('flybot: save failed: %s', error)) } ++ const persist = () => rt.save().catch(error => warn('flybot: save failed: %s', error)) + + async function create({ name, systemPrompt, fields, role, instructions, createdBy, plugin = SHARED } = {}) { + checkName('plugin', plugin) @@ -617,16 +618,21 @@ index 0000000000000000000000000000000000000000..79f6a1749c812b14ad4fbca138ed610c + // Replaces this plugin's list; saves only when it changed. + publish(plugin, list) { + checkName('plugin', plugin) -+ const next = (list ?? []).filter(bot => typeof bot?.handle === 'string').map(bot => cleanRemote(plugin, bot)) ++ const next = (list ?? []).filter(bot => typeof bot?.handle === 'string' && !isHomeLevelRemote(bot)).map(bot => cleanRemote(plugin, bot)) + // A read-only host shows what the writer saved; it changes nothing. + if (rt.readOnly() !== undefined) return + const all = remoteStore() -+ // Every granted remote Bot gets a chat of its own (remote-chat.js). -+ void rt.remoteChats?.sync(plugin, next).catch(error => warn('flybot: remote chats not synced: %s', error)) -+ if (same(all[plugin] ?? [], next)) return -+ if (next.length === 0) delete all[plugin] -+ else all[plugin] = next -+ persist() ++ // Every granted remote Bot gets a chat of its own (remote-chat.js). Hidden ++ // and home-level handles are not in `next`, so those sessions go gone/hidden. ++ const synced = rt.remoteChats?.sync(plugin, next)?.catch(error => warn('flybot: remote chats not synced: %s', error)) ++ const jobs = [] ++ if (synced && typeof synced.then === 'function') jobs.push(synced) ++ if (!same(all[plugin] ?? [], next)) { ++ if (next.length === 0) delete all[plugin] ++ else all[plugin] = next ++ jobs.push(Promise.resolve(persist())) ++ } ++ return jobs.length ? Promise.all(jobs) : synced + }, + list: () => Object.values(state.flybot?.remote ?? {}).flat().map(clone), + get: handle => remoteMembers.list().find(bot => bot.handle === toRemote(handle)?.handle), @@ -1348,10 +1354,18 @@ index 580ce7abbe24a8c94587745e8d23932d04bfd448..068f538f697c1cd4933f6943bb15afd0 }) } diff --git a/src/host/tools.js b/src/host/tools.js -index ffaf7827c143693ee624cb81dca7249bb4289fb1..a1ae014ee719e4d2310cfd67701d313979bfd358 100644 +index ffaf7827c143693ee624cb81dca7249bb4289fb1..f1703ae6e5aa6b1ca670a4a672e04c422e44111f 100644 --- a/src/host/tools.js +++ b/src/host/tools.js -@@ -27,16 +27,27 @@ export function install(rt) { +@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto' + import { defineTool } from '@deepseek-ai/dsh-tools' + import { COLORS, EXTRA_COLORS, MAX_HOPS, MAX_UNREAD_REQUESTS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js' + import { unquote } from './text.js' ++import { isListedRemoteChat } from '../shared/remote-label.js' + + export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] } + export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' } +@@ -27,16 +28,27 @@ export function install(rt) { const roomLine = (room, self) => { const members = room.members.map(rt.botOf).filter(Boolean) .map(bot => `${bot.name}${bot.id === room.admin ? ' (admin)' : ''}${bot.id === self ? ' (you)' : ''}`) @@ -1382,7 +1396,7 @@ index ffaf7827c143693ee624cb81dca7249bb4289fb1..a1ae014ee719e4d2310cfd67701d3139 ctx.tools.register(defineTool({ name: 'list_bots', -@@ -46,7 +57,7 @@ export function install(rt) { +@@ -46,7 +58,7 @@ export function install(rt) { async execute(_args, exec) { await rt.load() const self = rt.selfOf(exec.agent?.id) @@ -1391,11 +1405,11 @@ index ffaf7827c143693ee624cb81dca7249bb4289fb1..a1ae014ee719e4d2310cfd67701d3139 const model = bot.appliedModel ?? bot.model const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' -@@ -54,12 +65,14 @@ export function install(rt) { +@@ -54,12 +66,14 @@ export function install(rt) { }) if (lines.length === 0) return 'No Bots yet.' const rooms = visibleRooms(self) -+ const friends = Object.values(state.bots).filter(bot => bot.remote?.handle && !bot.remote.gone).map(bot => `- ${bot.name} (${bot.remote.handle}${bot.remote.homeName ? `, ${bot.remote.homeName}'s home` : ''})`) ++ const friends = Object.values(state.bots).filter(bot => bot.remote?.handle && isListedRemoteChat(bot)).map(bot => `- ${bot.name} (${bot.remote.handle}${bot.remote.homeName ? `, ${bot.remote.homeName}'s home` : ''})`) return [ 'Bots:', ...lines, @@ -1406,7 +1420,7 @@ index ffaf7827c143693ee624cb81dca7249bb4289fb1..a1ae014ee719e4d2310cfd67701d3139 ].join('\n') }, })) -@@ -86,7 +99,7 @@ export function install(rt) { +@@ -86,7 +100,7 @@ export function install(rt) { name: 'message_bot', description: 'Send a message to another Bot on the team. If that Bot is working, it reads your message at its next step and can change course, then answers once that work is done; if it is idle, it starts on it right away. Its reply arrives later as a new message to you; with reply_to "user" it answers the user in its own chat instead.', parameters: { @@ -1415,7 +1429,7 @@ index ffaf7827c143693ee624cb81dca7249bb4289fb1..a1ae014ee719e4d2310cfd67701d3139 message: { type: 'string', required: true, description: 'Self-contained message; the other Bot cannot see this conversation' }, reply_to: { type: 'string', -@@ -105,7 +118,17 @@ export function install(rt) { +@@ -105,7 +119,17 @@ export function install(rt) { const sender = callerBot(exec) if (sender === undefined) return 'Only Bots on the team can use message_bot.' const target = rt.findBot(args.to) @@ -1423,7 +1437,7 @@ index ffaf7827c143693ee624cb81dca7249bb4289fb1..a1ae014ee719e4d2310cfd67701d3139 + // A friend's Bot (by name or @handle) is reached through FlyBot's send path, so the + // friend's auto-accept setting decides whether it goes now or waits for the user. + const wanted = String(args.to ?? '').trim().replace(/^@/, '').toLowerCase() -+ const friend = target === undefined ? Object.values(state.bots).find(bot => bot.remote?.handle && !bot.remote.gone && (bot.name.toLowerCase() === wanted || bot.remote.handle.slice(1).toLowerCase() === wanted)) : undefined ++ const friend = target === undefined ? Object.values(state.bots).find(bot => bot.remote?.handle && isListedRemoteChat(bot) && (bot.name.toLowerCase() === wanted || bot.remote.handle.slice(1).toLowerCase() === wanted)) : undefined + if (friend !== undefined && rt.flybotSend) { + const result = await rt.flybotSend({ from: sender.id, to: friend.remote.handle, text: args.message, via: 'message_bot' }) + if (result?.action === 'deny') refuse(`Not sent to ${friend.name}: ${result.reason}`) @@ -1434,7 +1448,7 @@ index ffaf7827c143693ee624cb81dca7249bb4289fb1..a1ae014ee719e4d2310cfd67701d3139 if (target.id === sender.id) return 'You cannot message yourself.' const turn = rt.live.get(exec.agent?.id) const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0)) -@@ -118,7 +141,11 @@ export function install(rt) { +@@ -118,7 +142,11 @@ export function install(rt) { const toUser = args.reply_to === 'user' const working = rt.live.get(chat)?.turn !== undefined const steer = args.when !== 'queue' && !toUser @@ -1447,7 +1461,7 @@ index ffaf7827c143693ee624cb81dca7249bb4289fb1..a1ae014ee719e4d2310cfd67701d3139 const sent = toUser ? `Delivered to ${target.name}. It will answer the user in its own chat; nothing comes back to you, so point the user to ${target.name}'s chat instead of relaying.` : working && steer -@@ -153,7 +180,7 @@ export function install(rt) { +@@ -153,7 +181,7 @@ export function install(rt) { const sender = callerBot(exec) if (sender === undefined || !rt.isMain(sender.id)) refuse('Only a Main Bot can stop other Bots.') const target = rt.findBot(args.bot) @@ -1456,7 +1470,7 @@ index ffaf7827c143693ee624cb81dca7249bb4289fb1..a1ae014ee719e4d2310cfd67701d3139 if (target.id === sender.id) refuse('You cannot stop yourself.') const { running } = activeSessions(target.id) if (running.length === 0) refuse(`${target.name} is not running right now, so there is nothing to stop. Use message_bot to reach it.`) -@@ -257,7 +284,7 @@ export function install(rt) { +@@ -257,7 +285,7 @@ export function install(rt) { } return { bots, unknown } } diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 9eb219ccb..b0943ca5e 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1,7 +1,7 @@ -FlyBot patch 0003-flybot-api-client on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; 14 file(s). +FlyBot patch 0003-flybot-api-client on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; 17 file(s). diff --git a/src/client/at-menu.js b/src/client/at-menu.js -index 64d6cf053e838145eec326f1acf7cd744db9a990..aa7073af1f0c154342169bae43afb390abbc246f 100644 +index 64d6cf053e838145eec326f1acf7cd744db9a990..50129fbec0d57da73f89026a3b5f0c7f9fd5f9da 100644 --- a/src/client/at-menu.js +++ b/src/client/at-menu.js @@ -3,6 +3,8 @@ @@ -9,15 +9,16 @@ index 64d6cf053e838145eec326f1acf7cd744db9a990..aa7073af1f0c154342169bae43afb390 // Both are sources of the shell's trigger menu; the shell's own file and session // rows stay beside them, because a source cannot be hidden for one Session. +import { mentionCandidates, remoteMentionRows } from './flybot-registry.js' -+import { remoteBotTitle } from '../shared/remote-label.js' ++import { remoteBotTitle, listedChatBots } from '../shared/remote-label.js' import { t } from './i18n.js' import { currentPart, relativeShort } from './text.js' -@@ -26,7 +28,13 @@ export function mentionRows(roster, sessionId, query = '') { +@@ -26,14 +28,22 @@ export function mentionRows(roster, sessionId, query = '') { const here = conversationOf(roster, sessionId) if (here === null) return null const bots = here.room - ? here.room.members.map(id => roster.byId[id]).filter(Boolean) +- : roster.bots.filter(bot => bot.id !== here.self.id) + ? [ + ...here.room.members.map(id => roster.byId[id]).filter(Boolean), + ...(here.room.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined) @@ -25,10 +26,10 @@ index 64d6cf053e838145eec326f1acf7cd744db9a990..aa7073af1f0c154342169bae43afb390 + ...(here.room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) + .map(p => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle })), + ] - : roster.bots.filter(bot => bot.id !== here.self.id) ++ : listedChatBots(roster).filter(bot => bot.id !== here.self.id) const needle = query.trim().toLowerCase() const rows = bots.filter(bot => bot.name.toLowerCase().includes(needle)).map(bot => ({ bot, text: `@${bot.name} ` })) -@@ -34,6 +42,8 @@ export function mentionRows(roster, sessionId, query = '') { + const everyone = t('Everyone') if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some(word => word.toLowerCase().startsWith(needle))) { rows.push({ room: here.room, text: `@${everyone} ` }) } @@ -302,13 +303,13 @@ index 48b001892b5646e43b0aa01c8b98ce31d9def699..2fc397c61326b38cf7110b9704757c80 + diff --git a/src/client/flybot-registry.js b/src/client/flybot-registry.js new file mode 100644 -index 0000000000000000000000000000000000000000..f00f0478502aebf01e4498aaf25607fa2420173a +index 0000000000000000000000000000000000000000..891720798468b9b4618d5797ec830fe9b0ebc491 --- /dev/null +++ b/src/client/flybot-registry.js -@@ -0,0 +1,188 @@ +@@ -0,0 +1,208 @@ +// The FlyBot slot registry, free of React so the "@" menu and tests can use it. +// See flybot-slots.js for what each slot carries. -+export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock'] ++export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action'] + +export function createRegistry() { + const entries = new Map(SLOTS.map(name => [name, []])) @@ -349,6 +350,26 @@ index 0000000000000000000000000000000000000000..f00f0478502aebf01e4498aaf25607fa +} + +// Extra "@" rows from plugins (remote Bots and the like), after the team's own Bots. ++// Extra rows in the sidebar '+' new-chat menu (Create new Bot / Create group chat / …). ++export function newChatActionRows(context = {}) { ++ const rows = [] ++ for (const entry of registry().entries('flybot.new-chat.action')) { ++ try { ++ if (entry.visible && entry.visible(context) === false) continue ++ rows.push({ ++ key: entry.id, ++ order: entry.order ?? 0, ++ label: entry.label, ++ hint: entry.hint, ++ run: entry.run ?? (() => {}), ++ }) ++ } catch (error) { ++ console.error(`flybot: ${entry.id} new-chat action failed`, error) ++ } ++ } ++ return rows.sort((a, b) => a.order - b.order) ++} ++ +export async function mentionCandidates(query, context) { + const rows = [] + for (const entry of registry().entries('flybot.composer.mention')) { @@ -496,10 +517,10 @@ index 0000000000000000000000000000000000000000..f00f0478502aebf01e4498aaf25607fa +} diff --git a/src/client/flybot-slots.js b/src/client/flybot-slots.js new file mode 100644 -index 0000000000000000000000000000000000000000..9cbda2bed9db12c9d5768c5afb8acb3c829d25e9 +index 0000000000000000000000000000000000000000..8a7c962f0b2c68807659e4ae5400ff16c7e135a4 --- /dev/null +++ b/src/client/flybot-slots.js -@@ -0,0 +1,167 @@ +@@ -0,0 +1,169 @@ +// ------------------------------------------------------------------------- +// FlyBot frontend slots. Feature plugins' client bundles register into one shared +// registry at globalThis.__flybot__ (created by whichever bundle loads first), and @@ -515,16 +536,18 @@ index 0000000000000000000000000000000000000000..9cbda2bed9db12c9d5768c5afb8acb3c +// flybot.approvals { id, order, component({ approval, decide, call }) } custom card for a held send +// flybot.room.overview { id, order, component({ room, roster, actions, call }) } top of a group's overview +// flybot.session.dock { id, order, component({ sessionId, roster, room, bot, actions, call }) } above the composer ++// flybot.new-chat.action { id, order, label, hint?, visible?, run({ close, actions, roster }) } a row in the sidebar '+' menu (next to Create group chat) +// +// Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry). +import { Component, createElement as h, Fragment, useEffect, useState } from 'react' +import { call as callHost } from './api.js' ++import { isListedRemoteChat } from '../shared/remote-label.js' +import { registry as baseRegistry } from './flybot-registry.js' +import { t } from './i18n.js' + +import { createClientApi as createClientApiWith } from './flybot-registry.js' + -+export { SLOTS, createRegistry, mentionCandidates, botTree } from './flybot-registry.js' ++export { SLOTS, createRegistry, mentionCandidates, newChatActionRows, botTree } from './flybot-registry.js' + +// Calls a FlyBot endpoint on the host: "approvals", "decide", or a plugin's "social/...". +export const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payload) @@ -574,7 +597,7 @@ index 0000000000000000000000000000000000000000..9cbda2bed9db12c9d5768c5afb8acb3c + const shell = (actions.flybotSlotEntries?.('flybot.sidebar.section') ?? []) + .map((entry, index) => ({ id: entry.id ?? `slot-${index}`, order: entry.order ?? 0, component: entry.component })) + const entries = [...useSlot('flybot.sidebar.section'), ...shell].sort((a, b) => a.order - b.order) -+ const remoteChats = (roster.bots ?? []).filter(bot => bot.remote) ++ const remoteChats = (roster.bots ?? []).filter(bot => bot.remote && isListedRemoteChat(bot)) + return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-section', 'data-flybot': entry.id }, + h(Safe, { entry, props: { roster, actions, call, renderBot, remoteChats } })))) +} @@ -761,8 +784,81 @@ index 203345b25d20a3635d2d9b157ef29bd2aaa46766..ebc856e5e3ac9a79a025b7d35aac17d4 ] // The return button belongs to neither group: it renders only in Agent mode. ctx.effect(() => { +diff --git a/src/client/new-chat.js b/src/client/new-chat.js +index 4f87e382ce27f9c37bcf573f84393377d54bdeb6..69653541c3501c09ec6776046e322518321de7db 100644 +--- a/src/client/new-chat.js ++++ b/src/client/new-chat.js +@@ -6,6 +6,8 @@ import { useEscape, usePaneLeft } from './overlay-hooks.js' + import { CreateBot } from './create-bot.js' + import { QUICK_EDGES, useFlip, useGlide, useMorph } from './motion.js' + import { t } from './i18n.js' ++import { newChatActionRows } from './flybot-registry.js' ++import { listedChatBots, pickerBotLabel } from '../shared/remote-label.js' + + export function NewChat({ mode, roster, actions }) { + const [query, setQuery] = useState('') +@@ -24,9 +26,10 @@ export function NewChat({ mode, roster, actions }) { + useEscape(close) + const paneLeft = usePaneLeft() + const wanted = query.trim().toLowerCase() +- const matches = roster.bots.filter(bot => !picked.includes(bot.id) && bot.id !== admin && (wanted === '' || bot.name.toLowerCase().includes(wanted) || (bot.role ?? '').toLowerCase().includes(wanted))) ++ const bots = listedChatBots(roster) ++ const matches = bots.filter(bot => !picked.includes(bot.id) && bot.id !== admin && (wanted === '' || bot.name.toLowerCase().includes(wanted) || pickerBotLabel(bot).toLowerCase().includes(wanted) || (bot.role ?? '').toLowerCase().includes(wanted))) + const choosingAdmin = group && admin === null +- const exact = roster.bots.some(bot => bot.name.toLowerCase() === wanted) ++ const exact = bots.some(bot => bot.name.toLowerCase() === wanted) + const pickedBots = picked.map(id => roster.byId[id]).filter(Boolean) + const run = async (task) => { + if (busy) return +@@ -96,13 +99,22 @@ export function NewChat({ mode, roster, actions }) { + if (!group && picked.length === 0 && mode !== 'search' && wanted === '') { + options.push({ key: 'new', icon: h(PlusIcon), label: t('Create new Bot'), run: createBot }) + options.push({ key: 'group', icon: h(PeopleIcon), label: t('Create group chat'), run: () => { setGroup(true); setCursor(0); inputRef.current?.focus() } }) ++ for (const action of newChatActionRows({ mode, group, picked, query, roster })) { ++ options.push({ ++ key: action.key, ++ icon: h(PeopleIcon), ++ label: t(action.label), ++ hint: action.hint ? t(action.hint) : undefined, ++ run: () => action.run({ close, actions, roster }), ++ }) ++ } + // A team holds at most one DSH Agent; the entry exists only while there is none. + if ((roster.agents ?? []).length === 0) { + options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add DSH Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) + } + } + for (const bot of matches) { +- options.push({ key: bot.id, bot, label: bot.name, hint: choosingAdmin ? t('Make admin') : group || picked.length > 0 ? t('Add to group chat') : undefined, run: () => openBot(bot) }) ++ options.push({ key: bot.id, bot, label: pickerBotLabel(bot), hint: choosingAdmin ? t('Make admin') : group || picked.length > 0 ? t('Add to group chat') : undefined, run: () => openBot(bot) }) + } + if (wanted !== '' && !exact && mode !== 'search') options.push({ key: 'create', icon: h(PlusIcon), label: t('Create new Bot “{name}”', { name: query.trim() }), run: createBot }) + const active = Math.min(cursor, Math.max(options.length - 1, 0)) +diff --git a/src/client/palette.js b/src/client/palette.js +index 7de15ff58379f8302643a5688ebb4063484206a8..04457974ed11454fb242965e5d2e43635a128372 100644 +--- a/src/client/palette.js ++++ b/src/client/palette.js +@@ -7,6 +7,7 @@ import { BotAvatar, RoomAvatar } from './mark.js' + import { useEscape } from './overlay-hooks.js' + import { QUICK_EDGES, useFlip, useGlide } from './motion.js' + import { t } from './i18n.js' ++import { isListedRemoteChat, pickerBotLabel } from '../shared/remote-label.js' + + // Search is a command palette: a 560px dialog, a search header, and 49px + // rows (24px avatar, title, subtitle). Bots and groups match on name and role; past +@@ -49,8 +50,8 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) { + const matchesName = (...fields) => wanted === '' || fields.some(field => (field ?? '').toLowerCase().includes(wanted)) + const sections = [] + const people = [ +- ...roster.bots.filter(bot => matchesName(bot.name, bot.role)).map(bot => ({ +- key: bot.id, avatar: h(BotAvatar, { bot, size: 24, badge: false, live: false }), title: bot.name, ++ ...roster.bots.filter(bot => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map(bot => ({ ++ key: bot.id, avatar: h(BotAvatar, { bot, size: 24, badge: false, live: false }), title: pickerBotLabel(bot), + subtitle: [bot.role, isMainOf(roster, bot.id) ? t('Main Bot') : ''].filter(Boolean).join(' · '), badge: bot.hidden ? t('Hidden') : undefined, + run: async () => { if (bot.hidden) await actions.setFlags(bot.id, { hidden: false }); actions.openSession(bot.id) }, + })), diff --git a/src/client/sidebar.js b/src/client/sidebar.js -index 2b3ea08fa8dc74de8166c07c3b4f704d6c120f10..72cb933ad151f00edb29e89bbad120fa1ede5070 100644 +index 2b3ea08fa8dc74de8166c07c3b4f704d6c120f10..bf0df215fe07ecbd4ea94f0d50d4086190f58149 100644 --- a/src/client/sidebar.js +++ b/src/client/sidebar.js @@ -1,8 +1,9 @@ @@ -781,7 +877,7 @@ index 2b3ea08fa8dc74de8166c07c3b4f704d6c120f10..72cb933ad151f00edb29e89bbad120fa import { Shimmer, workPhrase } from './cells.js' import { springEasing, SPRING_SHAPE } from './motion.js' import { hostText, t } from './i18n.js' -+import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' ++import { remoteBotTitle, sharedRoomTitle, isListedRemoteChat } from '../shared/remote-label.js' import { showsDshRow } from './dsh-offer.js' // ------------------------------------------------------------------------- @@ -805,7 +901,14 @@ index 2b3ea08fa8dc74de8166c07c3b4f704d6c120f10..72cb933ad151f00edb29e89bbad120fa const approval = useApproval(useWork, entry => entry.botId === id) const alert = roster.questions?.[part] !== undefined || approval const doing = useActivity(map => map[id]) -@@ -61,14 +63,15 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -55,20 +57,21 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio + const away = !running && groups.length > 0 + const state = botState(running || away, doing, alert) + const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t('a group chat'), groups[0].tool, groups.length - 1) : null +- if (bot.hidden && !active && !main) return null ++ if (!isListedRemoteChat(bot) && !active && !main) return null + const onContextMenu = (event) => { + event.preventDefault() actions.openMenu({ kind: 'bot', id, x: event.clientX, y: event.clientY }) } const pinned = bot.pinned === true && !tile @@ -1176,13 +1279,13 @@ index 0000000000000000000000000000000000000000..649ca3f372a8b38d5ea9703b367f3596 +}) diff --git a/test/flybot-client.test.js b/test/flybot-client.test.js new file mode 100644 -index 0000000000000000000000000000000000000000..d20eb9d085c499511f6dd45da606bd089e292531 +index 0000000000000000000000000000000000000000..9c0172a8733f39c3ff9358fac06e4a2263d6a06d --- /dev/null +++ b/test/flybot-client.test.js -@@ -0,0 +1,122 @@ +@@ -0,0 +1,144 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' -+import { createClientApi, createRegistry, mentionCandidates, registry, SLOTS } from '../src/client/flybot-registry.js' ++import { createClientApi, createRegistry, mentionCandidates, newChatActionRows, registry, SLOTS } from '../src/client/flybot-registry.js' + +test('the client registry orders entries, replaces by id, and notifies', () => { + const slots = createRegistry() @@ -1197,7 +1300,29 @@ index 0000000000000000000000000000000000000000..d20eb9d085c499511f6dd45da606bd08 + // The replaced entry stays: disposing the old one does not remove its successor. + assert.deepEqual(slots.entries('flybot.sidebar.section').map(e => e.id), ['b', 'a']) + assert.throws(() => slots.register('nope', { id: 'x' }), /unknown slot/) -+ assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock']) ++ assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action']) ++}) ++ ++test('new-chat action rows sit next to Create group chat and skip a failing plugin', () => { ++ const slots = registry() ++ const original = console.error ++ console.error = () => {} ++ const offs = [ ++ slots.register('flybot.new-chat.action', { ++ id: 'social-face-group', order: 15, label: 'Face-to-face group', hint: 'Meet next to you', ++ run: () => {}, ++ }), ++ slots.register('flybot.new-chat.action', { id: 'broken', order: 1, label: 'x', visible: () => { throw new Error('x') } }), ++ ] ++ try { ++ const rows = newChatActionRows({ mode: 'new' }) ++ assert.deepEqual(rows.map(row => row.key), ['social-face-group']) ++ assert.equal(rows[0].label, 'Face-to-face group') ++ assert.equal(rows[0].hint, 'Meet next to you') ++ } finally { ++ console.error = original ++ for (const off of offs) off() ++ } +}) + +test('mention candidates come from every plugin and one failing source is skipped', async () => { @@ -1328,3 +1453,55 @@ index 0e0a1a221a1e90cdaa00ff92f84678ba7bc605e3..fbffde8021cf3e7e4ea8579235c40ec3 test('a Bot name is something "@" can call without a second reading', () => { assert.equal(nameProblem('新 Bot'), undefined) assert.equal(nameProblem(' '), 'A Bot needs a name') +diff --git a/test/new-chat.test.js b/test/new-chat.test.js +new file mode 100644 +index 0000000000000000000000000000000000000000..b0b530124340f68e3119003e2bbd081f7a4406f7 +--- /dev/null ++++ b/test/new-chat.test.js +@@ -0,0 +1,46 @@ ++import assert from 'node:assert/strict' ++import { readFileSync } from 'node:fs' ++import { test } from 'node:test' ++import { listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat } from '../src/shared/remote-label.js' ++import { DICTIONARY } from '../src/client/i18n.js' ++ ++test('picker hides gone, hidden, and home-level remotes; labels include the owner', () => { ++ const roster = { ++ bots: [ ++ { id: 'chief', name: 'Chief' }, ++ { id: 'r1', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home', homeName: 'hsliu', owner: 'hsliu' } }, ++ { id: 'r2', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', homeName: 'Friend', owner: 'Friend' } }, ++ { id: 'r3', name: 'Chief', remote: { handle: '@chief.friend.home', home: '@friend.home', homeName: 'Friend', gone: true } }, ++ { id: 'r4', name: 'Friend', remote: { handle: '@friend.home', home: '@friend.home', homeName: 'Friend' } }, ++ { id: 'r5', name: 'Coder', hidden: true, remote: { handle: '@coder.bob.home', home: '@bob.home', homeName: 'Bob' } }, ++ ], ++ } ++ assert.equal(isHomeLevelRemote({ handle: '@friend.home', home: '@friend.home' }), true) ++ assert.equal(isHomeLevelRemote({ handle: '@coder.bob.home', home: '@bob.home' }), false) ++ assert.equal(isHomeLevelRemote({ handle: '@bob.bot', home: '@bob.bot' }), false) ++ assert.equal(isListedRemoteChat(roster.bots[3]), false) ++ assert.equal(isListedRemoteChat(roster.bots[4]), false) ++ const listed = listedChatBots(roster) ++ assert.deepEqual(listed.map(bot => bot.id), ['chief', 'r1', 'r2']) ++ assert.equal(pickerBotLabel(listed[0]), 'Chief') ++ assert.equal(pickerBotLabel(listed[1]), "hsliu's Chief") ++ assert.equal(pickerBotLabel(listed[2]), "助手 · Friend's Bot") ++}) ++ ++test('the sidebar + menu wires plugin actions in after Create group chat', () => { ++ const src = readFileSync(new URL('../src/client/new-chat.js', import.meta.url), 'utf8') ++ const group = src.indexOf("t('Create group chat')") ++ const actions = src.indexOf('for (const action of newChatActionRows') ++ const agent = src.indexOf("t('Add DSH Agent')") ++ assert.ok(group > 0 && actions > group && agent > actions, `order group=${group} actions=${actions} agent=${agent}`) ++ assert.match(src, /listedChatBots/) ++ assert.match(src, /pickerBotLabel/) ++ assert.match(src, /t\(action\.label\)/) ++ const slots = readFileSync(new URL('../src/client/flybot-slots.js', import.meta.url), 'utf8') ++ assert.match(slots, /isListedRemoteChat/) ++ const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') ++ assert.match(sidebar, /isListedRemoteChat/) ++ assert.equal(DICTIONARY.zh['Face-to-face group'], '面对面建群') ++ assert.equal(DICTIONARY.zh['Create group chat'], '新建群聊') ++ assert.match(DICTIONARY.zh['Meet the people next to you. Pick your Bots and type the same 6-digit number.'], /身边/) ++}) diff --git a/patches/ds-bot/0004-flybot-api-types.patch b/patches/ds-bot/0004-flybot-api-types.patch index df71e22d2..65603f16c 100644 --- a/patches/ds-bot/0004-flybot-api-types.patch +++ b/patches/ds-bot/0004-flybot-api-types.patch @@ -2,10 +2,10 @@ FlyBot patch 0004-flybot-api-types on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Ge diff --git a/types/flybot.d.ts b/types/flybot.d.ts new file mode 100644 -index 0000000000000000000000000000000000000000..5ec1f8df553dceebd69481110f87ffa35087f30a +index 0000000000000000000000000000000000000000..2f85e4fc52bded12ed2ecebae0605b13ad26dfcf --- /dev/null +++ b/types/flybot.d.ts -@@ -0,0 +1,383 @@ +@@ -0,0 +1,394 @@ +// FlyBot extension API (version 1). Implemented in src/host/hooks.js (host) and +// src/client/flybot-registry.js + flybot-slots.js (browser). Feature plugins declare +// `inject: ['flybot']` and use `ctx.flybot`. @@ -328,6 +328,9 @@ index 0000000000000000000000000000000000000000..5ec1f8df553dceebd69481110f87ffa3 + * face-to-face room). + * - `flybot.session.dock`: a card above the composer in a Bot or group chat (held Bot + * Social replies waiting for approval). ++ * - `flybot.new-chat.action`: a row in the sidebar '+' new-chat menu, next to ++ * Create group chat (e.g. Face-to-face group). `label` / `hint` are English keys ++ * looked up with the same `t()` map as the neighbouring rows. + */ +export interface FlyBotSlots { + 'flybot.sidebar.section': { id: string; order?: number; component(props: SidebarSectionProps): any } @@ -344,6 +347,14 @@ index 0000000000000000000000000000000000000000..5ec1f8df553dceebd69481110f87ffa3 + 'flybot.approvals': { id: string; order?: number; policy?: string; component(props: ApprovalCardProps): any } + 'flybot.room.overview': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall }): any } + 'flybot.session.dock': { id: string; order?: number; component(props: { sessionId?: string; roster?: any; room?: any; bot?: any; actions?: any; call: FlyBotCall }): any } ++ 'flybot.new-chat.action': { ++ id: string ++ order?: number ++ label: string ++ hint?: string ++ visible?(context: { mode?: string; group?: boolean; picked?: string[]; query?: string; roster?: any }): boolean ++ run(context: { close?: () => void; actions?: any; roster?: any }): void ++ } +} + +/** Browser ctx.flybot (client plugins inject 'flybot'). */ diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index e108a34df..193c25aa9 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -410,10 +410,10 @@ index ae2e8157424841c1ecfce4c7c833c20e5d29b1a2..0e0d7ecd8a895d01309601229c4bd384 if (steer) agent.steer(message) diff --git a/src/host/remote-chat.js b/src/host/remote-chat.js new file mode 100644 -index 0000000000000000000000000000000000000000..93ee0120d8b88476eec2cec6a0196afe2478bc7a +index 0000000000000000000000000000000000000000..99f80f1a3b0eb3de0ea5f2977619f9604b03b9eb --- /dev/null +++ b/src/host/remote-chat.js -@@ -0,0 +1,180 @@ +@@ -0,0 +1,191 @@ +// FlyBot remote chats: a friend's (remote) Bot as a chat of its own, in ds-bot's own +// session model. Each remote Bot a plugin publishes gets a Bot record marked `remote` +// ({ handle, home, homeName, plugin }) whose Session runs on the model route @@ -429,6 +429,7 @@ index 0000000000000000000000000000000000000000..93ee0120d8b88476eec2cec6a0196afe +import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { SOURCE_KIND } from './constants.js' +import { cleanName, fenceUntrusted } from './delivery.js' ++import { isHomeLevelRemote, normRemoteHandle } from '../shared/remote-label.js' + +export const REMOTE_PROVIDER = 'flybot-remote' +export const REMOTE_FORM = 'remote-chat' @@ -468,7 +469,7 @@ index 0000000000000000000000000000000000000000..93ee0120d8b88476eec2cec6a0196afe + const { ctx, state } = rt + const warn = (...args) => rt.warn?.(...args) + const chats = () => Object.values(state.bots ?? {}).filter(isRemoteChat) -+ const chatOfHandle = handle => chats().find(bot => bot.remote.handle === handle) ++ const chatOfHandle = handle => chats().find(bot => normRemoteHandle(bot.remote.handle) === normRemoteHandle(handle)) + const senders = new Map() + const pending = new Map() + @@ -532,6 +533,7 @@ index 0000000000000000000000000000000000000000..93ee0120d8b88476eec2cec6a0196afe + let opening = Promise.resolve() + // Creates the chat for a remote Bot (once), or refreshes its name and home label. + function open({ handle, name, home, homeName, plugin }) { ++ if (isHomeLevelRemote({ handle, home })) return Promise.resolve(undefined) + const run = opening.then(async () => { + await rt.load() + const existing = chatOfHandle(handle) @@ -559,14 +561,23 @@ index 0000000000000000000000000000000000000000..93ee0120d8b88476eec2cec6a0196afe + async function sync(plugin, list) { + const handles = new Set() + for (const member of list) { -+ handles.add(member.handle) ++ if (isHomeLevelRemote(member)) continue ++ handles.add(normRemoteHandle(member.handle)) + await open({ ...member, plugin }).catch(error => warn('ds-bot: chat for %s not opened: %s', member.handle, error)) + } + let changed = false + for (const bot of chats()) { + if (bot.remote.plugin !== plugin) continue -+ const gone = !handles.has(bot.remote.handle) -+ if (Boolean(bot.remote.gone) !== gone) { bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) }; if (!gone) delete bot.remote.gone; changed = true } ++ const gone = isHomeLevelRemote(bot.remote) || !handles.has(normRemoteHandle(bot.remote.handle)) ++ const wasGone = Boolean(bot.remote.gone) ++ const wasHidden = Boolean(bot.hidden) ++ if (wasGone !== gone || wasHidden !== gone) { ++ bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) } ++ if (!gone) delete bot.remote.gone ++ if (gone) bot.hidden = true ++ else delete bot.hidden ++ changed = true ++ } + } + if (changed) await rt.save() + } @@ -707,13 +718,14 @@ index 0000000000000000000000000000000000000000..c25e41e1b2f9126624f610fa00b515d1 +}) diff --git a/test/remote-chat.test.js b/test/remote-chat.test.js new file mode 100644 -index 0000000000000000000000000000000000000000..e96ef4d40dc3fa19344755d83db4d59b85144e0c +index 0000000000000000000000000000000000000000..a859ece74d578b869dd540f1375c4890023f14e6 --- /dev/null +++ b/test/remote-chat.test.js -@@ -0,0 +1,54 @@ +@@ -0,0 +1,90 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; -+import { remoteTurn, isRemoteChat, REMOTE_PROVIDER } from '../src/host/remote-chat.js'; ++import { remoteTurn, isRemoteChat, REMOTE_PROVIDER, install } from '../src/host/remote-chat.js'; ++import { isHomeLevelRemote } from '../src/shared/remote-label.js'; + +const user = (content, source, id = 'm1') => ({ role: 'user', id, content, ...(source ? { source } : {}) }); + @@ -739,6 +751,41 @@ index 0000000000000000000000000000000000000000..e96ef4d40dc3fa19344755d83db4d59b + assert.equal(REMOTE_PROVIDER, 'flybot-remote'); +}); + ++test('sync hides gone and home-level remotes without deleting the chat', async () => { ++ assert.equal(isHomeLevelRemote({ handle: '@friend.home', home: '@friend.home' }), true) ++ const state = { ++ bots: { ++ a: { id: 'a', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home', plugin: 'social' } }, ++ b: { id: 'b', name: 'Friend', remote: { handle: '@friend.home', home: '@friend.home', plugin: 'social' } }, ++ c: { id: 'c', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, ++ }, ++ } ++ const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } ++ install(rt) ++ await rt.remoteChats.sync('social', [ ++ { handle: '@chief.hsliu.home', name: 'Chief', home: '@hsliu.home', homeName: 'hsliu' }, ++ ]) ++ assert.equal(state.bots.a.remote.gone, undefined) ++ assert.equal(state.bots.a.hidden, undefined) ++ assert.equal(state.bots.b.remote.gone, true) ++ assert.equal(state.bots.b.hidden, true) ++ assert.equal(state.bots.c.remote.gone, true) ++ assert.equal(state.bots.c.hidden, true) ++ assert.equal(state.bots.b.name, 'Friend') ++ await rt.remoteChats.sync('social', [ ++ { handle: '@chief.hsliu.home', name: 'Chief', home: '@hsliu.home' }, ++ { handle: '@assistant.friend.home', name: '助手', home: '@friend.home', homeName: 'Friend' }, ++ ]) ++ assert.equal(state.bots.c.remote.gone, undefined) ++ assert.equal(state.bots.c.hidden, undefined) ++ await rt.remoteChats.sync('social', [ ++ { handle: '@CHIEF.hsliu.home', name: 'Chief', home: '@HSLIU.home' }, ++ ]) ++ assert.equal(state.bots.a.remote.gone, undefined) ++ assert.equal(state.bots.a.hidden, undefined) ++ assert.equal(state.bots.c.hidden, true) ++}) ++ +test('roster and model guards keep a friend\'s Bot on its relay route', async () => { + const { readFileSync } = await import('node:fs') + const src = f => readFileSync(new URL('../src/host/' + f, import.meta.url), 'utf8') diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 0eb70b189..381caa078 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1,7 +1,7 @@ FlyBot patch 0008-client-bundle on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; 1 file(s). diff --git a/client.js b/client.js -index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5e33760ad 100644 +index f46d64171400ccaa6c00e4577c308297a70a0b4d..975f63898e2ee8e4b2e15c283b622c5d2d1d0d34 100644 --- a/client.js +++ b/client.js @@ -63,6 +63,21 @@ var ZH = { @@ -26,7 +26,15 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 Add: "添加", Close: "关闭", Copy: "复制", -@@ -89,9 +104,20 @@ var ZH = { +@@ -84,14 +99,28 @@ var ZH = { + "Remove Main Bot role": "取消主 Bot", + "Bot settings": "Bot 设置", + "Create group chat": "新建群聊", ++ "Face-to-face group": "面对面建群", ++ "Meet the people next to you. Pick your Bots and type the same 6-digit number.": "和身边的人建群。选你的 Bot,输入同一组 6 位数字。", ++ "Refreshing…": "正在刷新…", + "Delete group": "删除群聊", + "Open {name}'s chat": "打开 {name} 的对话", "New Bot": "新 Bot", // Sidebar and account menu "Bot list": "Bot 列表", @@ -48,7 +56,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "Automation tasks": "自动化任务", Connectors: "连接器", "Search connectors": "搜索连接器", -@@ -145,6 +171,7 @@ var ZH = { +@@ -145,6 +174,7 @@ var ZH = { "Switch": "切换", "Switch to classic Agent mode": "切换经典 Agent 模式", "{model} is not available anymore. Pick another model for me.": "{model} 已经不可用了。给我换一个模型吧。", @@ -56,7 +64,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "Hi, I'm {name}. I don't have a model yet. How do you want to set one up?": "你好,我是 {name}。我还没有可用的模型。你想怎么配置?", "Hi, I'm {name}. Pick a model for me first. You can change it later in my details.": "你好,我是 {name}。先给我选一个模型吧,以后可以在我的详情里换。", "Mode": "模式", -@@ -509,6 +536,9 @@ var ZH = { +@@ -509,6 +539,9 @@ var ZH = { "{first} and {second} are waiting for your approval": "{first}、{second} 等你批准", "{first}, {second} and {count} more are waiting for your approval": "{first}、{second} 等 {total} 个等你批准", "Waiting for your approval": "等你批准", @@ -66,7 +74,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "in its own chat": "在自己的对话里", Reject: "拒绝", "Allow once": "允许一次", -@@ -930,6 +960,20 @@ var HOST_ZH = { +@@ -930,6 +963,20 @@ var HOST_ZH = { "Unknown Bot": "找不到这个 Bot", "Unknown conversation": "找不到这个对话", "A group chat needs at least two Bots": "群聊至少要两个 Bot", @@ -87,7 +95,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "The admin must be a member of the group": "管理员必须是群成员", "Unknown group chat": "找不到这个群聊", "Hand the admin role to another member instead of leaving the group without one": "请把管理员交给其他成员,不要让群聊没有管理员", -@@ -1066,10 +1110,16 @@ var REFUSALS = /* @__PURE__ */ new Set(["bot/refused", "bot/invalid", "bot/read- +@@ -1066,10 +1113,16 @@ var REFUSALS = /* @__PURE__ */ new Set(["bot/refused", "bot/invalid", "bot/read- var call = async (endpoint, payload, signal) => { let response; try { @@ -105,12 +113,88 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1097,6 +1147,317 @@ var call = async (endpoint, payload, signal) => { +@@ -1097,6 +1150,412 @@ var call = async (endpoint, payload, signal) => { return result.value; }; ++// src/shared/remote-label.js ++function cleanPeerName(name, fallback = "") { ++ const text = String(name ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"`<>]/g, "").replace(/\s+/g, " ").trim().slice(0, 60); ++ return text || fallback; ++} ++function normRemoteHandle(handle) { ++ return `@${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`; ++} ++function ownerFromHandle(handle) { ++ const parts = String(handle ?? "").replace(/^@/, "").split("."); ++ if (parts.length >= 3 && parts.at(-1) === "home") return parts.at(-2); ++ if (parts.length === 2 && parts[1] === "home") return parts[0]; ++ return ""; ++} ++function isHomeLevelRemote(record) { ++ const handle = String(record?.handle ?? record?.remote?.handle ?? ""); ++ const key = normRemoteHandle(handle); ++ const parts = key.replace(/^@/, "").split("."); ++ return parts.length === 2 && parts[1] === "home"; ++} ++function isListedRemoteChat(bot) { ++ if (!bot) return false; ++ if (bot.hidden) return false; ++ const remote = bot.remote; ++ if (!remote || typeof remote.handle !== "string") return true; ++ if (remote.gone || remote.hidden) return false; ++ if (isHomeLevelRemote(remote)) return false; ++ return true; ++} ++function listedChatBots(roster) { ++ return (roster?.bots ?? []).filter(isListedRemoteChat); ++} ++function pickerBotLabel(bot) { ++ if (!bot) return ""; ++ if (!bot.remote) return bot.name ?? ""; ++ return remoteBotTitle({ ++ displayName: bot.name, ++ name: bot.name, ++ ownerName: bot.remote.owner || bot.remote.homeName, ++ owner: bot.remote.owner, ++ homeName: bot.remote.homeName, ++ home: bot.remote.home, ++ handle: bot.remote.handle, ++ isChief: bot.remote.isChief ++ }); ++} ++function ownerNameOf(record) { ++ if (!record) return ""; ++ return cleanPeerName(record.ownerName || record.homeName || record.owner || record.ownerHome || record.home || ownerFromHandle(record.handle), ""); ++} ++function isChiefRecord(record) { ++ if (!record) return false; ++ if (record.isChief === true) return true; ++ return /^chief$/i.test(String(record.displayName || record.name || "")); ++} ++function remoteBotTitle(record) { ++ const owner = ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); ++ const name = cleanPeerName(record?.displayName || record?.name, cleanPeerName(record?.handle, "Bot")); ++ if (isChiefRecord(record)) return `${owner}'s Chief`; ++ return `${name} · ${owner}'s Bot`; ++} ++function remoteOwnerBadge(record) { ++ const owner = ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); ++ return isChiefRecord(record) ? `${owner}'s Chief` : `${owner}'s Bot`; ++} ++function sharedRoomTitle(inviterName) { ++ return `Shared room from ${cleanPeerName(inviterName, "a friend")}`; ++} ++function disambiguatedRemoteTitle(record, others = []) { ++ const title = remoteBotTitle(record); ++ const name = cleanPeerName(record?.displayName || record?.name); ++ if (!name) return title; ++ const clash = others.some((item) => item !== record && cleanPeerName(item?.displayName || item?.name) === name && ownerNameOf(item) !== ownerNameOf(record)); ++ return clash ? title : title; ++} ++ +// src/client/flybot-registry.js -+var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock"]; ++var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action"]; +function createRegistry() { + const entries2 = new Map(SLOTS.map((name) => [name, []])); + const listeners2 = /* @__PURE__ */ new Set(); @@ -147,6 +231,25 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 + root.__flybot__ ??= { slots: createRegistry() }; + return root.__flybot__.slots; +} ++function newChatActionRows(context = {}) { ++ const rows = []; ++ for (const entry of registry().entries("flybot.new-chat.action")) { ++ try { ++ if (entry.visible && entry.visible(context) === false) continue; ++ rows.push({ ++ key: entry.id, ++ order: entry.order ?? 0, ++ label: entry.label, ++ hint: entry.hint, ++ run: entry.run ?? (() => { ++ }) ++ }); ++ } catch (error) { ++ console.error(`flybot: ${entry.id} new-chat action failed`, error); ++ } ++ } ++ return rows.sort((a, b) => a.order - b.order); ++} +async function mentionCandidates(query, context) { + const rows = []; + for (const entry of registry().entries("flybot.composer.mention")) { @@ -316,7 +419,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 +function FlyBotSections({ roster, actions, renderBot }) { + const shell = (actions.flybotSlotEntries?.("flybot.sidebar.section") ?? []).map((entry, index) => ({ id: entry.id ?? `slot-${index}`, order: entry.order ?? 0, component: entry.component })); + const entries2 = [...useSlot("flybot.sidebar.section"), ...shell].sort((a, b) => a.order - b.order); -+ const remoteChats = (roster.bots ?? []).filter((bot) => bot.remote); ++ const remoteChats = (roster.bots ?? []).filter((bot) => bot.remote && isListedRemoteChat(bot)); + return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( + "div", + { key: entry.id, className: "bt-flybot-section", "data-flybot": entry.id }, @@ -423,7 +526,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1177,11 +1538,14 @@ function defaultShape(id) { +@@ -1177,11 +1636,14 @@ function defaultShape(id) { value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -440,7 +543,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 image: avatar.image, color: colorOf(bot) }; -@@ -1206,8 +1570,8 @@ function bodyOf(def, paint, flat = false) { +@@ -1206,8 +1668,8 @@ function bodyOf(def, paint, flat = false) { return ""; } } @@ -451,7 +554,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1262,7 +1626,7 @@ function createSource(initial) { +@@ -1262,7 +1724,7 @@ function createSource(initial) { } }; } @@ -460,7 +563,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); function indexRoster(value) { const byId = {}; -@@ -1353,7 +1717,7 @@ var shellTokens = ({ light, dark }) => ({ +@@ -1353,7 +1815,7 @@ var shellTokens = ({ light, dark }) => ({ "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } }); var customThemes = {}; @@ -469,7 +572,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 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"); -@@ -1374,7 +1738,7 @@ function registerShape(id, shape) { +@@ -1374,7 +1836,7 @@ function registerShape(id, shape) { const refresh = () => { markupCache.clear(); maskCache.delete(id); @@ -478,7 +581,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 }; refresh(); return () => { -@@ -1390,11 +1754,11 @@ function registerTheme(id, theme) { +@@ -1390,11 +1852,11 @@ function registerTheme(id, theme) { } const previous = customThemes[id]; customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; @@ -492,7 +595,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 }; } var allThemes = () => ({ ...THEMES, ...customThemes }); -@@ -1463,6 +1827,19 @@ function mentionTarget(event) { +@@ -1463,6 +1925,19 @@ function mentionTarget(event) { const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; } @@ -512,7 +615,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 function clock(ms) { return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); } -@@ -1494,6 +1871,13 @@ function reminderPrompts(text) { +@@ -1494,6 +1969,13 @@ function reminderPrompts(text) { } } var stripHeader = (text) => text.replace(HEADER, ""); @@ -526,7 +629,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 function latestMemoryChange(news, ids) { const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); -@@ -1604,7 +1988,7 @@ function previewOf(list, id, hiddenTurns = []) { +@@ -1604,7 +2086,7 @@ function previewOf(list, id, hiddenTurns = []) { const entry = outline[index]; if (hiddenTurns.includes(entry.turn)) continue; const text = entry.response || entry.prompt || ""; @@ -535,7 +638,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } return ""; } -@@ -1626,7 +2010,7 @@ function usageOf(list, ids) { +@@ -1626,7 +2108,7 @@ function usageOf(list, ids) { } // src/client/motion.js @@ -544,7 +647,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; function springEasing(stiffness, damping) { const w0 = Math.sqrt(stiffness); -@@ -1659,12 +2043,12 @@ function liveSprings(apply2) { +@@ -1659,12 +2141,12 @@ function liveSprings(apply2) { const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); last = now; const steps = Math.ceil(dt * 240); @@ -560,7 +663,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; -@@ -1699,10 +2083,10 @@ function liveSprings(apply2) { +@@ -1699,10 +2181,10 @@ function liveSprings(apply2) { } var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { @@ -574,7 +677,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (own.current === null) { own.current = { key: void 0, -@@ -1736,7 +2120,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1736,7 +2218,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); container.dataset.glide = ""; }; @@ -583,7 +686,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const state = own.current; const node = box.current; if (node === state.node && key === state.key) return; -@@ -1750,7 +2134,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1750,7 +2232,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai } place(fresh); }); @@ -592,7 +695,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 own.current.observer?.disconnect(); own.current.springs.stop(); }, []); -@@ -1758,16 +2142,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1758,16 +2240,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai } function Segmented({ label, value, className, children }) { const glide = useGlide(value); @@ -613,7 +716,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const container = list.current; if (!container) return; const next = /* @__PURE__ */ new Map(); -@@ -1788,8 +2172,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { +@@ -1788,8 +2270,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { }, [key]); } function useMorph(box, key) { @@ -624,7 +727,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const node = box.current; const state = last.current; if (!node) { -@@ -1817,8 +2201,8 @@ function useMorph(box, key) { +@@ -1817,8 +2299,8 @@ function useMorph(box, key) { }); } function useLinger(value, ms) { @@ -635,7 +738,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (value) { setKept(value); return void 0; -@@ -1856,6 +2240,11 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo +@@ -1856,6 +2338,11 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo @keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} @@ -647,7 +750,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 @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 -@@ -1950,21 +2339,21 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1950,21 +2437,21 @@ 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} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -673,7 +776,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 .bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} .bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} -@@ -2599,6 +2988,9 @@ var CONVERSATION_CSS = ` +@@ -2599,6 +3086,9 @@ var CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} .bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} .bt-think summary::-webkit-details-marker{display:none} @@ -683,7 +786,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} -@@ -2856,12 +3248,12 @@ function installCompiledStyles(text, tag) { +@@ -2856,12 +3346,12 @@ function installCompiledStyles(text, tag) { } // src/client/mark.js @@ -698,7 +801,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!enabled) return void 0; let frame = 0; let point = null; -@@ -2895,10 +3287,10 @@ function useGaze(ref, enabled) { +@@ -2895,10 +3385,10 @@ function useGaze(ref, enabled) { 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 ?? lookOf(bot); @@ -712,7 +815,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 useGaze(ref, gaze); const common = { ref, -@@ -2918,31 +3310,31 @@ function BotMark({ bot, look: given, size = 36, state = "idle", live = false, ga +@@ -2918,31 +3408,31 @@ function BotMark({ bot, look: given, size = 36, state = "idle", live = false, ga if (/^bt-poke/.test(event.animationName)) setPoked(false); } : void 0 }; @@ -754,7 +857,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const was = prev.current; prev.current = state; if (state !== "idle") { -@@ -2958,16 +3350,16 @@ function useSettled(state) { +@@ -2958,16 +3448,16 @@ function useSettled(state) { } function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { const settled = useSettled(state); @@ -778,7 +881,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function clusterSeats(count, frame) { -@@ -2984,21 +3376,21 @@ function clusterSeats(count, frame) { +@@ -2984,21 +3474,21 @@ function clusterSeats(count, frame) { 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; @@ -805,7 +908,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "span", { className: "bt-cluster", style: { width: size, height: size } }, seats.map(([x, y, s], index) => { -@@ -3006,75 +3398,75 @@ function RoomAvatar({ room: room2, roster, size = 36 }) { +@@ -3006,75 +3496,75 @@ function RoomAvatar({ room: room2, roster, size = 36 }) { 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) { @@ -906,7 +1009,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); -@@ -3093,14 +3485,14 @@ var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 +@@ -3093,14 +3583,14 @@ var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); function PinMarkIcon() { @@ -925,7 +1028,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ); } -@@ -3112,6 +3504,7 @@ var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h +@@ -3112,6 +3602,7 @@ var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); @@ -933,7 +1036,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); -@@ -3119,14 +3512,14 @@ var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10 +@@ -3119,14 +3610,14 @@ var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10 var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; // src/client/questions.js @@ -951,7 +1054,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (count === 0) return void 0; const onKey = (event) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -@@ -3146,10 +3539,10 @@ function useOptionKeys(count, onPick, ownRef) { +@@ -3146,10 +3637,10 @@ function useOptionKeys(count, onPick, ownRef) { }, [count, ownRef]); } function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { @@ -966,7 +1069,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const options = optionsOf(question); const multi = question.multiSelect === true; const custom = own.trim(); -@@ -3167,7 +3560,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3167,7 +3658,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised }; useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; @@ -975,7 +1078,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ref: ownRef, rows: 1, value: own, -@@ -3186,7 +3579,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3186,7 +3677,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised }) : null; const rows = options.map((option, optionIndex) => { const selected = multi && picked.includes(option.label); @@ -984,7 +1087,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { key: option.label, -@@ -3196,36 +3589,36 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3196,36 +3687,36 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), onClick: () => choose(option.label) }, @@ -1034,7 +1137,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "form", { className: raised ? "bt-q bt-q-raised" : "bt-q", -@@ -3235,24 +3628,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3235,24 +3726,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised submit(); } }, @@ -1069,7 +1172,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) : null, foot ); -@@ -3262,29 +3655,29 @@ function SettledQuestion({ question, answer }) { +@@ -3262,29 +3753,29 @@ function SettledQuestion({ question, answer }) { const prompt = question?.question ?? ""; const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; if (chosen.length === 0) { @@ -1110,7 +1213,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); })) ); -@@ -3292,8 +3685,8 @@ function SettledQuestion({ question, answer }) { +@@ -3292,8 +3783,8 @@ function SettledQuestion({ question, answer }) { function QuestionCard({ matched }) { const pending = matched; const questions = pending.questions ?? []; @@ -1121,7 +1224,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const question = questions[index]; if (question === void 0) return null; const onAnswer = (answer) => { -@@ -3305,10 +3698,10 @@ function QuestionCard({ matched }) { +@@ -3305,10 +3796,10 @@ function QuestionCard({ matched }) { } pending.answer({ answers: next }); }; @@ -1134,7 +1237,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (answered === question.id) return null; const onAnswer = (answer) => { setAnswered(question.id); -@@ -3318,10 +3711,10 @@ function PendingQuestion({ sessionId, question, actions, inline }) { +@@ -3318,10 +3809,10 @@ function PendingQuestion({ sessionId, question, actions, inline }) { setAnswered(question.id); void actions.dismissQuestion(sessionId); }; @@ -1147,7 +1250,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -@@ -3329,16 +3722,16 @@ function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3329,16 +3820,16 @@ function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; const loading = useSession?.(historyLoading) === true; if (question === void 0 || question.callId && (drawn || loading)) return null; @@ -1167,7 +1270,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: question.id, question, answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -@@ -3346,26 +3739,26 @@ function AnsweredCell({ node }) { +@@ -3346,26 +3837,26 @@ function AnsweredCell({ node }) { } // src/client/secret-card.js @@ -1204,7 +1307,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); -@@ -3376,7 +3769,7 @@ var scopeLabel = (scope, roster) => { +@@ -3376,7 +3867,7 @@ var scopeLabel = (scope, roster) => { }; var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { @@ -1213,7 +1316,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 className: "bt-secret-input", type: MASK_BY_CSS ? "text" : "password", "data-masked": MASK_BY_CSS ? "" : void 0, -@@ -3402,11 +3795,11 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +@@ -3402,11 +3893,11 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { } function ScopeSwitch({ value, onChange, bot }) { const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; @@ -1228,7 +1331,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: id, type: "button", role: "radio", -@@ -3417,26 +3810,26 @@ function ScopeSwitch({ value, onChange, bot }) { +@@ -3417,26 +3908,26 @@ function ScopeSwitch({ value, onChange, bot }) { ); } function SecretHead({ bot, title, detail, name, onClose }) { @@ -1267,7 +1370,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); const run = (promise) => { setBusy(true); -@@ -3452,73 +3845,73 @@ function SecretCard({ bot, request, roster, actions }) { +@@ -3452,73 +3943,73 @@ function SecretCard({ bot, request, roster, actions }) { if (!busy) void run(actions.secretAllow(request.id)); }; const filling = request.kind === "fill" || replacing; @@ -1364,7 +1467,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } return null; } -@@ -3531,44 +3924,44 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3531,44 +4022,44 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); if (waiting.length === 0) return null; @@ -1423,7 +1526,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const long = value.trim().length >= MIN_VALUE; const save = () => { if (!long) return; -@@ -3583,66 +3976,66 @@ function ValueEditor({ secret, onSave }) { +@@ -3583,66 +4074,66 @@ function ValueEditor({ secret, onSave }) { setValue(next); setReplaced(false); }; @@ -1516,7 +1619,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; const save = () => { if (!ready) return; -@@ -3653,43 +4046,43 @@ function AddSecret({ actions, run, onDone }) { +@@ -3653,43 +4144,43 @@ function AddSecret({ actions, run, onDone }) { } }); }; @@ -1578,55 +1681,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ]; } var SECRET_CSS = ` -@@ -3756,6 +4149,47 @@ var SECRET_CSS = ` - @media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} - `; - -+// src/shared/remote-label.js -+function cleanPeerName(name, fallback = "") { -+ const text = String(name ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"`<>]/g, "").replace(/\s+/g, " ").trim().slice(0, 60); -+ return text || fallback; -+} -+function ownerFromHandle(handle) { -+ const parts = String(handle ?? "").replace(/^@/, "").split("."); -+ if (parts.length >= 3 && parts.at(-1) === "home") return parts.at(-2); -+ if (parts.length === 2 && parts[1] === "home") return parts[0]; -+ return ""; -+} -+function ownerNameOf(record) { -+ if (!record) return ""; -+ return cleanPeerName(record.ownerName || record.homeName || record.owner || record.ownerHome || record.home || ownerFromHandle(record.handle), ""); -+} -+function isChiefRecord(record) { -+ if (!record) return false; -+ if (record.isChief === true) return true; -+ return /^chief$/i.test(String(record.displayName || record.name || "")); -+} -+function remoteBotTitle(record) { -+ const owner = ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); -+ const name = cleanPeerName(record?.displayName || record?.name, cleanPeerName(record?.handle, "Bot")); -+ if (isChiefRecord(record)) return `${owner}'s Chief`; -+ return `${name} · ${owner}'s Bot`; -+} -+function remoteOwnerBadge(record) { -+ const owner = ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); -+ return isChiefRecord(record) ? `${owner}'s Chief` : `${owner}'s Bot`; -+} -+function sharedRoomTitle(inviterName) { -+ return `Shared room from ${cleanPeerName(inviterName, "a friend")}`; -+} -+function disambiguatedRemoteTitle(record, others = []) { -+ const title = remoteBotTitle(record); -+ const name = cleanPeerName(record?.displayName || record?.name); -+ if (!name) return title; -+ const clash = others.some((item) => item !== record && cleanPeerName(item?.displayName || item?.name) === name && ownerNameOf(item) !== ownerNameOf(record)); -+ return clash ? title : title; -+} -+ - // src/client/turn-reasoning.js - function turnReasoning(steps, current) { - const members = steps.length ? steps : [current]; -@@ -3767,7 +4201,7 @@ var thinkingLive = (step) => step.status === "running" && texts(step).length === +@@ -3767,7 +4258,7 @@ var thinkingLive = (step) => step.status === "running" && texts(step).length === var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); // src/client/cells.js @@ -1635,7 +1690,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var import_react_dom = require("react-dom"); var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -@@ -3780,7 +4214,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3780,7 +4271,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, actions.openSession(target); }; const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; @@ -1644,7 +1699,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "div", { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, texts2.map((text, index) => { -@@ -3788,21 +4222,21 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3788,21 +4279,21 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, const picked = key ? reactions?.[index] ?? [] : []; const last = index === texts2.length - 1; const live = streaming && last; @@ -1672,7 +1727,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: emoji, type: "button", className: "bt-react", -@@ -3815,7 +4249,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3815,7 +4306,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, } var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; function useDismiss(open, close, refs) { @@ -1681,7 +1736,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!open) return void 0; const onDown = (event) => { if (refs.some((ref) => ref.current?.contains(event.target))) return; -@@ -3836,11 +4270,11 @@ function useDismiss(open, close, refs) { +@@ -3836,11 +4327,11 @@ function useDismiss(open, close, refs) { }, [open]); } function MessageTools({ text, name, sessionId, react, actions }) { @@ -1698,7 +1753,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const close = () => setPanel(null); useDismiss(panel !== null, close, [tools, pop]); const open = (kind, event) => { -@@ -3865,25 +4299,25 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3865,25 +4356,25 @@ function MessageTools({ text, name, sessionId, react, actions }) { link.click(); setTimeout(() => URL.revokeObjectURL(url), 1e3); }; @@ -1733,7 +1788,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "div", { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, item(t("Copy"), CopyIcon, copy2), -@@ -3892,11 +4326,12 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3892,11 +4383,12 @@ function MessageTools({ text, name, sessionId, react, actions }) { ); } function ThinkingRow({ text }) { @@ -1749,7 +1804,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -@@ -3904,7 +4339,7 @@ function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +@@ -3904,7 +4396,7 @@ function useFileMentions({ node, openFile, fileMentions, useTurnData }) { const location2 = node.location; const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; const seq = node.data.finalNode?.seq; @@ -1758,7 +1813,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; }, [fileMentions, openFile, seq, tail, turn]); -@@ -3916,7 +4351,7 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3916,7 +4408,7 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use const contexts = useTurnContext(String(data.turn)); const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); @@ -1767,7 +1822,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const running = data.status === "running"; useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); if (hidden) return null; -@@ -3924,13 +4359,13 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3924,13 +4416,13 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; if (texts2.length === 0 && !reasoning && relays.length === 0) return null; const selfId = roster.byId[sessionId]?.id ?? sessionId; @@ -1784,7 +1839,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +4375,131 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +4432,131 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -1937,7 +1992,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -@@ -4005,14 +4510,14 @@ function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +@@ -4005,14 +4567,14 @@ function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { const target = other && other.id !== self?.id ? other : self; if (!target) return null; const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); @@ -1955,7 +2010,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } var scrollParent = (node) => { for (let at = node.parentElement; at; at = at.parentElement) { -@@ -4021,11 +4526,11 @@ var scrollParent = (node) => { +@@ -4021,11 +4583,11 @@ var scrollParent = (node) => { return null; }; function EarlierParts({ sessionId, roster, actions }) { @@ -1972,7 +2027,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const load = (cursor) => { if (busy.current) return; busy.current = true; -@@ -4041,19 +4546,19 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4041,19 +4603,19 @@ function EarlierParts({ sessionId, roster, actions }) { if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); }); }; @@ -1995,7 +2050,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const node = top.current; if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; const observer = new IntersectionObserver((entries2) => { -@@ -4069,35 +4574,35 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4069,35 +4631,35 @@ function EarlierParts({ sessionId, roster, actions }) { if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); } @@ -2042,7 +2097,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (selfId && other.id) actions.openExchange(selfId, other.id); }, whole: true }); } -@@ -4105,7 +4610,7 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4105,7 +4667,7 @@ function EarlierParts({ sessionId, roster, actions }) { return null; } }), @@ -2051,7 +2106,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -@@ -4116,7 +4621,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4116,7 +4678,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us const deferred = source.kind === "bot" && source.deferred === true; const relayed = isRelayContext(data); const turn = node.location?.turn?.turn ?? data.turn; @@ -2060,7 +2115,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); }, [relayed, sessionId, turn, source.senderSessionId]); const order = useSessions((list) => { -@@ -4135,23 +4640,29 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4135,23 +4697,29 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); const body = triggerBody(); @@ -2096,7 +2151,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const run = relayed ? commRunOf( order.split(",").filter(Boolean).map(Number), Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -@@ -4165,7 +4676,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4165,7 +4733,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); } @@ -2105,7 +2160,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), bots, onOpen: (bot) => actions.openExchange(selfId, bot.id), -@@ -4174,24 +4685,24 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4174,24 +4742,24 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us } if (source.kind === "schedule") { const prompts = reminderPrompts(contentText(data.content)); @@ -2135,7 +2190,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 actions.noteTurnTime(sessionId, turn, "first", start); }, [sessionId, turn, start]); const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,7 +4710,7 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu +@@ -4199,7 +4767,7 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu const own = state[sessionId]?.[turn]; return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; }); @@ -2144,7 +2199,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } function Nothing() { return null; -@@ -4236,7 +4747,7 @@ var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > vis +@@ -4236,7 +4804,7 @@ var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > vis function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { const turn = location2.turn; const time = location2.end.time; @@ -2153,7 +2208,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 actions.noteTurnTime(sessionId, turn, "end", time); }, [sessionId, turn, time]); const label = useTurnClock((state) => { -@@ -4250,15 +4761,15 @@ function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions +@@ -4250,15 +4818,15 @@ function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions return start !== void 0 && start > visit.seen && start <= visit.entered; }); if (!label && !fresh) return null; @@ -2174,7 +2229,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } var PHRASES = { thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -@@ -4324,15 +4835,15 @@ function workPhrase(tool) { +@@ -4324,15 +4892,15 @@ function workPhrase(tool) { } function useLiveNote(actions, sessionId, id, entry) { const key = entry ? JSON.stringify(entry) : ""; @@ -2193,7 +2248,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (done) return void 0; let stop = false; let timer; -@@ -4366,66 +4877,88 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -4366,66 +4934,88 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; if (replies.length === 0 && pending.length === 0) return null; const room2 = roster.roomsById[sessionId]; @@ -2270,7 +2325,8 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 + } + 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" }, 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, { @@ -2283,8 +2339,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 + 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) => { @@ -2301,7 +2356,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); }) ); -@@ -4435,52 +4968,52 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +@@ -4435,52 +5025,52 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); function callTree(root) { const calls = []; const seen = /* @__PURE__ */ new Set(); @@ -2370,7 +2425,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 case "create_group": case "update_group": case "delete_group": -@@ -4492,41 +5025,41 @@ function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot +@@ -4492,41 +5082,41 @@ function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot post_to_group: ["Posted in", "Couldn't post in the group"] }[tool]; if (!done) return null; @@ -2422,7 +2477,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 default: return null; } -@@ -4538,18 +5071,18 @@ function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { +@@ -4538,18 +5128,18 @@ function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { const hidden = isHiddenTurn(roster, sessionId, turn); const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); const calls = hidden ? [] : callTree(root); @@ -2446,7 +2501,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var import_react_dom2 = require("react-dom"); var PHRASE_HOLD_MS = 800; var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -@@ -4560,8 +5093,8 @@ function elapsedLabel(ms) { +@@ -4560,8 +5150,8 @@ function elapsedLabel(ms) { return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } function useRunningHost(active) { @@ -2457,7 +2512,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!active) { setHost(null); return void 0; -@@ -4585,9 +5118,9 @@ function useRunningHost(active) { +@@ -4585,9 +5175,9 @@ function useRunningHost(active) { return host; } function useRowEntrances(replacing) { @@ -2469,7 +2524,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const seen = /* @__PURE__ */ new WeakSet(); const started = Date.now(); let column = null; -@@ -4638,21 +5171,21 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4638,21 +5228,21 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; @@ -2496,7 +2551,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (wantKey === null || shown?.key === wantKey) return void 0; const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -@@ -4663,12 +5196,12 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4663,12 +5253,12 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us const timer = setTimeout(make, wait); return () => clearTimeout(timer); }, [wantKey, shown]); @@ -2514,7 +2569,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (visible) { setPhase("in"); return void 0; -@@ -4680,7 +5213,7 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4680,7 +5270,7 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us }, 140); return () => clearTimeout(timer); }, [visible]); @@ -2523,7 +2578,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (phase !== null) return; endedAt.current = Date.now(); setShown(null); -@@ -4691,12 +5224,12 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4691,12 +5281,12 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us const delta = before - scroller.scrollTop; if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); }, [phase]); @@ -2539,7 +2594,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (phase !== "in") return void 0; const timer = setInterval(() => setNow(Date.now()), 1e4); return () => clearInterval(timer); -@@ -4704,18 +5237,18 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4704,18 +5294,18 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us const host = useRunningHost(running); if (!host || phase === null || !shown) return null; const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; @@ -2564,7 +2619,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ) ), host); -@@ -4732,32 +5265,32 @@ function showsDshRow(dsh, app) { +@@ -4732,32 +5322,32 @@ function showsDshRow(dsh, app) { } // src/client/sidebar.js @@ -2604,7 +2659,14 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const approval = useApproval(useWork, (entry) => entry.botId === id); const alert = roster.questions?.[part] !== void 0 || approval; const doing = useActivity((map) => map[id]); -@@ -4771,9 +5304,10 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4765,15 +5355,16 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio + const away = !running && groups.length > 0; + const state = botState(running || away, doing, alert); + const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; +- if (bot.hidden && !active && !main) return null; ++ if (!isListedRemoteChat(bot) && !active && !main) return null; + const onContextMenu = (event) => { + event.preventDefault(); actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); }; const pinned = bot.pinned === true && !tile; @@ -2617,7 +2679,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { type: "button", -@@ -4785,12 +5319,12 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4785,12 +5376,12 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, @@ -2634,7 +2696,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { type: "button", -@@ -4802,21 +5336,33 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4802,21 +5393,33 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, @@ -2676,7 +2738,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; useMarkedUnread(id, active, marked, actions); -@@ -4826,8 +5372,10 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, +@@ -4826,8 +5429,10 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; if (room2.hidden && !active) return null; const pinned = room2.pinned === true; @@ -2689,7 +2751,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { type: "button", -@@ -4841,20 +5389,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, +@@ -4841,20 +5446,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, @@ -2718,7 +2780,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!renaming) return; input.current?.focus(); input.current?.select(); -@@ -4875,10 +5423,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4875,10 +5480,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { } })(); }; @@ -2731,7 +2793,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ref: input, defaultValue: session.title, "aria-label": t("Rename"), -@@ -4891,7 +5439,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4891,7 +5496,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { }) ); } @@ -2740,7 +2802,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { type: "button", -@@ -4905,8 +5453,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4905,8 +5510,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -2751,7 +2813,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -@@ -4941,10 +5489,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4941,10 +5546,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, const preview = sessions[0]?.title ?? t("New session"); const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); const open = active && wide; @@ -2764,7 +2826,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { type: "button", -@@ -4959,30 +5507,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4959,30 +5564,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -2806,7 +2868,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: session.id, session, agent, -@@ -4996,8 +5544,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4996,8 +5601,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { @@ -2817,7 +2879,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); -@@ -5014,9 +5562,40 @@ function useListGlide(listRef, layout, key) { +@@ -5014,9 +5619,40 @@ function useListGlide(listRef, layout, key) { tops.current = { layout, map: next }; }, [layout, key]); } @@ -2859,7 +2921,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); -@@ -5025,10 +5604,11 @@ function TeamSidebar(props) { +@@ -5025,10 +5661,11 @@ function TeamSidebar(props) { for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -2873,7 +2935,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ...roster.rooms.map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), // The Agent sorts by its most recent Session, falling back to when it was added. ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) -@@ -5036,6 +5616,10 @@ function TeamSidebar(props) { +@@ -5036,6 +5673,10 @@ function TeamSidebar(props) { return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at); }, [roster, updated]); const main = roster.byId[roster.mainBotId]; @@ -2884,7 +2946,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); useLandOnMainBot(roster, useSessions, actions); const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -@@ -5045,39 +5629,40 @@ function TeamSidebar(props) { +@@ -5045,39 +5686,40 @@ function TeamSidebar(props) { event.preventDefault(); actions.openPalette(event.key); }; @@ -2945,7 +3007,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -@@ -5120,10 +5705,10 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5120,10 +5762,10 @@ function UpdateButton({ useUpdate, actions }) { const message = error || (phase === "failed" ? update.error : ""); const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update DS Bot {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); @@ -2959,7 +3021,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ref: button, type: "button", className: "bt-round bt-update", -@@ -5133,21 +5718,21 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5133,21 +5775,21 @@ function UpdateButton({ useUpdate, actions }) { "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle @@ -2991,7 +3053,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 type: "button", className: "bt-update-go", disabled: phase === "installing", -@@ -5155,17 +5740,17 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5155,17 +5797,17 @@ function UpdateButton({ useUpdate, actions }) { }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, @@ -3016,7 +3078,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 type: "button", className: "bt-update-go", disabled: dshPhase === "installing", -@@ -5177,60 +5762,24 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5177,60 +5819,24 @@ function UpdateButton({ useUpdate, actions }) { ); } function ConnectPlugins({ wide, actions }) { @@ -3086,7 +3148,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } var openShellSettings = null; -@@ -5288,32 +5837,32 @@ function pressSettingsShortcut() { +@@ -5288,32 +5894,32 @@ function pressSettingsShortcut() { })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -3127,7 +3189,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -@@ -5349,22 +5898,22 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5349,22 +5955,22 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] ] ]; @@ -3159,7 +3221,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: label, type: "button", role: "menuitem", -@@ -5372,7 +5921,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5372,7 +5978,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -3168,7 +3230,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 )) ), document.body) : null ); -@@ -5382,8 +5931,8 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5382,8 +5988,8 @@ function useLandOnMainBot(roster, useSessions, actions) { const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; }); @@ -3179,7 +3241,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!roster.ready || !roster.mainBotId) return void 0; const [id, blank3] = mainView.split("|"); const bot = id === "" ? void 0 : roster.byId[id]; -@@ -5404,16 +5953,16 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5404,16 +6010,16 @@ function useLandOnMainBot(roster, useSessions, actions) { function FooterAccount(props) { const seated = props.useUi((value) => value.accountSeated === true); if (seated) return null; @@ -3200,7 +3262,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } var RETURN_CSS = ` -@@ -5427,10 +5976,10 @@ var RETURN_CSS = ` +@@ -5427,10 +6033,10 @@ var RETURN_CSS = ` .bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} `; function BrandMark({ size = 24 }) { @@ -3213,7 +3275,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5494,15 +6043,15 @@ var SIDEBAR_CSS = ` +@@ -5494,15 +6100,15 @@ var SIDEBAR_CSS = ` `; // src/client/header.js @@ -3233,7 +3295,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; -@@ -5512,16 +6061,16 @@ function useMemoryNotice(ids, useMemoryNews) { +@@ -5512,16 +6118,16 @@ function useMemoryNotice(ids, useMemoryNews) { return shown; } function MemoryNotice({ shown }) { @@ -3255,7 +3317,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -@@ -5537,7 +6086,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5537,7 +6143,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); @@ -3264,7 +3326,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5548,19 +6097,19 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,19 +6154,19 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -3288,7 +3350,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (room2) document.body.dataset.btRoom = ""; else delete document.body.dataset.btRoom; return () => { -@@ -5568,10 +6117,10 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5568,10 +6174,10 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe }; }, [Boolean(room2)]); const memoryNotice = notice && changed !== null && roster.memory !== false; @@ -3301,7 +3363,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { type: "button", -@@ -5581,19 +6130,19 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5581,19 +6187,19 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed]?.name ?? name }) : t("View conversation details"), onClick: () => memoryNotice ? actions.openMemory(changed) : actions.toggleDetails(sessionId) }, @@ -3330,7 +3392,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); -@@ -5658,18 +6207,18 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5658,18 +6264,18 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -3354,7 +3416,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const scroller = document.querySelector("[data-conversation-scroll]"); if (!scroller) return void 0; scroller.toggleAttribute("data-bt-news", showDown); -@@ -5677,16 +6226,16 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5677,16 +6283,16 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }, [showDown]); if (!view || !showDown && !showUp) return null; const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); @@ -3375,7 +3437,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), -@@ -5694,7 +6243,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5694,7 +6300,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { event.stopPropagation(); onDismiss(); } @@ -3384,7 +3446,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5704,8 +6253,8 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5704,8 +6310,8 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); return (0, import_react_dom4.createPortal)( @@ -3395,7 +3457,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 null, showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -@@ -5715,18 +6264,18 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5715,18 +6321,18 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { } // src/client/composer.js @@ -3421,7 +3483,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); -@@ -5774,12 +6323,31 @@ function useDictation(onText) { +@@ -5774,12 +6380,31 @@ function useDictation(onText) { const stop = () => recognition.current?.stop(); return { listening, note, start, stop }; } @@ -3454,7 +3516,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!quote || !inputActions) return; actions.clearQuote(quote.token); const excerpt = quote.text.replace(/\s+/g, " ").trim(); -@@ -5797,10 +6365,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5797,10 +6422,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; @@ -3468,7 +3530,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 type: "button", className: "bt-mic", "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), -@@ -5808,16 +6376,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5808,16 +6433,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { title: dictation.listening ? t("Stop dictation") : t("Dictate"), onMouseDown: keepComposerFocus, onClick: () => dictation.listening ? dictation.stop() : dictation.start() @@ -3489,7 +3551,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function menuIcons(roster) { -@@ -5825,17 +6393,17 @@ function menuIcons(roster) { +@@ -5825,17 +6450,17 @@ function menuIcons(roster) { return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; @@ -3512,7 +3574,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!open) return void 0; const onDown = (event) => { if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); -@@ -5876,10 +6444,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5876,10 +6501,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); @@ -3526,7 +3588,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ref: button, type: "button", className: "bt-plus", -@@ -5889,8 +6457,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5889,8 +6514,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle @@ -3537,7 +3599,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "div", { ref: menu, -@@ -5900,9 +6468,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5900,9 +6525,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, @@ -3550,7 +3612,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ), document.body) : null ); } -@@ -5922,13 +6490,18 @@ var conversationOf = (roster, sessionId) => { +@@ -5922,13 +6547,18 @@ var conversationOf = (roster, sessionId) => { function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -3559,7 +3621,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 + ...here.room.members.map((id) => roster.byId[id]).filter(Boolean), + ...(here.room.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), + ...(here.room.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => ({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, handle: p.handle })) -+ ] : roster.bots.filter((bot) => bot.id !== here.self.id); ++ ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id); const needle = query.trim().toLowerCase(); const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` })); const everyone = t("Everyone"); @@ -3570,7 +3632,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 return rows; } function chatRows(roster, list, sessionId) { -@@ -5959,14 +6532,26 @@ function atSources({ roster, sessions, launched, iconFor }) { +@@ -5959,14 +6589,26 @@ function atSources({ roster, sessions, launched, iconFor }) { order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -3599,7 +3661,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 }, onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 }; -@@ -6122,15 +6707,15 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { +@@ -6122,15 +6764,15 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { } // src/client/overlay-hooks.js @@ -3618,7 +3680,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); escapeLayers.push(ref); return () => { -@@ -6140,8 +6725,8 @@ function useEscape(onClose) { +@@ -6140,8 +6782,8 @@ function useEscape(onClose) { }, []); } function usePaneLeft() { @@ -3629,7 +3691,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const column = document.querySelector('[class*="_footArea"]')?.parentElement; if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); }, []); -@@ -6149,13 +6734,13 @@ function usePaneLeft() { +@@ -6149,13 +6791,13 @@ function usePaneLeft() { } // src/client/palette.js @@ -3647,16 +3709,21 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const wanted = query.trim().toLowerCase(); const close = () => { if (leaving) return; -@@ -6191,7 +6776,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6189,10 +6831,10 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { + const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted)); + const sections = []; const people = [ - ...roster.bots.filter((bot) => matchesName(bot.name, bot.role)).map((bot) => ({ +- ...roster.bots.filter((bot) => matchesName(bot.name, bot.role)).map((bot) => ({ ++ ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({ key: bot.id, - avatar: (0, import_react12.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), +- title: bot.name, + avatar: (0, import_react13.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), - title: bot.name, ++ title: pickerBotLabel(bot), subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "), badge: bot.hidden ? t("Hidden") : void 0, -@@ -6202,7 +6787,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { + run: async () => { +@@ -6202,7 +6844,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { })), ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({ key: room2.id, @@ -3665,7 +3732,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 title: room2.name, subtitle: t("{count} members", { count: room2.members.length }), badge: room2.hidden ? t("Hidden") : void 0, -@@ -6215,7 +6800,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6215,7 +6857,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { if (people.length) sections.push([t("Bots"), people]); const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({ key: agent.id, @@ -3674,7 +3741,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 title: "DSH Agent", subtitle: t("Plain dsh session"), run: () => actions.openAgent(agent.id) -@@ -6228,16 +6813,16 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6228,16 +6870,16 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { key: `${id}:${turn}`, title: bot?.name ?? room2?.name ?? t("Bot"), subtitle: snippet, @@ -3696,7 +3763,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ].filter((command) => matchesName(command.title)); if (commands.length) sections.push([t("Commands"), commands]); const rows = sections.flatMap(([, items]) => items); -@@ -6247,7 +6832,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6247,7 +6889,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { ${shown}`, { axis: "y", ...QUICK_EDGES }); const listRef = glide.box; useFlip(listRef, shown, "[data-flip]"); @@ -3705,7 +3772,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" }); }, [active, query]); const choose = (row) => { -@@ -6256,18 +6841,18 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6256,18 +6898,18 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); close(); }; let index = -1; @@ -3730,7 +3797,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 autoFocus: true, value: query, placeholder: t("Search Bots, chats and messages"), -@@ -6293,29 +6878,29 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6293,29 +6935,29 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); } } }), @@ -3772,7 +3839,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { key: row.key, -@@ -6329,14 +6914,14 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6329,14 +6971,14 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); }, onClick: () => choose(row) }, @@ -3792,7 +3859,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); }) )) -@@ -6346,7 +6931,7 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6346,7 +6988,7 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); } // src/client/model-menu.js @@ -3801,7 +3868,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var import_react_dom6 = require("react-dom"); var MENU_EXIT_MS = 120; function groupModels(models) { -@@ -6376,8 +6961,8 @@ function modelOf(bot, roster) { +@@ -6376,8 +7018,8 @@ function modelOf(bot, roster) { } var effortCache = /* @__PURE__ */ new Map(); function useEfforts(served, ref, actions) { @@ -3812,7 +3879,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 setFetched(void 0); if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; let live = true; -@@ -6402,12 +6987,12 @@ function effortRows(reasoning) { +@@ -6402,12 +7044,12 @@ function effortRows(reasoning) { ]; } function EffortRows({ reasoning, current, onPick }) { @@ -3830,7 +3897,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { key: row.effort ?? "default", -@@ -6416,17 +7001,17 @@ function EffortRows({ reasoning, current, onPick }) { +@@ -6416,17 +7058,17 @@ function EffortRows({ reasoning, current, onPick }) { "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, @@ -3854,7 +3921,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { key: model.ref, -@@ -6436,20 +7021,20 @@ function ModelRows({ roster, current, onPick }) { +@@ -6436,20 +7078,20 @@ function ModelRows({ roster, current, onPick }) { title: model.ref, onClick: () => onPick(model.ref) }, @@ -3883,7 +3950,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (place === null) return void 0; const onDown = (event) => { if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; -@@ -6484,6 +7069,7 @@ function useAnchoredMenu() { +@@ -6484,6 +7126,7 @@ function useAnchoredMenu() { return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; } function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { @@ -3891,7 +3958,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); const shown = modelOf(bot, roster); const current = bot.model ?? bot.appliedModel; -@@ -6511,10 +7097,10 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -6511,10 +7154,10 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; const models = roster.models ?? []; @@ -3905,7 +3972,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { ref: button, -@@ -6527,37 +7113,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -6527,37 +7170,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, onClick: toggle }, @@ -3957,7 +4024,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const models = roster.models ?? []; const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; if (bot.model && (!lost || models.length === 0)) return null; -@@ -6570,12 +7157,12 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6570,12 +7214,12 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { }).finally(() => setBusy("")); }; const text = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); @@ -3974,7 +4041,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: model.ref, type: "button", className: "bt-model-chip", -@@ -6584,29 +7171,49 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6584,29 +7228,49 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { "aria-label": `${model.name} · ${provider}`, title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) @@ -4034,7 +4101,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function setupChoices(actions) { -@@ -6620,14 +7227,14 @@ function DeepSeekSignIn({ useUi, actions }) { +@@ -6620,14 +7284,14 @@ function DeepSeekSignIn({ useUi, actions }) { const open = useUi((state) => state.signIn); const entry = open ? actions.signInEntry?.() : null; if (!entry) return null; @@ -4054,7 +4121,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ...ops, ...hooks, t: actions.accountT(), -@@ -6676,17 +7283,17 @@ var MODEL_CSS = ` +@@ -6676,17 +7340,17 @@ var MODEL_CSS = ` `; // src/client/folder-menu.js @@ -4079,7 +4146,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); const toggle = () => { if (isOpen) { -@@ -6719,35 +7326,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6719,35 +7383,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } }; const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; @@ -4131,7 +4198,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,33 +7366,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6759,33 +7423,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -4181,7 +4248,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { ref: button, -@@ -6798,19 +7405,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6798,19 +7462,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } title: value, onClick: toggle }, @@ -4207,7 +4274,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ), document.body) : null ); } -@@ -6830,8 +7437,8 @@ var FOLDER_CSS = ` +@@ -6830,8 +7494,8 @@ var FOLDER_CSS = ` `; // src/client/schedules.js @@ -4218,7 +4285,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { -@@ -6839,8 +7446,8 @@ function publish(view) { +@@ -6839,8 +7503,8 @@ function publish(view) { for (const listener of listeners) listener(view); } function useSchedules(actions) { @@ -4229,7 +4296,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 listeners.add(setView); let stopped = false; let timer; -@@ -6848,7 +7455,7 @@ function useSchedules(actions) { +@@ -6848,7 +7512,7 @@ function useSchedules(actions) { if (!stopped) publish(next); }).catch(() => { }).finally(() => { @@ -4238,7 +4305,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 }); load(); return () => { -@@ -6898,9 +7505,9 @@ function timingText(task) { +@@ -6898,9 +7562,9 @@ function timingText(task) { } var nextText = (task) => task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished"); function TaskRow({ task, actions, onView, showBot, roster }) { @@ -4251,7 +4318,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6914,19 +7521,19 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6914,19 +7578,19 @@ function TaskRow({ task, actions, onView, showBot, roster }) { void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; @@ -4279,7 +4346,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, -@@ -6934,7 +7541,7 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6934,7 +7598,7 @@ function TaskRow({ task, actions, onView, showBot, roster }) { "aria-label": confirming ? t("Click again to delete") : t("Delete task"), title: confirming ? void 0 : t("Delete task"), onClick: remove @@ -4288,7 +4355,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } var pad = (number) => String(number).padStart(2, "0"); -@@ -6946,17 +7553,17 @@ function inAnHour() { +@@ -6946,17 +7610,17 @@ function inAnHour() { var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; var UNITS = [["minutes", 1, "minutes"], ["hours", 60, "hours"], ["days", 1440, "days"]]; function TaskForm({ roster, actions, start, onDone, onCancel }) { @@ -4317,7 +4384,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const timing = () => { if (kind === "at") return { kind, at: new Date(at).toISOString() }; if (kind === "every") return { kind, minutes: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) }; -@@ -6981,101 +7588,101 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { +@@ -6981,101 +7645,101 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { }); }; const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); @@ -4462,7 +4529,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 )) ]; } -@@ -7083,21 +7690,21 @@ function BotSchedules({ bot, roster, actions }) { +@@ -7083,21 +7747,21 @@ function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); if (view === null) return null; const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; @@ -4492,7 +4559,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ] ) ); -@@ -7105,16 +7712,16 @@ function BotSchedules({ bot, roster, actions }) { +@@ -7105,16 +7769,16 @@ function BotSchedules({ bot, roster, actions }) { function ScheduleSummary({ bot, actions }) { const view = useSchedules(actions); const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id && task.status === "active") : []; @@ -4515,7 +4582,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ))) ); } -@@ -7158,7 +7765,7 @@ var SCHEDULE_CSS = ` +@@ -7158,7 +7822,7 @@ var SCHEDULE_CSS = ` `; // src/client/capability-settings.js @@ -4524,7 +4591,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); var parseMap = (text, label) => { let value; -@@ -7172,11 +7779,11 @@ var parseMap = (text, label) => { +@@ -7172,11 +7836,11 @@ var parseMap = (text, label) => { }; var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { @@ -4539,7 +4606,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, -@@ -7188,7 +7795,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl +@@ -7188,7 +7852,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { @@ -4548,7 +4615,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); const submit = async (event) => { event.preventDefault(); -@@ -7219,95 +7826,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { +@@ -7219,95 +7883,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); @@ -4684,7 +4751,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); -@@ -7330,20 +7937,20 @@ function CapabilitiesPage({ roster, actions, run }) { +@@ -7330,20 +7994,20 @@ function CapabilitiesPage({ roster, actions, run }) { setConfirming(""); void run(actions.deleteCapability(item.name)); }; @@ -4712,7 +4779,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +7966,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7359,10 +8023,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; @@ -4725,7 +4792,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: item.name, label: item.name, hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7373,8 +7980,8 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7373,8 +8037,8 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow } // src/client/memory-panel.js @@ -4736,7 +4803,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); -@@ -7384,8 +7991,8 @@ function noteCount(botId, count) { +@@ -7384,8 +8048,8 @@ function noteCount(botId, count) { for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -4747,7 +4814,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const listener = () => setCount(counts.get(botId) ?? null); countListeners.add(listener); listener(); -@@ -7404,16 +8011,16 @@ function useMemoryCount(botId, actions) { +@@ -7404,16 +8068,16 @@ function useMemoryCount(botId, actions) { function MemoryRow({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -4770,7 +4837,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ); } -@@ -7428,45 +8035,45 @@ function ago(at, now) { +@@ -7428,45 +8092,45 @@ function ago(at, now) { return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { @@ -4835,7 +4902,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ); } -@@ -7478,9 +8085,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { +@@ -7478,9 +8142,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { return groups; }, []); function EntryRow({ entry, remove }) { @@ -4848,7 +4915,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -7495,16 +8102,16 @@ function EntryRow({ entry, remove }) { +@@ -7495,16 +8159,16 @@ function EntryRow({ entry, remove }) { setBusy(true); if (!await remove(entry)) setBusy(false); }; @@ -4870,7 +4937,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, -@@ -7512,7 +8119,7 @@ function EntryRow({ entry, remove }) { +@@ -7512,7 +8176,7 @@ function EntryRow({ entry, remove }) { "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click @@ -4879,7 +4946,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function Entries({ bot, view, remove }) { -@@ -7521,25 +8128,25 @@ function Entries({ bot, view, remove }) { +@@ -7521,25 +8185,25 @@ function Entries({ bot, view, remove }) { { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own }, { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team } ]; @@ -4916,7 +4983,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 )) ); } -@@ -7551,52 +8158,52 @@ function changeLine(change) { +@@ -7551,52 +8215,52 @@ function changeLine(change) { return change.scope === "team" ? t("Added to team memory: {text}", { text }) : t("Added: {text}", { text }); } function Reply({ answer, dismiss }) { @@ -4988,7 +5055,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 alive.current = true; void load({ summarize: true }); return () => { -@@ -7607,8 +8214,8 @@ function useMemoryView(botId, actions) { +@@ -7607,8 +8271,8 @@ function useMemoryView(botId, actions) { return { view, error, load, show }; } function useNow(every) { @@ -4999,7 +5066,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); -@@ -7616,18 +8223,18 @@ function useNow(every) { +@@ -7616,18 +8280,18 @@ function useNow(every) { } function MemoryDialog({ bot, actions, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); @@ -5027,7 +5094,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!menu) return void 0; const onDown = (event) => { if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -@@ -7689,52 +8296,52 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7689,52 +8353,52 @@ function MemoryDialog({ bot, actions, leaving = false }) { const when3 = view?.summary ? ago(view.summary.at, now) : null; const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : ""; const busy = answer?.pending === true; @@ -5103,7 +5170,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ref: inputRef, className: "bt-mem-input", value: text, -@@ -7744,10 +8351,10 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7744,10 +8408,10 @@ function MemoryDialog({ bot, actions, leaving = false }) { "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }), onChange: (event) => setText(event.target.value) }), @@ -5116,7 +5183,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ) ) -@@ -7756,7 +8363,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7756,7 +8420,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { } // src/client/bot-settings.js @@ -5125,7 +5192,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var capitalize = (word) => word[0].toUpperCase() + word.slice(1); async function avatarImage(file) { if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image")); -@@ -7780,8 +8387,8 @@ async function avatarImage(file) { +@@ -7780,8 +8444,8 @@ async function avatarImage(file) { throw new Error(t("That image is too large")); } function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) { @@ -5136,7 +5203,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const commit = () => { const next = draft?.trim(); setDraft(null); -@@ -7792,7 +8399,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu +@@ -7792,7 +8456,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu if (next === void 0 || next === (value ?? "") || required && next === "") return; void onSave(next); }; @@ -5145,7 +5212,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", value: draft ?? value ?? "", placeholder, -@@ -7813,29 +8420,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu +@@ -7813,29 +8477,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu }); } function Row({ label, hint, stack = false, children }) { @@ -5183,7 +5250,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } function modelHint(bot, roster) { -@@ -7846,22 +8453,22 @@ function modelHint(bot, roster) { +@@ -7846,22 +8510,22 @@ function modelHint(bot, roster) { return t("Applies from the next message."); } function ShapeRing({ shape, size }) { @@ -5211,7 +5278,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: id, type: "button", className: "bt-shape", -@@ -7869,8 +8476,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { +@@ -7869,8 +8533,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { title: t(shapeOf(id).label), "aria-pressed": !hasPhoto && look.shape === id, onClick: () => onShape(id) @@ -5222,7 +5289,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: color, type: "button", className: "bt-color", -@@ -7878,19 +8485,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { +@@ -7878,19 +8542,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { title: t(capitalize(color)), "aria-pressed": look.color === color, onClick: () => onColor(color) @@ -5248,7 +5315,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const onDown = (event) => { if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; onClose(); -@@ -7908,7 +8515,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7908,7 +8572,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { window.removeEventListener("keydown", onKey, true); }; }, [onClose]); @@ -5257,7 +5324,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (tab !== "upload") return void 0; const onPaste = (event) => { const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -@@ -7922,32 +8529,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7922,32 +8586,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { }, [tab, bot]); const hasPhoto = Boolean(look.image); const glide = useGlide(tab); @@ -5298,7 +5365,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "div", { className: "bt-dropzone", -@@ -7963,15 +8570,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7963,15 +8627,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { upload(event.dataTransfer.files?.[0]); } }, @@ -5320,7 +5387,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const file = event.target.files?.[0]; event.target.value = ""; upload(file); -@@ -7982,22 +8589,22 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7982,22 +8646,22 @@ function AvatarEditor({ bot, actions, run, onClose }) { function MemoryCard({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -5351,7 +5418,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 4e3); return () => clearTimeout(timer); -@@ -8011,112 +8618,113 @@ function BotSettingsForm({ bot, roster, actions, run }) { +@@ -8011,112 +8675,113 @@ function BotSettingsForm({ bot, roster, actions, run }) { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -5512,7 +5579,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ) ); -@@ -8176,7 +8784,7 @@ function createPayload({ name, look, image, model, effort, brief }) { +@@ -8176,7 +8841,7 @@ function createPayload({ name, look, image, model, effort, brief }) { } // src/client/create-bot.js @@ -5521,7 +5588,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var pick = (list) => list[Math.floor(Math.random() * list.length)]; var randomLook = (avoid = {}) => { const shapes = DEFAULT_SHAPES.filter((shape) => shape !== avoid.shape); -@@ -8184,17 +8792,17 @@ var randomLook = (avoid = {}) => { +@@ -8184,17 +8849,17 @@ var randomLook = (avoid = {}) => { return { shape: pick(shapes), color: pick(colors) }; }; function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCreated, inline = false }) { @@ -5549,7 +5616,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const fallbackName = freshBotName(roster, t("New Bot")); -@@ -8219,25 +8827,25 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8219,25 +8884,25 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure))); }; const previewLook = image ? { ...look, image } : look; @@ -5583,7 +5650,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 look, hasPhoto: Boolean(image), onShape: (shape) => { -@@ -8249,27 +8857,27 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8249,27 +8914,27 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr setLook((current) => ({ ...current, color })); } }), @@ -5619,7 +5686,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,55 +8892,55 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8284,62 +8949,63 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr } } }), @@ -5702,7 +5769,17 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const close = () => actions.closeOverlay(); useEscape(close); const paneLeft = usePaneLeft(); -@@ -8418,14 +9026,14 @@ function NewChat({ mode, roster, actions }) { + const wanted = query.trim().toLowerCase(); +- const matches = roster.bots.filter((bot) => !picked.includes(bot.id) && bot.id !== admin && (wanted === "" || bot.name.toLowerCase().includes(wanted) || (bot.role ?? "").toLowerCase().includes(wanted))); ++ const bots = listedChatBots(roster); ++ const matches = bots.filter((bot) => !picked.includes(bot.id) && bot.id !== admin && (wanted === "" || bot.name.toLowerCase().includes(wanted) || pickerBotLabel(bot).toLowerCase().includes(wanted) || (bot.role ?? "").toLowerCase().includes(wanted))); + const choosingAdmin = group && admin === null; +- const exact = roster.bots.some((bot) => bot.name.toLowerCase() === wanted); ++ const exact = bots.some((bot) => bot.name.toLowerCase() === wanted); + const pickedBots = picked.map((id) => roster.byId[id]).filter(Boolean); + const run = async (task) => { + if (busy) return; +@@ -8418,35 +9084,44 @@ function NewChat({ mode, roster, actions }) { }); const options = []; if (!group && picked.length === 0 && mode !== "search" && wanted === "") { @@ -5714,15 +5791,26 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 setCursor(0); inputRef.current?.focus(); } }); ++ for (const action of newChatActionRows({ mode, group, picked, query, roster })) { ++ options.push({ ++ key: action.key, ++ icon: (0, import_react21.createElement)(PeopleIcon), ++ label: t(action.label), ++ hint: action.hint ? t(action.hint) : void 0, ++ run: () => action.run({ close, actions, roster }) ++ }); ++ } if ((roster.agents ?? []).length === 0) { - options.push({ key: "agent", icon: (0, import_react20.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => { + options.push({ key: "agent", icon: (0, import_react21.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => { await actions.addAgent(); close(); }) }); -@@ -8434,19 +9042,19 @@ function NewChat({ mode, roster, actions }) { + } + } for (const bot of matches) { - options.push({ key: bot.id, bot, label: bot.name, hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) }); +- options.push({ key: bot.id, bot, label: bot.name, hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) }); ++ options.push({ key: bot.id, bot, label: pickerBotLabel(bot), hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) }); } - if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react20.createElement)(PlusIcon), label: t("Create new Bot “{name}”", { name: query.trim() }), run: createBot }); + if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react21.createElement)(PlusIcon), label: t("Create new Bot “{name}”", { name: query.trim() }), run: createBot }); @@ -5744,7 +5832,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 roster, actions, start: draft, -@@ -8467,33 +9075,33 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8467,33 +9142,33 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -5791,7 +5879,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ref: inputRef, autoFocus: true, value: query, -@@ -8517,20 +9125,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8517,20 +9192,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } else if (event.key === "Backspace" && query === "") unpick(); } }), @@ -5817,7 +5905,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { key: option.key, -@@ -8542,21 +9150,21 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8542,21 +9217,21 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -5848,7 +5936,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 rows: 1, value: message, placeholder, -@@ -8569,7 +9177,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8569,7 +9244,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } } }), @@ -5857,7 +5945,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ) ) -@@ -8577,18 +9185,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8577,18 +9252,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } // src/client/exchange.js @@ -5880,7 +5968,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 let stop = false; let timer; let seen = null; -@@ -8612,48 +9220,48 @@ function ExchangeDialog({ pair, roster, actions }) { +@@ -8612,48 +9287,48 @@ function ExchangeDialog({ pair, roster, actions }) { const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -5945,7 +6033,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var plainSpeech = (text) => stripHeader(text).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); var VOICE_LABELS = { listening: "Listening", -@@ -8665,8 +9273,8 @@ var VOICE_LABELS = { +@@ -8665,8 +9340,8 @@ var VOICE_LABELS = { }; var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" }; function useMicLevel(enabled) { @@ -5956,7 +6044,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!enabled || !navigator.mediaDevices?.getUserMedia) return void 0; let stream = null; let audio = null; -@@ -8716,18 +9324,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8716,18 +9391,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; }); @@ -5986,7 +6074,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; -@@ -8747,12 +9355,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8747,12 +9422,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } dictation.stop(); submit(text); }); @@ -6001,7 +6089,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!speech) return void 0; let live = true; speechCatalog(speech).then(({ catalog: next, usable }) => { -@@ -8767,16 +9375,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8767,16 +9442,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } }; }, [speech]); const listen = phase === "listening"; @@ -6021,7 +6109,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; -@@ -8838,7 +9446,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8838,7 +9513,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } setServiceLevel(0); }; }, [speech, catalog, listen, cycle]); @@ -6030,7 +6118,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (phase !== "thinking" || running || waiting.current === null) return; const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; if (count === waiting.current || !text.trim()) return; -@@ -8858,7 +9466,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8858,7 +9533,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); @@ -6039,7 +6127,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); const level = speech ? serviceLevel : micLevel; const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -@@ -8875,50 +9483,50 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8875,50 +9550,50 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -6107,7 +6195,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var ROOM_MODES = [ ["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."], ["everyone", "Everyone", "Every member answers in turn, admin first."] -@@ -8929,15 +9537,27 @@ function roomSubtitle(room2, roster) { +@@ -8929,15 +9604,27 @@ function roomSubtitle(room2, roster) { return [t("{count} members", { count: room2.members.length }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · "); } function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { @@ -6139,7 +6227,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const admin = roster.byId[room2.admin]; const mode = shownMode(room2, roster); const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; -@@ -8955,41 +9575,41 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +@@ -8955,41 +9642,41 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (done) onDeleted?.(); }); }; @@ -6195,7 +6283,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: id, type: "button", "aria-pressed": mode === id, -@@ -8999,86 +9619,128 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +@@ -8999,86 +9686,128 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (mode !== id) void update({ mode: id }); } }, t(label)))), @@ -6293,10 +6381,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 + (0, import_react24.createElement)(BotMark, { bot, size: 22 }), + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, bot.name)), + (0, import_react24.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_react24.createElement)( + "button", + { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { @@ -6305,7 +6390,10 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 + } }, + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), + (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot")) -+ )), + )), +- (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_react24.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_react24.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react24.createElement)(PlusIcon), t("Add member")) : null, + (0, import_react24.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react24.createElement)( @@ -6359,7 +6447,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); const pickAdmin = (id) => { setAdmin(id); -@@ -9094,48 +9756,48 @@ function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { +@@ -9094,48 +9823,48 @@ function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { if (done && room2) onCreated(room2); else setBusy(false); }; @@ -6423,7 +6511,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ); } -@@ -9294,7 +9956,7 @@ function percent(part, whole) { +@@ -9294,7 +10023,7 @@ function percent(part, whole) { } // src/client/usage-page.js @@ -6432,7 +6520,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var HEAT_WEEKS = 53; var TOP_SERIES = 7; var RING_R = 42; -@@ -9341,9 +10003,9 @@ function titleOf(range, bucket) { +@@ -9341,9 +10070,9 @@ function titleOf(range, bucket) { return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { @@ -6445,7 +6533,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); -@@ -9363,9 +10025,9 @@ function useTween(value) { +@@ -9363,9 +10092,9 @@ function useTween(value) { }, [value]); return shown; } @@ -6457,7 +6545,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 className: "bt-us-model", "aria-hidden": true, style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` } -@@ -9382,7 +10044,7 @@ function ownerFace(owner, roster) { +@@ -9382,7 +10111,7 @@ function ownerFace(owner, roster) { sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], @@ -6466,7 +6554,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 }; } if (owner.kind === "room") { -@@ -9392,7 +10054,7 @@ function ownerFace(owner, roster) { +@@ -9392,7 +10121,7 @@ function ownerFace(owner, roster) { name: owner.name || t("Deleted group chat"), sub: owner.deleted ? t("Deleted") : t("Group chat"), paint: "var(--bt-us-rest)", @@ -6475,7 +6563,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 }; } if (owner.kind === "agent") { -@@ -9414,30 +10076,30 @@ function Tabs({ label, value, options, onChange, boxRef }) { +@@ -9414,30 +10143,30 @@ function Tabs({ label, value, options, onChange, boxRef }) { const names = options.map(([, text]) => text).join("\n"); const glide = useGlide(`${value} ${names}`, { box: boxRef }); @@ -6516,7 +6604,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` -@@ -9455,83 +10117,83 @@ function niceTop(max) { +@@ -9455,83 +10184,83 @@ function niceTop(max) { return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max); } function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) { @@ -6627,7 +6715,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 onClick ? "button" : "div", { className: "bt-us-row", -@@ -9540,69 +10202,69 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick +@@ -9540,69 +10269,69 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }), ...onClick ? { type: "button", onClick } : {} }, @@ -6724,7 +6812,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const onDown = (event) => { if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose(); }; -@@ -9628,17 +10290,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9628,17 +10357,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { const [from, to] = [anchor, key].sort(); onPick(from, to); }; @@ -6745,7 +6833,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key, type: "button", className: "bt-us-day", -@@ -9654,15 +10316,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9654,15 +10383,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) @@ -6767,7 +6855,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); }; const shortcuts = [ -@@ -9671,30 +10333,30 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9671,30 +10400,30 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today] ]; const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1; @@ -6806,7 +6894,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const today = hourMs(`${view.now.slice(0, 10)} 00`); const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS; const from = dayKey(first); -@@ -9710,13 +10372,13 @@ function Heat({ view, keep, paint, index }) { +@@ -9710,13 +10439,13 @@ function Heat({ view, keep, paint, index }) { for (let week = 0; week < HEAT_WEEKS; week += 1) { const monday = first + week * 7 * DAY_MS; if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) { @@ -6822,7 +6910,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: ms, className: "bt-us-cell", style: { "--w": week }, -@@ -9724,7 +10386,7 @@ function Heat({ view, keep, paint, index }) { +@@ -9724,7 +10453,7 @@ function Heat({ view, keep, paint, index }) { "data-future": ms > today ? "" : void 0, "data-today": ms === today ? "" : void 0, onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day }) @@ -6831,7 +6919,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } } const tipSide = hover ? { -@@ -9733,61 +10395,61 @@ function Heat({ view, keep, paint, index }) { +@@ -9733,61 +10462,61 @@ function Heat({ view, keep, paint, index }) { } : null; const active = [...daily.values()].filter((value) => value > 0).length; const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active }); @@ -6919,7 +7007,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 let stopped = false; let timer; const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => { -@@ -9797,7 +10459,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9797,7 +10526,7 @@ function UsagePage({ roster, actions, focusId }) { const value = await actions.usage(); await resized; if (stopped) return; @@ -6928,7 +7016,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { -@@ -9814,7 +10476,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9814,7 +10543,7 @@ function UsagePage({ roster, actions, focusId }) { }, []); const today = view?.now.slice(0, 10); const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId); @@ -6937,7 +7025,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); -@@ -9823,7 +10485,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9823,7 +10552,7 @@ function UsagePage({ roster, actions, focusId }) { const models2 = view.models.map((model, at) => { const name = model.name || model.model || t("Unknown model"); const paint = modelPaint.get(at) ?? "var(--bt-us-rest)"; @@ -6946,7 +7034,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 }); const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1; const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1; -@@ -9871,23 +10533,23 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9871,23 +10600,23 @@ function UsagePage({ roster, actions, focusId }) { }); return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 }; }, [view, rangeId, span, by, focusId, roster]); @@ -6978,7 +7066,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ); } -@@ -9912,7 +10574,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9912,7 +10641,7 @@ function UsagePage({ roster, actions, focusId }) { ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon] ]; const whole = part.total.total; @@ -6987,7 +7075,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), -@@ -9924,7 +10586,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9924,7 +10653,7 @@ function UsagePage({ roster, actions, focusId }) { fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint), onClick: () => pick2({ kind: "owner", key: view.owners[at].key }) }); @@ -6996,7 +7084,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: `m${at}`, kind: "model", glyph: models[at].glyph(32), -@@ -9936,30 +10598,30 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9936,30 +10665,30 @@ function UsagePage({ roster, actions, focusId }) { fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint), onClick: () => pick2({ kind: "model", key: view.models[at].key }) }); @@ -7037,7 +7125,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ]; } else { -@@ -9968,39 +10630,39 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9968,39 +10697,39 @@ function UsagePage({ roster, actions, focusId }) { const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ @@ -7091,7 +7179,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 body ); } -@@ -10157,12 +10819,12 @@ button.bt-us-row:hover{background:var(--bt-hover)} +@@ -10157,12 +10886,12 @@ button.bt-us-row:hover{background:var(--bt-hover)} `; // src/client/connectors.js @@ -7107,7 +7195,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); var MARKS = { github: GitHubMark }; var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; -@@ -10173,10 +10835,10 @@ function statusText2(connector) { +@@ -10173,10 +10902,10 @@ function statusText2(connector) { return ABOUT[connector.id]?.() ?? ""; } function ConnectorRow({ connector, actions }) { @@ -7122,7 +7210,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const run = (task, after) => { setBusy(true); setError(null); -@@ -10193,63 +10855,63 @@ function ConnectorRow({ connector, actions }) { +@@ -10193,63 +10922,63 @@ function ConnectorRow({ connector, actions }) { }; const on = connector.state !== "off"; const Mark = MARKS[connector.id]; @@ -7209,7 +7297,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); function download(name, text) { const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); -@@ -10272,16 +10934,16 @@ function entrySummary(entry) { +@@ -10272,16 +11001,16 @@ function entrySummary(entry) { } var SOURCES = { turn: "Model call", host: "DS Bot", api: "DS Bot", browser: "This window" }; function FeedbackPage({ actions, run, Section, Row: Row3 }) { @@ -7231,7 +7319,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { -@@ -10289,7 +10951,7 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10289,7 +11018,7 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it.")); if (!copied) setPreview(true); })); @@ -7240,7 +7328,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; -@@ -10317,59 +10979,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10317,59 +11046,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ @@ -7324,7 +7412,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ) ]; -@@ -10377,8 +11039,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10377,8 +11106,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); @@ -7335,7 +7423,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); -@@ -10389,15 +11051,16 @@ function ErrorToast({ useUi, actions }) { +@@ -10389,15 +11118,16 @@ function ErrorToast({ useUi, actions }) { }; }, [toast?.token]); if (!toast) return null; @@ -7357,7 +7445,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } var FEEDBACK_CSS = ` -@@ -10428,7 +11091,7 @@ var FEEDBACK_CSS = ` +@@ -10428,7 +11158,7 @@ var FEEDBACK_CSS = ` `; // src/client/about.js @@ -7366,7 +7454,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var CHEER_MS = 1800; var starCount = (count) => new Intl.NumberFormat(dateLocale(), { notation: "compact", maximumFractionDigits: 1 }).format(count); function botHint(update) { -@@ -10454,9 +11117,9 @@ function dshHint(dsh, shown) { +@@ -10454,9 +11184,9 @@ function dshHint(dsh, shown) { var linkProps = (href) => ({ className: "bt-soft bt-soft-sm bt-about-link", href, target: "_blank", rel: "noopener noreferrer" }); function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { const update = useUpdate((value) => value) ?? {}; @@ -7379,7 +7467,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 let live = true; actions.repoStars().then((count) => { if (live && Number.isInteger(count)) setStars(count); -@@ -10466,8 +11129,8 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { +@@ -10466,8 +11196,8 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { live = false; }; }, []); @@ -7390,7 +7478,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!cheer) return void 0; const timer = setTimeout(() => setCheer(false), CHEER_MS); return () => clearTimeout(timer); -@@ -10482,35 +11145,35 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { +@@ -10482,35 +11212,35 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { const phase = update.phase ?? "idle"; const dsh = update.dsh; const dshPhase = dsh?.phase ?? "idle"; @@ -7436,7 +7524,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "a", { className: "bt-star", -@@ -10520,23 +11183,23 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { +@@ -10520,23 +11250,23 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { title: t("Star DS Bot on GitHub"), "aria-label": stars === null ? t("Star DS Bot on GitHub") : `${t("Star DS Bot on GitHub")} · ${t("{count} stars", { count: stars })}` }, @@ -7470,7 +7558,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ]; } -@@ -10569,54 +11232,54 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s +@@ -10569,54 +11299,54 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s `; // src/client/settings.js @@ -7541,7 +7629,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: ink, type: "button", className: "bt-swatch", -@@ -10626,21 +11289,21 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,21 +11356,21 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -7568,7 +7656,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ) ]; -@@ -10649,52 +11312,52 @@ function MainBotsSection({ roster, actions, run }) { +@@ -10649,52 +11379,52 @@ function MainBotsSection({ roster, actions, run }) { const mains = (roster.mainBotIds ?? []).map((id) => roster.byId[id]).filter(Boolean); const [first, ...others] = mains; const candidates = roster.bots.filter((bot) => !isMainOf(roster, bot.id)); @@ -7634,7 +7722,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: id, type: "button", "aria-pressed": motion === id, -@@ -10706,69 +11369,69 @@ function MotionSection({ roster, actions, run }) { +@@ -10706,69 +11436,69 @@ function MotionSection({ roster, actions, run }) { ); } function BotsPage({ roster, actions, run }) { @@ -7733,7 +7821,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10786,32 +11449,32 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10786,32 +11516,32 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false const nav = useGlide(shown, { axis: "y" }); const pageKey = `${shown}:${bot?.id ?? room2?.id ?? ""}`; const depth = bot || room2 ? 1 : 0; @@ -7777,7 +7865,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 type: "button", className: "bt-settings-item bt-settings-shell", title: t("Models, providers, and the rest of DeepSeek Harness"), -@@ -10819,64 +11482,65 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10819,64 +11549,65 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false close(); openHarnessSettings(); } @@ -7869,7 +7957,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; -@@ -10901,20 +11565,21 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10901,20 +11632,21 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null, bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null ], @@ -7896,7 +7984,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: label, type: "button", role: "menuitem", -@@ -10923,14 +11588,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10923,14 +11655,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -7915,7 +8003,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const box = ref.current?.getBoundingClientRect(); if (!box) return; setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) }); -@@ -10952,14 +11617,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10952,14 +11684,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; @@ -7935,7 +8023,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: label, type: "button", role: "menuitem", -@@ -10968,7 +11633,7 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10968,7 +11700,7 @@ function AgentMenu({ menu, roster, actions, leaving }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } @@ -7944,7 +8032,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 )) ); } -@@ -11039,7 +11704,7 @@ function migrateStorage(storage = globalThis.localStorage) { +@@ -11039,7 +11771,7 @@ function migrateStorage(storage = globalThis.localStorage) { } // src/client/member-status.js @@ -7953,7 +8041,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; var ROWS_MAX = 4; function memberWork(room2, work = NO_WORK) { -@@ -11077,35 +11742,35 @@ function summaryText(busy) { +@@ -11077,35 +11809,35 @@ function summaryText(busy) { return { waiting, doing }; } function Face({ entry, size }) { @@ -8001,7 +8089,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ); } -@@ -11119,20 +11784,20 @@ function MemberDock({ sessionId, useRoster, useWork, actions }) { +@@ -11119,20 +11851,20 @@ function MemberDock({ sessionId, useRoster, useWork, actions }) { const groups = bot !== void 0 && !ownChat ? botGroupWork(bot.id, work, roster) : []; const shown = busy.length > 0 || groups.length > 0; const now = useNow2(shown); @@ -8027,7 +8115,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: item.room.id, bot, tool: item.tool, -@@ -11143,7 +11808,7 @@ function MemberDock({ sessionId, useRoster, useWork, actions }) { +@@ -11143,7 +11875,7 @@ function MemberDock({ sessionId, useRoster, useWork, actions }) { }))); } function useFlowColumn(ref, active, key) { @@ -8036,7 +8124,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const node = ref.current; const flow = document.querySelector("[data-chat-flow]"); if (!active || !node || !flow) return void 0; -@@ -11160,8 +11825,8 @@ function useFlowColumn(ref, active, key) { +@@ -11160,8 +11892,8 @@ function useFlowColumn(ref, active, key) { }, [active, key]); } function useNow2(active) { @@ -8047,7 +8135,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!active) return void 0; const timer = setInterval(() => setNow(Date.now()), 3e4); return () => clearInterval(timer); -@@ -11170,38 +11835,38 @@ function useNow2(active) { +@@ -11170,38 +11902,38 @@ function useNow2(active) { } var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; function ApprovalCard({ approval, bot, actions }) { @@ -8101,7 +8189,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } if (entry.state !== "work") return null; -@@ -11210,14 +11875,14 @@ function MemberWork({ entry, bot, room: room2, roster, actions, now }) { +@@ -11210,14 +11942,14 @@ function MemberWork({ entry, bot, room: room2, roster, actions, now }) { setStopping(true); void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); }; @@ -8120,7 +8208,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) ); } -@@ -11264,7 +11929,7 @@ body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} +@@ -11264,7 +11996,7 @@ body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} `; // src/client/details-drawer.js @@ -8129,7 +8217,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 function transcriptOf(list, id, name, hiddenTurns = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; if (!Array.isArray(outline)) return ""; -@@ -11289,10 +11954,10 @@ var EMPTY_TABS = { ids: [], active: null }; +@@ -11289,10 +12021,10 @@ var EMPTY_TABS = { ids: [], active: null }; var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" }; var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]); function PermissionRow({ bot, roster, actions, run }) { @@ -8142,7 +8230,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; -@@ -11302,14 +11967,14 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11302,14 +12034,14 @@ function PermissionRow({ bot, roster, actions, run }) { setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; @@ -8161,7 +8249,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 key: option.value, type: "button", "aria-pressed": option.value === current, -@@ -11323,42 +11988,42 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11323,42 +12055,42 @@ function PermissionRow({ bot, roster, actions, run }) { function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) { const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); @@ -8219,7 +8307,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,71 +12032,181 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,71 +12099,181 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -8436,7 +8524,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) : null ]; } -@@ -11439,15 +12214,15 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11439,15 +12281,15 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a const owner = roster.byId[sessionId]; const part = owner ? currentPart(owner) : sessionId; const isMainBot = isMainOf(roster, owner?.id); @@ -8459,7 +8547,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" }; else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" }; const move = last.current.move; -@@ -11472,36 +12247,36 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11472,36 +12314,36 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setTimeout(() => setCopied(false), 1600); }); }; @@ -8510,7 +8598,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ]; } const current = bot?.name ?? room2.name; -@@ -11515,12 +12290,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11515,12 +12357,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ @@ -8527,7 +8615,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,55 +12310,55 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11535,55 +12377,55 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setEditing(false); } } @@ -8606,7 +8694,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var import_react_dom8 = require("react-dom"); var PANEL_LINGER_MS = 300; var WIDTH_KEY = STORAGE.browserWidth; -@@ -11598,10 +12373,10 @@ var EMPTY_TABS2 = { ids: [], active: null }; +@@ -11598,10 +12440,10 @@ var EMPTY_TABS2 = { ids: [], active: null }; var SPRING_LEAD2 = springEasing(576, 39.4); var SPRING_TRAIL2 = springEasing(144, 19.7); var icon = (paths) => function BrowserIcon() { @@ -8619,7 +8707,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); }; var GlobeIcon = icon(["M10 3a7 7 0 1 0 0 14 7 7 0 0 0 0-14z", "M3 10h14", "M10 3c2 2.2 2.9 4.5 2.9 7S12 14.8 10 17c-2-2.2-2.9-4.5-2.9-7S8 5.2 10 3z"]); -@@ -11613,9 +12388,9 @@ var CloseIcon2 = icon(["M5.5 5.5l9 9M14.5 5.5l-9 9"]); +@@ -11613,9 +12455,9 @@ var CloseIcon2 = icon(["M5.5 5.5l9 9M14.5 5.5l-9 9"]); var PlusTabIcon = icon(["M10 5v10M5 10h10"]); var LockIcon2 = icon(["M6.5 9V7a3.5 3.5 0 0 1 7 0v2", "M5.5 9h9v7h-9z"]); function Favicon({ src, className }) { @@ -8632,7 +8720,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } var savedWidth = () => { try { -@@ -11674,11 +12449,11 @@ var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(pref +@@ -11674,11 +12516,11 @@ var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(pref var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab"); function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) { const pages = useBrowserPages((map) => map); @@ -8649,7 +8737,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const box = wrap.current; const capsule = ind.current; if (!box || !capsule) return void 0; -@@ -11707,7 +12482,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11707,7 +12549,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, trail.cancel(); }; }); @@ -8658,7 +8746,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { -@@ -11721,13 +12496,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11721,13 +12563,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, observer.observe(box); return () => observer.disconnect(); }, []); @@ -8675,7 +8763,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { type: "button", -@@ -11737,13 +12512,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11737,13 +12579,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, title: bot.name, onClick: () => onView("details") }, @@ -8692,7 +8780,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "button", { key: tabId, -@@ -11764,9 +12539,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11764,9 +12606,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, } } }, @@ -8705,7 +8793,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), -@@ -11778,20 +12553,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11778,20 +12620,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, }, "×") ); }), @@ -8733,7 +8821,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); -@@ -11817,7 +12592,7 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11817,7 +12659,7 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); @@ -8742,7 +8830,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 setDraft(null); if (landed && !page.url) input.current?.focus(); }, [tabId, landed]); -@@ -11833,27 +12608,27 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11833,27 +12675,27 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) }; const editing = draft !== null; const secure = /^https:/i.test(page.url); @@ -8779,7 +8867,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ref: input, type: "text", spellCheck: false, -@@ -11877,25 +12652,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11877,25 +12719,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) } }) ), @@ -8815,7 +8903,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ) : null ) ); -@@ -11907,41 +12682,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11907,41 +12749,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2); const active = tabState.active; const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE); @@ -8873,7 +8961,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 let timer; const onResize = () => { setResizing(true); -@@ -11955,28 +12730,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11955,28 +12797,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu window.removeEventListener("resize", onResize); }; }, []); @@ -8909,7 +8997,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0; setReading(true); const timer = setTimeout(() => setReading(false), READ_FLASH_MS); -@@ -12013,7 +12788,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12013,7 +12855,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu return actions[name](...args); }; } @@ -8918,7 +9006,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 const move = fly.current; if (!move) return; fly.current = null; -@@ -12096,7 +12871,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12096,7 +12938,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu for (const name of ends) grip.addEventListener(name, onEnd); }; const hasStrip = canBrowse && tabState.ids.length > 0; @@ -8927,7 +9015,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 "aside", { ref: root, -@@ -12113,15 +12888,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12113,15 +12955,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu if (event.target === event.currentTarget) setLanded(true); } }, @@ -8948,7 +9036,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, -@@ -12218,7 +12993,7 @@ var BROWSER_CSS = ` +@@ -12218,7 +13060,7 @@ var BROWSER_CSS = ` `; // src/client/overlays.js @@ -8957,7 +9045,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var import_react_dom9 = require("react-dom"); var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; -@@ -12227,15 +13002,15 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12227,15 +13069,15 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity const ui2 = useUi((value) => value); useRegistry((value) => value); const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null); @@ -8977,7 +9065,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (open) { setKept(open); return void 0; -@@ -12247,17 +13022,17 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12247,17 +13089,17 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS); const [memory, memoryLeaving] = useLinger(ui2.memory && roster.byId[ui2.memory] ? ui2.memory : null, DIALOG_EXIT_MS); const [menu, menuLeaving] = useLinger(ui2.menu, MENU_EXIT_MS3); @@ -9005,7 +9093,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ); } -@@ -13105,7 +13880,7 @@ function writeMode(mode, storage = globalThis.localStorage) { +@@ -13105,7 +13947,7 @@ function writeMode(mode, storage = globalThis.localStorage) { } // src/client/user-message.js @@ -9014,7 +9102,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { -@@ -13121,8 +13896,25 @@ function contentParts(content) { +@@ -13121,8 +13963,25 @@ function contentParts(content) { } function mentionableBots(roster, sessionId) { const room2 = roster.roomsById[sessionId]; @@ -9042,7 +9130,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } function userTextParts(text, bots, referenceLabels = []) { const parts = []; -@@ -13137,38 +13929,38 @@ function userTextParts(text, bots, referenceLabels = []) { +@@ -13137,38 +13996,38 @@ function userTextParts(text, bots, referenceLabels = []) { return parts; } function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { @@ -9092,7 +9180,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 } var clock3 = (time) => typeof time === "number" ? new Date(time).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }) : void 0; function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageImages, openFile, openSkill }) { -@@ -13178,26 +13970,41 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma +@@ -13178,26 +14037,41 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma const referenceLabels = data.referenceLabels ?? []; const references = openFile && openSkill ? { openFile, openSkill } : void 0; const steering = node.kind === "steering"; @@ -9143,7 +9231,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 .bt-ustack{min-width:0;max-width:min(calc(var(--dsh-chat-content-width,748px) * .702),82%);display:flex;flex-direction:column;align-items:flex-end;gap:8px} .bt-ubody{max-width:100%;box-sizing:border-box;background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);white-space:pre-wrap;word-break:break-word} .bt-ubody a{color:inherit} -@@ -13250,6 +14057,14 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since +@@ -13250,6 +14124,14 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since // src/client/index.js var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { @@ -9158,7 +9246,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ctx.effect(() => installLocale(ctx.get("locale")), "ds-bot: interface copy"); const roster = createSource(EMPTY_ROSTER); migrateStorage(); -@@ -13501,7 +14316,7 @@ function apply(ctx) { +@@ -13501,7 +14383,7 @@ function apply(ctx) { }; render(); const offRoster = roster.subscribe(render); @@ -9167,7 +9255,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 return () => { offRoster(); offRegistry(); -@@ -13520,7 +14335,7 @@ function apply(ctx) { +@@ -13520,7 +14402,7 @@ function apply(ctx) { const accent = isHex(prefs.accent) ? prefs.accent : void 0; const motion = prefs.motion === "quiet" || prefs.motion === "lively" ? prefs.motion : "normal"; document.body.dataset.btMotion = motion; @@ -9176,7 +9264,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 if (next === key) return; key = next; style.textContent = themeCss(themes[id], accent); -@@ -13536,7 +14351,7 @@ function apply(ctx) { +@@ -13536,7 +14418,7 @@ function apply(ctx) { }; render(); const offRoster = roster.subscribe(render); @@ -9185,7 +9273,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 return () => { offRoster(); offRegistry(); -@@ -13846,6 +14661,8 @@ function apply(ctx) { +@@ -13846,6 +14728,8 @@ function apply(ctx) { noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, @@ -9194,7 +9282,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) }; if (browsers) { -@@ -13890,6 +14707,7 @@ function apply(ctx) { +@@ -13890,6 +14774,7 @@ function apply(ctx) { actions.closePanel = () => { ui2.set((state) => state.details ? { ...state, details: null, view: "details", renaming: false, tab: null } : state); }; @@ -9202,7 +9290,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 actions.signInEntry = () => ctx.slots.entries?.("settings.models.sign-in")?.[0] ?? null; actions.canSignIn = () => Boolean(actions.signInEntry()); actions.signIn = () => ui2.set((state) => ({ ...state, signIn: (state.signIn ?? 0) + 1 })); -@@ -13964,7 +14782,7 @@ function apply(ctx) { +@@ -13964,7 +14849,7 @@ function apply(ctx) { }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); @@ -9211,7 +9299,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ctx.effect(() => noticeApprovals({ work, roster, -@@ -14024,6 +14842,7 @@ function apply(ctx) { +@@ -14024,6 +14909,7 @@ function apply(ctx) { installCompiledStyles(SCHEDULE_CSS, "schedules"), installCompiledStyles(FEEDBACK_CSS, "feedback"), installCompiledStyles(ABOUT_CSS, "about"), @@ -9219,7 +9307,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 installCompiledStyles(MEMBER_CSS, "members"), installCompiledStyles(USER_MESSAGE_CSS, "user-messages"), browsers ? installCompiledStyles(BROWSER_CSS, "browser") : null, -@@ -14039,7 +14858,8 @@ function apply(ctx) { +@@ -14039,7 +14925,8 @@ function apply(ctx) { shadow("settings.launcher", { priority: -10, inject: face }, AccountLauncher), shadow("shell.overlay", { id: "bot", order: 50, inject: face }, Overlays), shadow("shell.overlay", { id: "bot-signin", order: 55, inject: face }, DeepSeekSignIn), @@ -9229,7 +9317,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 ]; const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, "conversation"), -@@ -14066,6 +14886,7 @@ function apply(ctx) { +@@ -14066,6 +14953,7 @@ function apply(ctx) { shadow("conversation.composer.dock", { id: "activity", order: 0, priority: -10 }, Nothing), shadow("conversation.composer.dock", { id: "usage", order: 1, priority: -10 }, Nothing), shadow("conversation.input.left", { id: "bot-plus", order: -100, inject: face }, ComposerPlus), @@ -9237,7 +9325,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..a2018fab83e49b7d233a52c431edf9f5 shadow("conversation.input.right", { id: "bot-voice", order: 100, inject: face }, ComposerVoice), shadow("conversation.composer", { priority: -10, -@@ -14074,7 +14895,8 @@ function apply(ctx) { +@@ -14074,7 +14962,8 @@ function apply(ctx) { shadow("conversation.input.dock", { id: "bot-members", order: -120, inject: face }, MemberDock), shadow("conversation.input.dock", { id: "bot-model", order: -110, inject: face }, ModelChoiceDock), shadow("conversation.input.dock", { id: "bot-question", order: -100, inject: face }, QuestionDock), diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 0ea0f45d2..258f1d1d9 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -1435,10 +1435,10 @@ index 0000000000000000000000000000000000000000..3710ec17730a6dccee8631ebd0129dbe +} diff --git a/src/shared/remote-label.js b/src/shared/remote-label.js new file mode 100644 -index 0000000000000000000000000000000000000000..d4cee82bc0f71929799cc5c0c3dee30768e9060c +index 0000000000000000000000000000000000000000..25ace2eb087a408fe1c5ef821abbcc2740d29e7e --- /dev/null +++ b/src/shared/remote-label.js -@@ -0,0 +1,52 @@ +@@ -0,0 +1,92 @@ +// Display labels for a friend's published Bot and for a room the owner did not start. +// Peer-controlled strings (names, homes, handles) are untrusted: strip controls and +// markup so a name cannot look like instructions when it later reaches a model. @@ -1448,6 +1448,10 @@ index 0000000000000000000000000000000000000000..d4cee82bc0f71929799cc5c0c3dee307 + return text || fallback +} + ++export function normRemoteHandle(handle) { ++ return `@${String(handle ?? '').trim().replace(/^@+/, '').toLowerCase()}` ++} ++ +function ownerFromHandle(handle) { + const parts = String(handle ?? '').replace(/^@/, '').split('.') + if (parts.length >= 3 && parts.at(-1) === 'home') return parts.at(-2) @@ -1455,6 +1459,42 @@ index 0000000000000000000000000000000000000000..d4cee82bc0f71929799cc5c0c3dee307 + return '' +} + ++export function isHomeLevelRemote(record) { ++ const handle = String(record?.handle ?? record?.remote?.handle ?? '') ++ const key = normRemoteHandle(handle) ++ const parts = key.replace(/^@/, '').split('.') ++ return parts.length === 2 && parts[1] === 'home' ++} ++ ++export function isListedRemoteChat(bot) { ++ if (!bot) return false ++ if (bot.hidden) return false ++ const remote = bot.remote ++ if (!remote || typeof remote.handle !== 'string') return true ++ if (remote.gone || remote.hidden) return false ++ if (isHomeLevelRemote(remote)) return false ++ return true ++} ++ ++export function listedChatBots(roster) { ++ return (roster?.bots ?? []).filter(isListedRemoteChat) ++} ++ ++export function pickerBotLabel(bot) { ++ if (!bot) return '' ++ if (!bot.remote) return bot.name ?? '' ++ return remoteBotTitle({ ++ displayName: bot.name, ++ name: bot.name, ++ ownerName: bot.remote.owner || bot.remote.homeName, ++ owner: bot.remote.owner, ++ homeName: bot.remote.homeName, ++ home: bot.remote.home, ++ handle: bot.remote.handle, ++ isChief: bot.remote.isChief, ++ }) ++} ++ +export function ownerNameOf(record) { + if (!record) return '' + return cleanPeerName(record.ownerName || record.homeName || record.owner || record.ownerHome || record.home || ownerFromHandle(record.handle), '') diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 9f4cfb13b..add332975 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -39,7 +39,10 @@ "src/client/user-message.js", "src/client/styles.js", "src/client/icons.js", + "src/client/new-chat.js", + "src/client/palette.js", "test/flybot-client.test.js", + "test/new-chat.test.js", "test/mention-rule.test.js", "test/bot-tree.test.js" ], diff --git a/plugins/social/README.md b/plugins/social/README.md index 959619861..85323a520 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -57,8 +57,8 @@ Tests (`test/social.test.js`) use a mocked server. People in the same place (or on a call) put their Bots in one room by each typing the same 6-digit number. There is no host and no friend request. -1. Open **Friends' Bots** in the sidebar and click **+ Add**. -2. Click **Face-to-face group**. Choose which of your Bots to bring (multi-select; default: the Main Bot). Type the same six digits as the people next to you. Click **Join**. +1. Click **+** at the top of the sidebar (next to Search) and choose **Face-to-face group**, or open **Friends' Bots** and **+ Add** → **Face-to-face group**. +2. Choose which of your Bots to bring (multi-select; default: the Main Bot). Type the same six digits as the people next to you. Click **Join**. 3. You join at once — there is no preview. Several selected Bots join the same room (one join per Bot). If one of them fails, the others still join and the failure is shown. A waiting room lists who is in (each Bot and its owner; yours marked **mine**) and how much time is left. If a Bot has no Bot Social identity yet, FlyBot registers it for this room only, then joins — friends still cannot reach it unless you invite them to it. **Invite to this Bot** / **Manage access** (Bot settings) are how you grant and revoke. 4. **Enter chat** starts the group and opens it (voids the number). If someone else starts first, this screen opens the chat for you. The matching window is 10 minutes; when it ends or the room starts, the number is invalid. 5. **Leave** removes your Bots in one click. Anyone can leave; if the last member leaves, the room closes and the number is free again. @@ -71,7 +71,7 @@ A failed number never shows who is in a room. Too many attempts ask you to wait Invites are **per Bot**; there is no whole-home invite. A friend who joins an invite made for one of your Bots can talk to that Bot only; your other Bots stay hidden unless you invite them too (server: friendships scoped to the invite's target, Bot Social `requester_scope`/`target_scope`; the roster lists only what `list_remote_bots` returns). -The **Friends' Bots** section (collapsible header with a Bot count, ds-bot `--bt-*` tokens) lists friends' Bots by home, with an empty state. **+ Add** opens **Face-to-face group** (separate screen: pick Bots, 6-digit number, waiting room, Enter chat) and: +The **Friends' Bots** section (collapsible header with a Bot count, ds-bot `--bt-*` tokens) lists friends' Bots by home, with an empty state. It paints a cached roster immediately and shows **Refreshing…** while it talks to Bot Social. **Face-to-face group** is on the sidebar **+** menu (next to Create group chat) and under **+ Add** (including while the roster is still loading): pick Bots, 6-digit number, waiting room, Enter chat. **+ Add** also: - **Invite a friend to one of your Bots:** pick the Bot (default: the Main Bot, Chief) and **Create invite link**. It works once and lasts 7 days. The field shows the URL; **Copy link** copies only the bare URL; **Copy invite with instructions** copies a short message naming the Bot ("Add my bot @chief. (Chief) from my FlyBot home . In FlyBot: open Friends' Bots, click + Add, paste this link and click Join. Using a Grok bot? Send it this link and say 'join this FlyBot invite'. Link: (single use, expires )"). The Bot's right-click menu has the same two items. Each clipboard write happens inside the click. - **Paste a friend's invite link or code** → **Join:** sends a friend request from one of your Bots (**Join as**, default: the Main Bot). The friendship is between the invited Bot and that Bot only: your friend sees and reaches just it, and only it can message their Bot (the roster's `via`; other local Bots are refused before anything leaves). If the server also needs your own approval, the click counts as that approval. diff --git a/plugins/social/client.js b/plugins/social/client.js index 199728d27..90bfe5908 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -81,6 +81,7 @@ window.__ModuleLoader__.load({ .fs-status{padding:0;font-size:12px;line-height:17px;color:var(--bt-ink-2,rgba(20,20,20,.6));overflow-wrap:anywhere} .fs-status[data-tone=error]{color:#e5484d} .fs-pending-tag{background:var(--bt-accent,#4d6bfe);color:#fff} +.fs-refresh{flex:none;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36));font-weight:400} .fs-pending{margin:4px 4px 6px;padding:8px 10px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:10px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:6px} .fs-pending .fs-label{padding:0} .fs-draft{font-size:13px;line-height:18px;color:var(--bt-ink,#141414);white-space:pre-wrap;overflow-wrap:anywhere;max-height:96px;overflow:auto} @@ -260,7 +261,7 @@ window.__ModuleLoader__.load({ if (step !== 'waiting' || !(room?.faceRoomId || room?.roomId)) return undefined const faceId = room.faceRoomId const localId = room.roomId - const load = () => call('social/face-rooms', {}).then(value => { + const load = () => call('social/face-rooms', { poll: true }).then(value => { const list = Array.isArray(value?.rooms) ? value.rooms : [] const next = list.find(item => (faceId && item.faceRoomId === faceId) || (localId && item.roomId === localId)) if (next) setRoom(current => current ? { ...current, ...next } : next) @@ -585,22 +586,28 @@ window.__ModuleLoader__.load({ return seconds === 1 ? '1 second left to join' : `${seconds} seconds left to join` } - function FaceRooms({ actions } = {}) { + function FaceRooms({ actions, enabled = true } = {}) { const [rooms, setRooms] = useState([]) const [autoSend, setAutoSend] = useState(false) const [autoBusy, setAutoBusy] = useState(false) const [autoError, setAutoError] = useState('') - const load = () => call('social/face-rooms', {}).then(value => { + const candidate = rooms.some(room => room.status === 'candidate') + const load = (poll) => call('social/face-rooms', { poll }).then(value => { setRooms(Array.isArray(value?.rooms) ? value.rooms : []) if (typeof value?.autoApproveFaceRooms === 'boolean') setAutoSend(value.autoApproveFaceRooms) }, () => {}) useEffect(() => { - load() + if (!enabled) return undefined + load(false) if (typeof setInterval !== 'function') return undefined - const timer = setInterval(load, 5000) + const tick = () => { + if (typeof document !== 'undefined' && document.hidden) return + load(candidate) + } + const timer = setInterval(tick, candidate ? 5000 : 30_000) timer?.unref?.() return () => clearInterval(timer) - }, []) + }, [enabled, candidate]) const toggle = async (next) => { setAutoBusy(true) setAutoError('') @@ -650,9 +657,34 @@ window.__ModuleLoader__.load({ note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) } + const ROSTER_CACHE_KEY = 'flybot-social-remote-bots' + function readCachedRoster() { + try { + const raw = globalThis.sessionStorage?.getItem?.(ROSTER_CACHE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + return parsed && Array.isArray(parsed.homes) ? parsed : null + } catch { return null } + } + function writeCachedRoster(value) { + try { globalThis.sessionStorage?.setItem?.(ROSTER_CACHE_KEY, JSON.stringify({ homes: value?.homes ?? [] })) } catch {} + } + + let faceOpenHandler = null + let pendingFaceOpen = false + function openFaceToFace() { + if (faceOpenHandler) faceOpenHandler() + else pendingFaceOpen = true + } + function bindFaceOpen(fn) { + faceOpenHandler = fn + if (pendingFaceOpen) { pendingFaceOpen = false; fn() } + return () => { if (faceOpenHandler === fn) faceOpenHandler = null } + } + function FriendsBots({ renderBot, remoteChats, actions } = {}) { useStyles() - const [data, setData] = useState(null) + const [data, setData] = useState(() => readCachedRoster()) const [error, setError] = useState('') const [open, setOpen] = useState(true) const [adding, setAdding] = useState(false) @@ -662,21 +694,37 @@ window.__ModuleLoader__.load({ const [pending, setPending] = useState([]) const [hiding, setHiding] = useState(() => new Set()) const [faceOpen, setFaceOpen] = useState(false) - const load = () => call('social/remote-bots', { fresh: true }).then(value => { setData(value); setError('') }, e => setError(errorText(e))) + const [refreshing, setRefreshing] = useState(false) + const openRef = useRef(open) + openRef.current = open + const load = () => call('social/remote-bots', { fresh: true }).then(value => { + setData(value) + writeCachedRoster(value) + setError('') + }, e => setError(errorText(e))) const loadSettings = () => call('social/settings', {}).then(value => setAuto(value?.autoAccept ?? {}), () => {}) const loadApprovals = () => call('social/status', {}).then(value => { const items = Array.isArray(value?.approvals) ? value.approvals : [] setPending(items) if (items.length) setOpen(true) }, () => {}) + const loadAll = () => { + setRefreshing(true) + return Promise.all([load(), loadSettings(), loadApprovals()]).finally(() => setRefreshing(false)) + } useEffect(() => { - load().then(loadSettings) - loadApprovals() + loadAll() if (typeof setInterval !== 'function') return undefined - const timer = setInterval(loadApprovals, 5000) + const tick = () => { + if (!openRef.current) return + if (typeof document !== 'undefined' && document.hidden) return + loadApprovals() + } + const timer = setInterval(tick, 15_000) timer?.unref?.() return () => clearInterval(timer) }, []) + useEffect(() => bindFaceOpen(() => { setFaceOpen(true); setOpen(true) }), []) const homes = data?.homes ?? [] const count = homes.reduce((n, entry) => n + entry.bots.length, 0) const chats = remoteChats ?? [] @@ -728,17 +776,17 @@ window.__ModuleLoader__.load({ h('span', { className: 'fs-title' }, 'Friends\' Bots'), data ? h('span', { className: 'fs-count', title: `${count} ${count === 1 ? 'Bot' : 'Bots'}` }, String(count)) : null), pending.length ? h('button', { type: 'button', className: 'fs-tag fs-pending-tag', 'data-flybot': 'pending-badge', title: `${pending.length} outbound ${pending.length === 1 ? 'reply' : 'replies'} waiting for your approval. Opens that chat.`, onClick: openPending }, `${pending.length} to approve`) : null, + refreshing ? h('span', { className: 'fs-refresh', 'data-flybot': 'roster-refreshing' }, 'Refreshing…') : null, unavailable ? null : h('button', { type: 'button', className: 'fs-icon', 'aria-expanded': adding, title: 'Add a friend', onClick: () => { setAdding(!adding); setOpen(true) } }, adding ? 'Done' : '+ Add')), open ? h('div', { className: 'fs-body' }, h(PendingApprovals, { items: pending, busy: saving, onDecide: decide }), - unavailable ? null : h(FaceRooms, { actions }), + unavailable ? null : h(FaceRooms, { actions, enabled: open }), adding && !unavailable ? h('div', { className: 'fs-card', 'data-flybot': 'face-group-entry' }, h('button', { type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-group-open', onClick: () => setFaceOpen(true) }, 'Face-to-face group'), h('div', { className: 'fs-hint' }, 'Meet the people next to you. Pick your Bots and type the same 6-digit number.')) : null, adding && !unavailable ? h(AddFriend, { onChange: () => load().then(loadSettings) }) : null, faceOpen && !unavailable ? h(FaceToFaceGroup, { actions, onClose: () => setFaceOpen(false) }) : null, unavailable ? h('div', { className: 'fs-empty', 'data-flybot': 'social-unavailable' }, error) : error ? h('div', { className: 'fs-status', 'data-tone': 'error', style: { padding: '4px 10px' } }, error) : null, - data === null && !error ? h('div', { className: 'fs-hint' }, 'Loading…') : null, homes.map(entry => { const shut = closed[entry.home] === true const rows = entry.bots.map(bot => ({ bot, chat: chats.find(chat => chat.remote?.handle === bot.handle) })) @@ -978,6 +1026,13 @@ window.__ModuleLoader__.load({ } offs = [ slots().register('flybot.sidebar.section', { id: 'social', order: 100, component: FriendsBots }), + slots().register('flybot.new-chat.action', { + id: 'social-face-group', + order: 15, + label: 'Face-to-face group', + hint: 'Meet the people next to you. Pick your Bots and type the same 6-digit number.', + run: ({ close } = {}) => { openFaceToFace(); close?.() }, + }), slots().register('flybot.composer.mention', { id: 'social', order: 100, candidates: friendRows }), slots().register('flybot.bot.details', { id: 'social-invite', order: 100, component: BotSocialDetails }), slots().register('flybot.room.overview', { id: 'social-face', order: 50, component: FaceRoomOverview }), @@ -985,7 +1040,7 @@ window.__ModuleLoader__.load({ ] // For other plugins (the tree's per-node "Copy invite link"): ctx.flybot.social.canInvite(botId) // is synchronous; flybot.call('social.createInvite', { botId }) returns { link, text, ... }. - const social = { canInvite, onChange: onStatusChange, createInvite: botId => call('social.createInvite', { botId }) } + const social = { canInvite, onChange: onStatusChange, createInvite: botId => call('social.createInvite', { botId }), openFaceToFace } globalThis.__flybot__.social = social if (globalThis.__flybot__.api) globalThis.__flybot__.api.social = social offs.push(() => { diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index 2f1ebedda..478b0e433 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -6,6 +6,8 @@ // Peer text is untrusted: fence it before anything model-visible. Platform system copy // (joined / left / started) is sanitized the same way as a display name. +import { socialFetch } from './mcp-client.js' + export const FACE_JOIN_CODE = /^\d{6}$/ export const FACE_MAX_MEMBERS = 8 export const FACE_TTL_MS = 10 * 60 * 1000 @@ -338,7 +340,7 @@ const normHandle = (handle) => { return `@${raw.replace(/^@+/, '').toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 120)}` } -export function createFaceRoomApi({ server, token, fetch: fetchImpl = globalThis.fetch, timeoutMs = CALL_TIMEOUT_MS } = {}) { +export function createFaceRoomApi({ server, token, fetch: fetchImpl = socialFetch, timeoutMs = CALL_TIMEOUT_MS } = {}) { if (!server) throw new Error('flybot-social: server URL is required') if (!token) throw new Error('flybot-social: owner connector token is required') const origin = String(server).replace(/\/+$/, '') diff --git a/plugins/social/src/hidden-remotes.js b/plugins/social/src/hidden-remotes.js index b7af34531..da7f9e325 100644 --- a/plugins/social/src/hidden-remotes.js +++ b/plugins/social/src/hidden-remotes.js @@ -1,28 +1,97 @@ // Local hide of a friend's Bot (#90): persist the handle, drop it from Friends' Bots, -// and show it again only after it leaves the granted list and comes back (re-grant). +// and show it again only after it leaves the granted list (two successful home +// fetches in a row, or an explicit revoke) and comes back (re-grant). +export const HIDE_GONE_FETCHES = 2 + +export function normHiddenHandle(handle) { + return `@${String(handle ?? '').trim().replace(/^@+/, '').toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 120)}` +} + +// A home identity (@bob.home), not a Bot (@coder.bob.home) and not a legacy per-bot +// identity (@bob.bot). Handle === home is not enough: legacy friends use that shape. +export function isHomeLevelHandle(handle) { + const key = normHiddenHandle(handle) + if (!key || key === '@') return false + const parts = key.slice(1).split('.') + return parts.length === 2 && parts[1] === 'home' +} + export function hideRemoteInSettings(settings, handle, at = Date.now()) { - const key = String(handle ?? '') - if (!key) return settings + const key = normHiddenHandle(handle) + if (!key || key === '@') return settings return { ...settings, - hiddenRemotes: { ...(settings?.hiddenRemotes ?? {}), [key]: { hiddenAt: at } }, + hiddenRemotes: { ...(settings?.hiddenRemotes ?? {}), [key]: { hiddenAt: at, absentFetches: 0 } }, } } export function isHiddenRemote(settings, handle) { - return Boolean(settings?.hiddenRemotes?.[String(handle ?? '')]) + const key = normHiddenHandle(handle) + const hidden = settings?.hiddenRemotes ?? {} + if (hidden[key] || hidden[String(handle ?? '')]) return true + return Object.keys(hidden).some(entry => normHiddenHandle(entry) === key) } -// A handle that vanished from list_remote_bots was revoked. Forget the hide so an -// explicit re-grant (it appears again) shows the Bot in Friends' Bots. -export function forgetHiddenIfGone(settings, presentHandles) { - const present = new Set((presentHandles ?? []).map(value => String(value ?? ''))) +// @coder.bob.home → @bob.home; @bob.home stays @bob.home. +export function homeOfHiddenHandle(handle) { + const key = normHiddenHandle(handle) + const parts = key.slice(1).split('.') + if (parts.length >= 2 && parts.at(-1) === 'home') return `@${parts.at(-2)}.home` + return key +} + +// Drop a hide at once (an explicit revoke event). Refresh still needs two successful +// absences before it forgets on its own. +export function clearHiddenRemote(settings, handle) { + const key = normHiddenHandle(handle) + if (!key || key === '@') return settings + const hidden = { ...(settings?.hiddenRemotes ?? {}) } + let changed = false + for (const entry of Object.keys(hidden)) { + if (normHiddenHandle(entry) === key) { + delete hidden[entry] + changed = true + } + } + if (!changed) return settings + return { ...settings, hiddenRemotes: hidden } +} + +// Forget a hide only after the Bot has been absent from ≥2 consecutive successful +// non-legacy list_remote_bots results. A failed, partial, cached, or legacy +// (list_friends / per-bot identity) fetch must not un-hide anyone: a Mac on a +// high-latency link was wiping hiddenRemotes to {} on the first refresh after ×. +export function forgetHiddenIfGone(settings, presentHandles, { source = 'home', ok = true, homes } = {}) { + if (!ok || source !== 'home') return settings + const present = new Set((presentHandles ?? []).map(normHiddenHandle).filter(key => key && key !== '@')) + const listedHomes = Array.isArray(homes) + ? new Set(homes.map(entry => normHiddenHandle(entry?.home ?? entry)).filter(key => key && key !== '@')) + : null const hidden = { ...(settings?.hiddenRemotes ?? {}) } let changed = false for (const handle of Object.keys(hidden)) { - if (!present.has(handle)) { + const key = normHiddenHandle(handle) + const rec = hidden[handle] && typeof hidden[handle] === 'object' ? hidden[handle] : { hiddenAt: 0 } + if (present.has(key)) { + if ((rec.absentFetches ?? 0) !== 0) { + hidden[handle] = { ...rec, absentFetches: 0 } + changed = true + } + continue + } + // An empty or home-missing result is partial: not evidence that this Bot left. + if (listedHomes) { + if (listedHomes.size === 0) continue + const homeKey = homeOfHiddenHandle(key) + if (homeKey && !listedHomes.has(homeKey)) continue + } + const n = (rec.absentFetches ?? 0) + 1 + if (n >= HIDE_GONE_FETCHES) { delete hidden[handle] changed = true + } else { + hidden[handle] = { ...rec, absentFetches: n } + changed = true } } if (!changed) return settings @@ -30,15 +99,14 @@ export function forgetHiddenIfGone(settings, presentHandles) { } export function filterHiddenHomes(homes, settings) { - const hidden = settings?.hiddenRemotes ?? {} return (homes ?? []).map(entry => ({ ...entry, - bots: (entry.bots ?? []).filter(bot => !hidden[String(bot?.handle ?? '')]), + bots: (entry.bots ?? []).filter(bot => !isHiddenRemote(settings, bot?.handle) && !isHomeLevelHandle(bot?.handle, entry.home)), })) } export function presentRemoteHandles(homes) { - return (homes ?? []).flatMap(entry => (entry.bots ?? []).map(bot => String(bot?.handle ?? '')).filter(Boolean)) + return (homes ?? []).flatMap(entry => (entry.bots ?? []).map(bot => normHiddenHandle(bot?.handle)).filter(key => key && key !== '@')) } export const WAITING_OWNER = 'Waiting for approval' diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index 88583f090..8b261593b 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -1,8 +1,95 @@ // A minimal client for a Bot Social server's owner MCP endpoint (POST /mcp, // stateless Streamable HTTP, JSON responses), authenticated by an owner connector token. // Only tools/call is used; see bot-social src/owner-mcp.ts for the tool set. +// One client instance is reused for the plugin lifetime (seq ids, no initialize per call). +// HTTPS to a remote Bot Social host pays ~2 s per fresh TCP+TLS, so every fetch shares +// one keep-alive http(s).Agent (Node builtins; no extra package). + +import http from 'node:http' +import https from 'node:https' const UNTRUSTED = /^[\s\S]*?\n([\s\S]*)\n[^\n]*$/ +const GRANT_CONCURRENCY = 4 +const KEEP_ALIVE_MS = 30_000 + +function makeAgent(Agent) { + return new Agent({ keepAlive: true, maxSockets: 8, keepAliveMsecs: KEEP_ALIVE_MS }) +} + +let sharedHttps +let sharedHttp + +export function socialHttpAgent() { + sharedHttps ??= makeAgent(https.Agent) + return sharedHttps +} + +function agentFor(url) { + if (String(url).startsWith('http:')) { + sharedHttp ??= makeAgent(http.Agent) + return sharedHttp + } + return socialHttpAgent() +} + +export function fetchOptions(init = {}) { + return { ...init, agent: init.agent ?? socialHttpAgent() } +} + +export function socialFetch(url, init = {}) { + const parsed = new URL(String(url)) + const lib = parsed.protocol === 'http:' ? http : https + const agent = init.agent ?? agentFor(url) + const method = String(init.method ?? 'GET').toUpperCase() + const headers = { ...(init.headers ?? {}) } + const signal = init.signal + return new Promise((resolve, reject) => { + const req = lib.request(parsed, { method, headers, agent }, (res) => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + const buf = Buffer.concat(chunks) + const headerMap = new Headers() + for (const [key, value] of Object.entries(res.headers)) { + if (value === undefined) continue + headerMap.set(key, Array.isArray(value) ? value.join(', ') : String(value)) + } + resolve(new Response(buf, { status: res.statusCode ?? 0, statusText: res.statusMessage ?? '', headers: headerMap })) + }) + }) + req.on('error', reject) + if (signal) { + const abort = () => { + req.destroy() + reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })) + } + if (signal.aborted) { abort(); return } + signal.addEventListener('abort', abort, { once: true }) + req.on('close', () => signal.removeEventListener('abort', abort)) + } + if (init.body) req.write(init.body) + req.end() + }) +} + +export async function mapPool(items, limit, fn) { + const list = [...items] + if (list.length === 0) return [] + const cap = Math.max(1, Math.min(limit || GRANT_CONCURRENCY, list.length)) + const out = new Array(list.length) + let next = 0 + async function worker() { + for (;;) { + const at = next++ + if (at >= list.length) return + out[at] = await fn(list[at], at) + } + } + await Promise.all(Array.from({ length: cap }, () => worker())) + return out +} + +export { GRANT_CONCURRENCY } export class BotSocialError extends Error { constructor(message, code) { @@ -13,13 +100,15 @@ export class BotSocialError extends Error { export const CALL_TIMEOUT_MS = 30_000 -export function createMcpClient({ server, token, fetch: fetchImpl = globalThis.fetch, timeoutMs = CALL_TIMEOUT_MS }) { +export function createMcpClient({ server, token, fetch: fetchImpl = socialFetch, timeoutMs = CALL_TIMEOUT_MS }) { if (!server) throw new Error('flybot-social: server URL is required') if (!token) throw new Error('flybot-social: owner connector token is required') const url = `${server.replace(/\/+$/, '')}/mcp` let seq = 0 async function callTool(name, args = {}) { // Every call has a deadline: a hung server is an error, never a stall. + // Stateless tools/call only: Bot Social's owner MCP does not need initialize, and + // this client is created once per plugin start (not per Friends' Bots refresh). const signal = AbortSignal.timeout(timeoutMs) let response let body @@ -98,7 +187,7 @@ export function normalizeGrants(value) { // Owner MCP + REST grant/invite helpers. Named methods so social.js never builds // URLs or tool names inline; a later server can add list_grants / revoke_grant // without touching the plugin's grant bookkeeping. -export function createSocialApi({ server, token, fetch: fetchImpl = globalThis.fetch, timeoutMs = CALL_TIMEOUT_MS, client } = {}) { +export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, timeoutMs = CALL_TIMEOUT_MS, client } = {}) { const mcp = client ?? createMcpClient({ server, token, fetch: fetchImpl, timeoutMs }) const base = String(server ?? '').replace(/\/+$/, '') diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 7dc61227f..35d6d7d75 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -30,7 +30,7 @@ import { chmodSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, wri import { readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, extname, isAbsolute, join, sep } from 'node:path' -import { createSocialApi, untrustedText } from './mcp-client.js' +import { createSocialApi, untrustedText, mapPool, GRANT_CONCURRENCY } from './mcp-client.js' import { createFaceRoomApi, isJoinCode, @@ -47,6 +47,7 @@ import { forgetHiddenIfGone, filterHiddenHomes, presentRemoteHandles, + isHomeLevelHandle, WAITING_OTHERS, REJECT_NOTE, } from './hidden-remotes.js' @@ -442,41 +443,66 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // ------------------------------------------------------------ remote roster let remote = { homes: [] } let fetchedAt = 0 + function asRemoteHomes(entries) { + return (entries ?? []).map(entry => { + const homeHandle = norm(entry.home.handle) + return { + home: homeHandle, + name: cleanText(entry.home.display_name), + owner: cleanText(entry.home.owner_display_name), + bots: (entry.bots ?? []).map(bot => ({ + handle: norm(bot.handle), + name: cleanText(bot.display_name, norm(bot.handle)), + home: homeHandle, + remote: true, + })).filter(bot => !isHomeLevelHandle(bot.handle, homeHandle)), + // The friendship covers only these local members (an invite made for one Bot, or joined + // as one Bot); empty when it covers this whole home. Only they can talk to this friend. + via: (entry.via ?? []).map(norm), + } + }) + } + function listedRemoteBots() { + return remote.homes.flatMap(entry => entry.bots.map(bot => ({ handle: bot.handle, name: bot.name, home: entry.home, homeName: entry.name, owner: entry.owner }))) + } + function applyRemoteRoster(unfiltered, { source, ok }) { + const next = forgetHiddenIfGone(settings, presentRemoteHandles(unfiltered.homes), { source, ok, homes: unfiltered.homes }) + if (next !== settings) { settings = next; saveSettings() } + remote = { homes: filterHiddenHomes(unfiltered.homes, settings) } + // Hidden and home-level handles stay out of publish, so remote-chat sync marks those + // sessions gone/hidden instead of putting them back in the picker and sidebar. + return flybot.remote?.publish(listedRemoteBots()) ?? remote + } async function remoteBots({ fresh = false } = {}) { if (!fresh && now() - fetchedAt < REMOTE_TTL_MS) return remote let result - if (home) { - result = await client.callTool('list_remote_bots', { home: home.id }) - remote = { - homes: (result?.homes ?? []).map(entry => ({ - home: norm(entry.home.handle), - name: cleanText(entry.home.display_name), - owner: cleanText(entry.home.owner_display_name), - bots: entry.bots.map(bot => ({ handle: norm(bot.handle), name: cleanText(bot.display_name, norm(bot.handle)), home: norm(entry.home.handle), remote: true })), - // The friendship covers only these local members (an invite made for one Bot, or joined - // as one Bot); empty when it covers this whole home. Only they can talk to this friend. - via: (entry.via ?? []).map(norm), - })), - } - } else { - // Per-bot identity (legacy): each active friend is its own "home" of one Bot. - const overview = await client.callTool('list_friends', config.botId ? { bot_id: config.botId } : {}) - remote = { - homes: (overview?.friends ?? []).filter(item => item.status === 'active').map(item => ({ - home: norm(item.peer?.handle), name: cleanText(item.peer?.display_name), owner: cleanText(item.peer?.owner_display_name), - bots: [{ handle: norm(item.peer?.handle), name: cleanText(item.peer?.display_name, norm(item.peer?.handle)), home: norm(item.peer?.handle), remote: true, conversationId: item.last_conversation?.status === 'active' ? item.last_conversation.conversation_id : undefined }], - })), + const source = home ? 'home' : 'legacy' + let unfiltered + try { + if (home) { + result = await client.callTool('list_remote_bots', { home: home.id }) + unfiltered = { homes: asRemoteHomes(result?.homes) } + } else { + // Per-bot identity (legacy): each active friend is its own "home" of one Bot. + const overview = await client.callTool('list_friends', config.botId ? { bot_id: config.botId } : {}) + unfiltered = { + homes: (overview?.friends ?? []).filter(item => item.status === 'active').map(item => ({ + home: norm(item.peer?.handle), name: cleanText(item.peer?.display_name), owner: cleanText(item.peer?.owner_display_name), + bots: [{ handle: norm(item.peer?.handle), name: cleanText(item.peer?.display_name, norm(item.peer?.handle)), home: norm(item.peer?.handle), remote: true, conversationId: item.last_conversation?.status === 'active' ? item.last_conversation.conversation_id : undefined }], + })), + } } + } catch (error) { + warn('remote roster fetch failed', error) + if (fetchedAt || remote.homes.length) return remote + throw error } fetchedAt = now() if (home) void syncServerAutoApprove((result?.homes ?? [])) - await syncOutgoingGrants().catch(error => warn('grants not synced', error)) - // A Bot that left the granted list was revoked: forget the hide so a later re-grant shows. - const next = forgetHiddenIfGone(settings, presentRemoteHandles(remote.homes)) - if (next !== settings) { settings = next; saveSettings() } - remote = { homes: filterHiddenHomes(remote.homes, settings) } - // Friends' Bots become chat members (remote badge) through the FlyBot registry. - flybot.remote?.publish(remote.homes.flatMap(entry => entry.bots.map(bot => ({ handle: bot.handle, name: bot.name, home: entry.home, homeName: entry.name, owner: entry.owner })))) + // Grant listing is N HTTP calls; do not block Friends' Bots on it. + void syncOutgoingGrants().catch(error => warn('grants not synced', error)) + const published = applyRemoteRoster(unfiltered, { source, ok: true }) + if (published && typeof published.then === 'function') await published.catch(error => warn('remote chats not synced', error)) return remote } const remoteBot = async handle => (await remoteBots()).homes.flatMap(entry => entry.bots).find(bot => bot.handle === norm(handle)) @@ -530,7 +556,6 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } async function afterHideRemote(key) { await leaveRoomsWith(key) - fetchedAt = 0 try { await remoteBots({ fresh: true }) } catch (error) { @@ -543,9 +568,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp settings = hideRemoteInSettings(settings, key, now()) saveSettings() remote = { homes: filterHiddenHomes(remote.homes, settings) } - flybot.remote?.publish(remote.homes.flatMap(entry => entry.bots.map(bot => ({ - handle: bot.handle, name: bot.name, home: entry.home, homeName: entry.name, owner: entry.owner, - })))) + const published = flybot.remote?.publish(listedRemoteBots()) + if (published && typeof published.then === 'function') await published.catch(error => warn('hidden remote chat not synced', error)) const leftRooms = roomsHolding(key) void afterHideRemote(key).catch(error => warn('hide-remote follow-up failed', error)) return { handle: key, hidden: true, leftRooms, homes: remote.homes } @@ -575,9 +599,12 @@ export function createSocial(service, config = {}, { client: given, api: givenAp async function syncOutgoingGrants() { if (typeof flybot.grants?.set !== 'function') return - for (const bot of flybot.bot.list()) { + const bots = flybot.bot.list().filter(bot => { + const member = memberOf(bot.id) + return member && !member.roomOnly + }) + await mapPool(bots, GRANT_CONCURRENCY, async (bot) => { const member = memberOf(bot.id) - if (!member || member.roomOnly) continue try { const listed = await api.listGrants({ botId: member.bot_id }) const roomScoped = (flybot.grants.of?.(bot.id) ?? []).filter(grant => grant.roomId) @@ -591,7 +618,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } catch (error) { warn(`${bot.name} grants not synced`, error) } - } + }) } async function setRoomGrant(botId, room, { faceRoomId, name } = {}) { @@ -914,7 +941,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // What the browser needs to offer invites: connected or not, which local Bots can be // invited to, and any outbound drafts Bot Social is still holding for this owner. async function socialStatus() { - const approvals = await pendingApprovals() + const [approvals, autoApproveFaceRooms] = await Promise.all([ + pendingApprovals(), + faceAutoApprove().catch(() => false), + ]) return { connected: Boolean(home), home: home ? norm(home.handle) : undefined, @@ -925,7 +955,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp })) : [], pendingApprovals: approvals.length, approvals, - autoApproveFaceRooms: await faceAutoApprove().catch(() => false), + autoApproveFaceRooms, } } disposers.push(flybot.api.handle('social/status', socialStatus, { read: true })) @@ -1337,15 +1367,17 @@ export function createSocial(service, config = {}, { client: given, api: givenAp try { const listed = await faceApi.list() const known = new Set(listed.map(item => item.roomId)) - for (const snapshot of listed) { + const got = await mapPool(listed, GRANT_CONCURRENCY, async (snapshot) => { try { const meta = settings.faceRooms[snapshot.roomId] await pollOneFace(snapshot.roomId, meta) - n += 1 + return 1 } catch (error) { warn('face-room get failed', error) + return 0 } - } + }) + n += got.reduce((sum, count) => sum + count, 0) for (const [serverId, meta] of Object.entries(settings.faceRooms)) { if (known.has(serverId)) continue if (meta?.localRoomId) await flybot.rooms.dropFace?.(meta.localRoomId).catch(() => {}) @@ -1432,8 +1464,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp })) } disposers.push(flybot.api.handle('social/face-join', async ({ code, botId } = {}) => joinFaceRoom({ code, botId }))) - disposers.push(flybot.api.handle('social/face-rooms', async () => { - await pollFaceRooms().catch(() => {}) + disposers.push(flybot.api.handle('social/face-rooms', async (payload) => { + if (payload?.poll !== false) await pollFaceRooms().catch(() => {}) const rooms = Object.entries(settings.faceRooms).map(([faceRoomId, meta]) => { const room = flybot.rooms.ofFace?.(faceRoomId) return viewHostedFace(room, { faceRoomId, localRoomId: meta.localRoomId, cursor: meta.cursor }) || { faceRoomId, roomId: meta.localRoomId, status: 'candidate' } diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index e6442f2b4..3ed0aa4f0 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -9,9 +9,10 @@ import { apply as applyDsBot } from 'ds-bot' import { fakeContext } from './fake-ctx.js' import { apply as applySocial, inject, name } from '../index.js' import { approvalView, createSocial, draftText, homeHandleOf, onboard, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js' -import { createMcpClient, createSocialApi, untrustedText } from '../src/mcp-client.js' +import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions } from '../src/mcp-client.js' import { hideRemoteInSettings, forgetHiddenIfGone, filterHiddenHomes, presentRemoteHandles, + HIDE_GONE_FETCHES, isHomeLevelHandle, isHiddenRemote, clearHiddenRemote, homeOfHiddenHandle, approvalsForSession, chatApprovalTitle, WAITING_OWNER, WAITING_OTHERS, REJECT_NOTE, } from '../src/hidden-remotes.js' @@ -491,7 +492,7 @@ test('client.js registers a sidebar section and @ rows into the FlyBot registry' runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) const plugin = registered[0].factory(() => ({ createElement: () => null, useEffect() {}, useState: v => [v, () => {}] })) plugin.apply({}) - assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.composer.mention#social', 'flybot.bot.details#social-invite', 'flybot.room.overview#social-face', 'flybot.session.dock#social-approval']) + assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.composer.mention#social', 'flybot.bot.details#social-invite', 'flybot.room.overview#social-face', 'flybot.session.dock#social-approval']) // Transport errors are worded in English from their code, never the localized host text. assert.match(plugin.errorText(Object.assign(new Error('请求失败(HTTP 404)'), { code: 'HTTP_404' })), /^The FlyBot host did not answer \(HTTP 404\)/) assert.equal(plugin.errorText(Object.assign(new Error('x'), { code: 'bot/refused' })), 'x') @@ -502,7 +503,7 @@ test('client.js registers a sidebar section and @ rows into the FlyBot registry' assert.equal(typeof sandbox.__flybot__.social.canInvite, 'function') assert.equal(sandbox.__flybot__.social.canInvite('a'), false) assert.equal(typeof sandbox.__flybot__.social.onChange, 'function') // status-change event, so callers need not poll - const rows = await entries[1][1].candidates('cod') + const rows = await entries.find(([slot]) => slot === 'flybot.composer.mention')[1].candidates('cod') assert.deepEqual(JSON.parse(JSON.stringify(rows)), [{ name: 'Coder · remote', description: '@coder.bob.home · @bob.home', section: 'Friends\' Bots', value: '@coder.bob.home ' }]) }) @@ -1266,6 +1267,26 @@ function loadSocialClient(callImpl) { return { registered, entries, walk, pluginOf, h } } +test('social HTTP keep-alive reuses one Agent', () => { + const agent = socialHttpAgent() + assert.equal(socialHttpAgent(), agent) + assert.equal(agent.keepAlive, true) + assert.equal(fetchOptions({ method: 'POST' }).agent, agent) + assert.equal(fetchOptions({ agent: 'x' }).agent, 'x') +}) + +test('mapPool runs work concurrently up to the cap', async () => { + let inflight = 0 + let max = 0 + await mapPool([1, 2, 3, 4], 2, async () => { + inflight += 1 + max = Math.max(max, inflight) + await new Promise(resolve => setTimeout(resolve, 20)) + inflight -= 1 + }) + assert.equal(max, 2) +}) + test('Friends\' Bots (#92): Face-to-face group is under + Add; the inline join form is gone', () => { const sidebar = (adding) => { const { entries, walk, pluginOf } = loadSocialClient(async () => ({ connected: true, bots: [{ id: 'a', name: 'Chief', isMain: true }], homes: [], rooms: [] })) @@ -1284,6 +1305,67 @@ test('Friends\' Bots (#92): Face-to-face group is under + Add; the inline join f assert.ok(opened.nodes.some(n => n.type === 'button' && n.props['data-flybot'] === 'face-group-open' && n.children.includes('Face-to-face group'))) assert.equal(opened.nodes.some(n => n.type === 'button' && n.children.includes('Join with a number')), false) assert.equal(opened.nodes.some(n => n.props['data-flybot'] === 'face-join'), false) + + const loading = (() => { + const { entries, walk, pluginOf } = loadSocialClient(async () => ({ connected: true, bots: [], homes: [], rooms: [] })) + const plugin = pluginOf([null, '', true, true, {}, {}, '', [], new Set(), false, false]) + plugin.apply({}) + const entry = entries.find(([slot]) => slot === 'flybot.sidebar.section') + return walk(entry[1].component({})) + })() + assert.ok(loading.some(n => n.props['data-flybot'] === 'face-group-entry'), 'Face-to-face group must show while the roster is still loading') + assert.ok(loading.some(n => n.type === 'button' && n.children.includes('+ Add') || n.children.includes('Done'))) + assert.equal(loading.some(n => n.type === 'div' && n.children.includes('Loading…')), false) +}) + +test('Friends\' Bots mount starts remote-bots, settings, and status in parallel', async () => { + const started = [] + let resolved = 0 + const { registered, entries, walk } = loadSocialClient(async (endpoint) => { + started.push(endpoint) + assert.equal(resolved, 0, `${endpoint} started after another Friends' Bots call finished`) + await new Promise(resolve => setTimeout(resolve, 25)) + resolved += 1 + if (endpoint === 'social/remote-bots') return { homes: [] } + if (endpoint === 'social/settings') return { autoAccept: {} } + if (endpoint === 'social/status') return { approvals: [], bots: [] } + if (endpoint === 'social/face-rooms') return { rooms: [] } + return {} + }) + const hooks = [] + let hookI = 0 + const plugin = registered[0].factory(() => ({ + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }), + Fragment: 'F', + useEffect: fn => { fn?.() }, + useState: (init) => { + const i = hookI++ + if (hooks[i] === undefined) hooks[i] = typeof init === 'function' ? init() : init + return [hooks[i], (value) => { hooks[i] = typeof value === 'function' ? value(hooks[i]) : value }] + }, + useRef: v => ({ current: v }), + useMemo: f => f(), + })) + plugin.apply({}) + const entry = entries.find(([slot]) => slot === 'flybot.sidebar.section') + hookI = 0 + walk(entry[1].component({})) + assert.ok(started.includes('social/remote-bots')) + assert.ok(started.includes('social/settings')) + assert.ok(started.includes('social/status')) + const trio = ['social/remote-bots', 'social/settings', 'social/status'] + assert.deepEqual(started.filter(name => trio.includes(name)).slice(0, 3).sort(), trio.slice().sort()) +}) + +test('the sidebar + menu gets a Face-to-face group action from social', () => { + const { entries, pluginOf } = loadSocialClient(async () => ({})) + pluginOf([]).apply({}) + const row = entries.find(([slot, entry]) => slot === 'flybot.new-chat.action' && entry.id === 'social-face-group') + assert.ok(row) + assert.equal(row[1].label, 'Face-to-face group') + assert.match(row[1].hint, /6-digit/) + assert.equal(row[1].order, 15) + assert.equal(typeof row[1].run, 'function') }) test('face-to-face auto-send maps to PATCH /v1/me and swallows confirmation', async (t) => { @@ -1720,16 +1802,35 @@ test('face-to-face join registration failures are friendly and leave no leftover assert.equal(scope.fields.get('b', 'member').handle, '@writer.danyang.home', 'an identity already in a room is kept when another join fails') }) -test('#90: hide helpers persist a handle and forget it only after it leaves the granted list', () => { - let settings = hideRemoteInSettings({ autoAccept: {} }, '@coder.bob.home', 10) +test('#90: hide helpers persist a handle and forget it only after two successful home fetches', () => { + let settings = hideRemoteInSettings({ autoAccept: {} }, 'Coder.Bob.Home', 10) assert.equal(settings.hiddenRemotes['@coder.bob.home'].hiddenAt, 10) - const homes = [{ home: '@bob.home', bots: [{ handle: '@coder.bob.home' }, { handle: '@writer.bob.home' }] }] + assert.equal(isHiddenRemote(settings, '@CODER.bob.home'), true) + assert.equal(isHomeLevelHandle('@friend.home', '@friend.home'), true) + assert.equal(isHomeLevelHandle('@coder.bob.home', '@bob.home'), false) + assert.equal(isHomeLevelHandle('@bob.bot', '@bob.bot'), false) + const homes = [{ home: '@bob.home', bots: [{ handle: '@coder.bob.home' }, { handle: '@writer.bob.home' }, { handle: '@bob.home', name: 'Friend' }] }] assert.deepEqual(filterHiddenHomes(homes, settings)[0].bots.map(b => b.handle), ['@writer.bob.home']) - assert.deepEqual(presentRemoteHandles(homes), ['@coder.bob.home', '@writer.bob.home']) + assert.ok(presentRemoteHandles(homes).includes('@coder.bob.home')) const still = forgetHiddenIfGone(settings, ['@coder.bob.home', '@writer.bob.home']) assert.ok(still.hiddenRemotes['@coder.bob.home']) - const gone = forgetHiddenIfGone(settings, ['@writer.bob.home']) + const once = forgetHiddenIfGone(settings, ['@writer.bob.home']) + assert.ok(once.hiddenRemotes['@coder.bob.home']) + assert.equal(once.hiddenRemotes['@coder.bob.home'].absentFetches, 1) + const legacy = forgetHiddenIfGone(once, [], { source: 'legacy' }) + assert.ok(legacy.hiddenRemotes['@coder.bob.home']) + const failed = forgetHiddenIfGone(once, [], { ok: false }) + assert.ok(failed.hiddenRemotes['@coder.bob.home']) + const gone = forgetHiddenIfGone(once, ['@writer.bob.home']) assert.equal(gone.hiddenRemotes['@coder.bob.home'], undefined) + assert.equal(HIDE_GONE_FETCHES, 2) + assert.equal(homeOfHiddenHandle('@coder.bob.home'), '@bob.home') + const empty = forgetHiddenIfGone(once, [], { homes: [] }) + assert.ok(empty.hiddenRemotes['@coder.bob.home'], 'an empty successful fetch is partial and must not forget') + const otherHome = forgetHiddenIfGone(once, ['@writer.eve.home'], { homes: [{ home: '@eve.home' }] }) + assert.ok(otherHome.hiddenRemotes['@coder.bob.home'], 'a different home in the result is not evidence this Bot left') + const cleared = clearHiddenRemote(settings, '@CODER.bob.home') + assert.equal(cleared.hiddenRemotes['@coder.bob.home'], undefined) const reshared = filterHiddenHomes(homes, gone) assert.deepEqual(reshared[0].bots.map(b => b.handle), ['@coder.bob.home', '@writer.bob.home']) }) @@ -1771,6 +1872,15 @@ test('#90: hide a friend\'s Bot updates the list, leaves rooms, and does not unf }) const saved = JSON.parse(readFileSync(settingsPath, 'utf8')) assert.ok(saved.hiddenRemotes['@coder.bob.home']) + const state = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + const published = Object.values(state.flybot?.remote ?? {}).flat() + assert.equal(published.some(bot => bot.handle === '@coder.bob.home'), false, 'hidden handles stay out of flybot.remote.publish') + assert.equal(published.some(bot => bot.handle === '@writer.bob.home'), true) + const hiddenChat = Object.values(state.bots ?? {}).find(bot => bot.remote?.handle === '@coder.bob.home') + if (hiddenChat) { + assert.equal(hiddenChat.hidden, true) + assert.equal(hiddenChat.remote.gone, true) + } const again = (await call('flybot/social/remote-bots', { fresh: true })).value assert.equal(again.homes[0].bots.some(b => b.handle === '@coder.bob.home'), false, 'still hidden after refresh') }) @@ -1856,6 +1966,58 @@ test('#90: hide-remote leaves independent rooms in parallel and does not wait fo await waitUntil(() => ctx.flybot.rooms.list().every(room => !(room.participants ?? []).some(p => p.handle === '@coder.bob.home' && p.leftSeq === undefined))) }) +test('a hidden Bot survives a failed fetch and a legacy list_friends refresh', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + const settingsPath = join(home, 'flybot-social-settings.json') + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + assert.equal((await call('flybot/social/hide-remote', { handle: '@coder.bob.home' })).value.hidden, true) + assert.equal((await call('flybot/social/remote-bots', { fresh: true })).value.homes[0].bots.some(b => b.handle === '@coder.bob.home'), false) + assert.ok(JSON.parse(readFileSync(settingsPath, 'utf8')).hiddenRemotes['@coder.bob.home']) + const list = server.tools.list_remote_bots + server.tools.list_remote_bots = () => { throw new Error('Bot Social list_remote_bots: timeout') } + const afterFail = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(afterFail.homes[0].bots.some(b => b.handle === '@coder.bob.home'), false) + assert.ok(JSON.parse(readFileSync(settingsPath, 'utf8')).hiddenRemotes['@coder.bob.home']) + server.tools.list_remote_bots = list + const kept = JSON.parse(readFileSync(settingsPath, 'utf8')) + const afterLegacy = forgetHiddenIfGone(kept, [], { source: 'legacy' }) + assert.ok(afterLegacy.hiddenRemotes['@coder.bob.home'], 'legacy list_friends must not forget a hide') +}) + +test('remote-bots does not wait for per-Bot grant listing', async (t) => { + const { ctx, stop } = await team() + t.after(stop) + const server = mockServer() + const base = createSocialApi({ server: 'https://s', token: 'owner-token', fetch: server.fetch }) + let grantsDone = false + const api = { + ...base, + listGrants: async () => { + await new Promise(resolve => setTimeout(resolve, 80)) + grantsDone = true + return [] + }, + } + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + api, + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + const listed = await social.remoteBots({ fresh: true }) + assert.equal(grantsDone, false) + assert.equal(listed.homes[0].bots.some(b => b.handle === '@coder.bob.home'), true) +}) + test('#90: a hidden Bot shows again only after the owner re-shares it', async (t) => { const { ctx, call, home, stop } = await team() t.after(stop) @@ -1875,6 +2037,9 @@ test('#90: a hidden Bot shows again only after the owner re-shares it', async (t // Owner revoked: the Bot leaves the granted list, so the hide is forgotten. list = [{ relationship_id: 'r1', home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob' }, bots: [{ handle: '@writer.bob.home', display_name: 'Writer' }] }] assert.equal((await call('flybot/social/remote-bots', { fresh: true })).value.homes[0].bots.some(b => b.handle === '@coder.bob.home'), false) + const midOnce = JSON.parse(readFileSync(settingsPath, 'utf8')) + assert.ok(midOnce.hiddenRemotes?.['@coder.bob.home'], 'one missing fetch must not forget the hide') + assert.equal((await call('flybot/social/remote-bots', { fresh: true })).value.homes[0].bots.some(b => b.handle === '@coder.bob.home'), false) const mid = JSON.parse(readFileSync(settingsPath, 'utf8')) assert.equal(mid.hiddenRemotes?.['@coder.bob.home'], undefined) // Explicit re-share: it is on the list again and must show.